@emilia-protocol/gate 0.8.0 → 0.9.1

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/enterprise.js ADDED
@@ -0,0 +1,174 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EMILIA Gate — enterprise entitlement layer (EP-GATE-ENTITLEMENT-v1).
4
+ *
5
+ * The license key IS an EP-style artifact: a signed entitlement — Ed25519 over
6
+ * canonical JSON (sorted keys, same idiom as receipts/evidence) of
7
+ * { org, tier, features[], limits, not_before, expires_at, kid }. Verifiers pin
8
+ * issuer keys by kid; nothing in the artifact is trusted until the signature
9
+ * verifies against a pinned key.
10
+ *
11
+ * OPEN-CORE SEMANTICS — two different fail directions, by design:
12
+ * - The CORE gate is never bricked. No entitlement, an expired one, a
13
+ * tampered one, an unknown kid — all resolve to { valid:false,
14
+ * tier:'community' } with a machine-readable reason. Community tier always
15
+ * works; a licensing failure can never block the firewall itself.
16
+ * - Enterprise FEATURES fail closed. `requireFeature` returns true ONLY for
17
+ * a cryptographically valid, in-window entitlement that explicitly lists
18
+ * the feature. Everything else — including community fallback — is false.
19
+ *
20
+ * Pure functions: inputs in, verdict out. Time is injected (`now`), never read
21
+ * from the wall clock implicitly, so verification is deterministic.
22
+ */
23
+ import crypto from 'node:crypto';
24
+
25
+ export const ENTITLEMENT_VERSION = 'EP-GATE-ENTITLEMENT-v1';
26
+ export const ENTITLEMENT_TIERS = ['community', 'team', 'business', 'enterprise', 'regulated'];
27
+
28
+ /** The tier every failure path resolves to — the gate keeps working on it. */
29
+ const COMMUNITY = 'community';
30
+
31
+ /** Canonical JSON (recursive sorted keys) — matches @emilia-protocol/verify. */
32
+ function canonical(v) {
33
+ if (v === null || v === undefined) return JSON.stringify(v);
34
+ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
35
+ if (typeof v === 'object') {
36
+ return `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonical(v[k])).join(',')}}`;
37
+ }
38
+ return JSON.stringify(v);
39
+ }
40
+
41
+ function toMs(t) {
42
+ if (t == null) return null;
43
+ const ms = typeof t === 'number' ? t : Date.parse(t);
44
+ return Number.isFinite(ms) ? ms : null;
45
+ }
46
+
47
+ /** Community fallback — every refusal shape is identical and machine-readable. */
48
+ function community(reason, extra = {}) {
49
+ return { valid: false, tier: COMMUNITY, features: [], limits: null, reason, ...extra };
50
+ }
51
+
52
+ /**
53
+ * Mint a signed entitlement (test/ops helper — the licensing service, not the
54
+ * verifier, holds the private key). Throws on invalid fields: a malformed
55
+ * license must never be issued, only refused.
56
+ * @param {crypto.KeyObject} privateKey Ed25519 private key
57
+ * @param {object} fields { org, tier, features?, limits?, not_before, expires_at, kid }
58
+ * @returns {{ '@version': string, payload: object, signature: { algorithm: 'Ed25519', value: string } }}
59
+ */
60
+ export function mintEntitlement(privateKey, {
61
+ org, tier, features = [], limits = {}, not_before, expires_at, kid,
62
+ } = {}) {
63
+ if (!org || typeof org !== 'string') throw new Error('entitlement: org is required');
64
+ if (!ENTITLEMENT_TIERS.includes(tier)) {
65
+ throw new Error(`entitlement: unknown tier "${tier}" (expected one of ${ENTITLEMENT_TIERS.join('|')})`);
66
+ }
67
+ if (!Array.isArray(features) || features.some((f) => typeof f !== 'string')) {
68
+ throw new Error('entitlement: features must be an array of strings');
69
+ }
70
+ if (!limits || typeof limits !== 'object' || Array.isArray(limits)) {
71
+ throw new Error('entitlement: limits must be an object');
72
+ }
73
+ if (toMs(not_before) == null) throw new Error('entitlement: not_before is required (ISO or ms)');
74
+ if (toMs(expires_at) == null) throw new Error('entitlement: expires_at is required (ISO or ms)');
75
+ if (!kid || typeof kid !== 'string') throw new Error('entitlement: kid is required');
76
+ const payload = { org, tier, features, limits, not_before, expires_at, kid };
77
+ const value = crypto.sign(null, Buffer.from(canonical(payload), 'utf8'), privateKey).toString('base64url');
78
+ return { '@version': ENTITLEMENT_VERSION, payload, signature: { algorithm: 'Ed25519', value } };
79
+ }
80
+
81
+ /** Resolve a base64url SPKI-DER key for `kid` from a map or an entry list. */
82
+ function issuerKeyFor(issuerKeys, kid) {
83
+ if (!issuerKeys) return null;
84
+ if (Array.isArray(issuerKeys)) {
85
+ const e = issuerKeys.find((x) => x && x.kid === kid && typeof x.key === 'string');
86
+ return e ? e.key : null;
87
+ }
88
+ const k = issuerKeys[kid];
89
+ return typeof k === 'string' ? k : null;
90
+ }
91
+
92
+ /**
93
+ * Verify an entitlement. NEVER throws for a bad artifact — every failure
94
+ * resolves to the community tier with a machine-readable reason, so a licensing
95
+ * problem degrades gracefully instead of bricking the gate. Enterprise features
96
+ * remain gated by `requireFeature`, which fails closed on any non-valid result.
97
+ *
98
+ * @param {object|string|null} entitlementJson the artifact (object or JSON string); absence -> community
99
+ * @param {object} o
100
+ * @param {object|Array<{kid:string,key:string}>} o.issuerKeys pinned kid -> base64url SPKI-DER public key
101
+ * @param {number|string|function} [o.now=Date.now] injected clock (ms, ISO, or () => ms)
102
+ * @returns {{ valid: boolean, tier: string, features: string[], limits: object|null, reason: string, org?: string, kid?: string }}
103
+ */
104
+ export function verifyEntitlement(entitlementJson, { issuerKeys, now = Date.now } = {}) {
105
+ // Absence is NOT an error: the open-core floor. Community keeps working.
106
+ if (entitlementJson == null || entitlementJson === '') return community('no_entitlement');
107
+
108
+ let doc = entitlementJson;
109
+ if (typeof doc === 'string') {
110
+ try { doc = JSON.parse(doc); } catch { return community('entitlement_unparseable'); }
111
+ }
112
+ if (!doc || typeof doc !== 'object') return community('entitlement_malformed');
113
+ if (doc['@version'] !== ENTITLEMENT_VERSION) return community('unsupported_version');
114
+
115
+ const p = doc.payload;
116
+ const sig = doc.signature;
117
+ if (!p || typeof p !== 'object' || !sig || typeof sig !== 'object') return community('entitlement_malformed');
118
+ if (sig.algorithm !== 'Ed25519' || typeof sig.value !== 'string') return community('unsupported_algorithm');
119
+ if (!ENTITLEMENT_TIERS.includes(p.tier)) return community('unknown_tier');
120
+ if (!Array.isArray(p.features) || p.features.some((f) => typeof f !== 'string')) return community('entitlement_malformed');
121
+
122
+ // Issuer pinning: the kid must resolve to a PINNED key. An entitlement can
123
+ // never nominate its own key — unknown kid (or no pins at all) fails closed.
124
+ const keyB64 = issuerKeyFor(issuerKeys, p.kid);
125
+ if (!keyB64) return community('unknown_kid', { kid: p.kid ?? null });
126
+
127
+ let ok = false;
128
+ try {
129
+ const pub = crypto.createPublicKey({ key: Buffer.from(keyB64, 'base64url'), format: 'der', type: 'spki' });
130
+ ok = crypto.verify(null, Buffer.from(canonical(p), 'utf8'), pub, Buffer.from(sig.value, 'base64url'));
131
+ } catch { ok = false; }
132
+ // One reason covers both tampering and a wrong key: the signature does not
133
+ // verify against the pinned key for this kid.
134
+ if (!ok) return community('bad_signature', { kid: p.kid });
135
+
136
+ // Validity window — checked only AFTER the signature, so the timestamps
137
+ // themselves are authenticated. Both bounds are required; an unparseable
138
+ // window fails closed.
139
+ const nowMs = typeof now === 'function' ? now() : toMs(now);
140
+ const nbf = toMs(p.not_before);
141
+ const exp = toMs(p.expires_at);
142
+ if (nbf == null || exp == null || nowMs == null) return community('invalid_validity_window', { kid: p.kid });
143
+ if (nowMs < nbf) return community('not_yet_valid', { kid: p.kid, not_before: p.not_before });
144
+ if (nowMs > exp) return community('expired', { kid: p.kid, expires_at: p.expires_at });
145
+
146
+ return {
147
+ valid: true,
148
+ tier: p.tier,
149
+ features: p.features.slice(),
150
+ limits: (p.limits && typeof p.limits === 'object') ? { ...p.limits } : {},
151
+ reason: 'entitlement_verified',
152
+ org: p.org,
153
+ kid: p.kid,
154
+ not_before: p.not_before,
155
+ expires_at: p.expires_at,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Is `feature` licensed? FAIL CLOSED: true only for a valid entitlement that
161
+ * explicitly lists the feature. Community fallback, invalid/expired/tampered
162
+ * artifacts, and unlisted features are all false — no enterprise code path
163
+ * runs without a live license naming it.
164
+ * @param {object} verified the result of verifyEntitlement
165
+ * @param {string} feature
166
+ * @returns {boolean}
167
+ */
168
+ export function requireFeature(verified, feature) {
169
+ if (!verified || verified.valid !== true) return false;
170
+ if (typeof feature !== 'string' || feature.length === 0) return false;
171
+ return Array.isArray(verified.features) && verified.features.includes(feature);
172
+ }
173
+
174
+ export default { mintEntitlement, verifyEntitlement, requireFeature, ENTITLEMENT_VERSION, ENTITLEMENT_TIERS };
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
- import { buildReliancePacket } from './reliance-packet.js';
40
- import { createEg1Harness, makeGateInvoke, runEg1, EG1_DEFAULT_SELECTOR } from './eg1-conformance.js';
50
+ import { buildReliancePacket, ADMISSIBILITY_VERDICTS } from './reliance-packet.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';
@@ -61,21 +72,151 @@ export {
61
72
  validateActionControlManifest,
62
73
  } from './action-control-manifest.js';
63
74
  export { EXECUTION_BINDING_VERSION, canonicalize, hashCanonical, materialFieldsFor, verifyExecutionBinding } from './execution-binding.js';
64
- export { RELIANCE_PACKET_VERSION, buildReliancePacket } from './reliance-packet.js';
75
+ export { RELIANCE_PACKET_VERSION, ADMISSIBILITY_VERDICTS, 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
+ * Verify a PRE-COMPUTED reliance packet's admissibility block against a profile
86
+ * the relying party PINNED. This is the gate's whole job re: admissibility — it
87
+ * does NOT re-evaluate raw evidence and does NOT define the bar. The relying
88
+ * party's own evaluator (lib/evidence/admissibility-profiles.js) computes the
89
+ * verdict OFFLINE against its pinned profile and produces the reliance packet;
90
+ * the gate only confirms the packet answers the SAME profile_hash and carries an
91
+ * 'admissible' verdict. Pure, dependency-light, fail-closed.
92
+ *
93
+ * @param {{id?:string, profile_hash:string}} pinned the profile the relying party requires
94
+ * @param {object|null} presented a reliance packet, or its `.admissibility` block,
95
+ * as produced by buildReliancePacket / the relying party's evaluator
96
+ * @returns {{ok:boolean, reason:string|null, pinned_hash:string, presented_hash:string|null, verdict:string|null}}
97
+ * ok:true ONLY when the presented profile_hash equals the pinned hash AND the
98
+ * verdict is exactly 'admissible'. Every other case fails closed with a distinct reason.
99
+ */
100
+ export function verifyAdmissibilityAgainstPinnedProfile(pinned, presented) {
101
+ const pinnedHash = pinned && typeof pinned.profile_hash === 'string' ? pinned.profile_hash : null;
102
+ // A pin with no hash is a misconfiguration: refuse, do not silently pass.
103
+ if (!pinnedHash) {
104
+ return { ok: false, reason: 'pinned_profile_missing_hash', pinned_hash: null, presented_hash: null, verdict: null };
105
+ }
106
+ // Accept either a full reliance packet (has .admissibility) or the block itself.
107
+ const adm = presented && typeof presented === 'object'
108
+ ? (presented.admissibility !== undefined ? presented.admissibility : presented)
109
+ : null;
110
+ if (!adm || typeof adm !== 'object') {
111
+ return { ok: false, reason: 'admissibility_profile_pinned_but_absent', pinned_hash: pinnedHash, presented_hash: null, verdict: null };
112
+ }
113
+ const presentedHash = typeof adm.profile_hash === 'string' ? adm.profile_hash : null;
114
+ const verdict = typeof adm.verdict === 'string' ? adm.verdict : null;
115
+ // Constant-work equality is unnecessary (hashes are public), but the mismatch
116
+ // MUST be a distinct, named refusal: a presented verdict for a DIFFERENT bar is
117
+ // not evidence about the pinned bar.
118
+ if (presentedHash === null || presentedHash !== pinnedHash) {
119
+ return { ok: false, reason: 'profile_hash_mismatch', pinned_hash: pinnedHash, presented_hash: presentedHash, verdict };
120
+ }
121
+ // Verdict must be recognized AND exactly 'admissible'. Any other closed-set
122
+ // member (missing_evidence/stale/conflicted/unverifiable), an unrecognized
123
+ // string, or a missing verdict fails closed and names the verdict it saw.
124
+ if (verdict === null || !ADMISSIBILITY_VERDICTS.includes(verdict)) {
125
+ return { ok: false, reason: 'admissibility_verdict_unrecognized', pinned_hash: pinnedHash, presented_hash: presentedHash, verdict };
126
+ }
127
+ if (verdict !== 'admissible') {
128
+ return { ok: false, reason: `admissibility_not_admissible:${verdict}`, pinned_hash: pinnedHash, presented_hash: presentedHash, verdict };
129
+ }
130
+ return { ok: true, reason: null, pinned_hash: pinnedHash, presented_hash: presentedHash, verdict };
131
+ }
132
+
133
+ /**
134
+ * The assurance tier a receipt has CRYPTOGRAPHICALLY EARNED.
135
+ *
136
+ * SECURITY: the credited tier is NEVER inferred from self-asserted payload
137
+ * fields. A bare `quorum:{signers,threshold}` block or an `outcome:
138
+ * 'allow_with_signoff'` string with no verifiable signature earns only
139
+ * `software` — it will be refused `assurance_too_low` by any guard that needs
140
+ * more. Fail-closed by construction.
141
+ *
142
+ * Two independent cryptographic proof shapes are accepted; a receipt earns the
143
+ * HIGHEST tier any of them proves:
144
+ *
145
+ * (a) Pinned assurance proof (`payload.assurance_proof`, EP-ASSURANCE-PROOF-v1):
146
+ * per-signer signatures verified against PINNED approver keys (opts.approverKeys)
147
+ * or a caller-supplied verifier (opts.verifyAssurance). This is the primary,
148
+ * strongest model — the verifier never trusts a key that travels inside the
149
+ * receipt. Delegated to require-receipt's receiptAssuranceTierFromProof.
150
+ *
151
+ * (b) Self-contained embedded evidence (DoD audit fix): a full EP-QUORUM-v1
152
+ * document (payload.quorum) whose per-signer WebAuthn assertions verify via
153
+ * verifyQuorum (distinct humans + distinct keys + threshold + action-binding
154
+ * + window) earns `quorum`; a WebAuthn device signoff (payload.signoff =
155
+ * {context, webauthn}) that verifies against the approver's own key via
156
+ * verifyWebAuthnSignoff earns `class_a`. Used where the approver keys travel
157
+ * with the receipt rather than being pinned by the relying party.
158
+ *
159
+ * @param {object} doc the EP-RECEIPT-v1 document
160
+ * @param {object} [opts]
161
+ * @param {object} [opts.approverKeys] pinned approver keys for path (a)
162
+ * @param {function} [opts.verifyAssurance] custom assurance verifier for path (a)
163
+ * @param {string} [opts.rpId] bind embedded device assertions to this WebAuthn RP id (path b)
164
+ * @param {boolean} [opts.detail] return a {tier, quorum, signoff} object instead of the string
165
+ * @returns {'software'|'class_a'|'quorum'|object} the highest tier proven
76
166
  */
77
167
  export function receiptAssuranceTier(doc, opts = {}) {
78
- return receiptAssuranceTierFromProof(doc, opts);
168
+ const detail = { tier: 'software', quorum: null, signoff: null };
169
+
170
+ // --- Path (a): pinned assurance proof / caller-supplied verifier. ---
171
+ // Never inferred from receipt fields without a pinned key or explicit verifier.
172
+ let proofTier = 'software';
173
+ try {
174
+ proofTier = receiptAssuranceTierFromProof(doc, opts) || 'software';
175
+ } catch { proofTier = 'software'; }
176
+ if ((TIER_RANK[proofTier] ?? 0) > (TIER_RANK[detail.tier] ?? 0)) detail.tier = proofTier;
177
+
178
+ // --- Path (b): self-contained embedded per-signer evidence (DoD audit fix). ---
179
+ const p = doc?.payload || {};
180
+ const verifyOpts = opts.rpId ? { rpId: opts.rpId } : {};
181
+
182
+ // quorum: a real, self-contained EP-QUORUM-v1 evidence document. Accept it
183
+ // under payload.quorum or payload.claim.quorum. It only counts if it is a full
184
+ // quorum document (policy + members with WebAuthn signoffs) AND verifyQuorum
185
+ // returns valid. A bare {signers,threshold} block has no members to verify and
186
+ // therefore CANNOT be credited quorum.
187
+ const q = p.quorum || p.claim?.quorum;
188
+ if (detail.tier !== 'quorum' && isQuorumEvidence(q)) {
189
+ const qr = verifyQuorum(q, verifyOpts);
190
+ detail.quorum = { valid: qr.valid, checks: qr.checks };
191
+ if (qr.valid) detail.tier = 'quorum';
192
+ }
193
+
194
+ // class_a: a verifiable WebAuthn device signoff. The signoff evidence is
195
+ // {context, webauthn}; the approver key travels with it (signoff.approver_public_key)
196
+ // or alongside it (payload.approver_public_key).
197
+ if ((TIER_RANK[detail.tier] ?? 0) < TIER_RANK.class_a) {
198
+ const so = p.signoff || p.claim?.signoff;
199
+ if (isSignoffEvidence(so)) {
200
+ const key = so.approver_public_key || p.approver_public_key || p.claim?.approver_public_key;
201
+ if (key) {
202
+ 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';
205
+ }
206
+ }
207
+ }
208
+
209
+ return opts.detail ? detail : detail.tier;
210
+ }
211
+
212
+ /** A quorum evidence doc must carry members with per-signer signoffs to be verifiable. */
213
+ function isQuorumEvidence(q) {
214
+ return !!q && typeof q === 'object' && q.policy && Array.isArray(q.members) && q.members.length > 0
215
+ && typeof q.action_hash === 'string' && q.action_hash.length > 0;
216
+ }
217
+ /** A device signoff must carry the WebAuthn assertion material to be verifiable. */
218
+ function isSignoffEvidence(s) {
219
+ return !!s && typeof s === 'object' && s.context && s.webauthn;
79
220
  }
80
221
 
81
222
  /**
@@ -91,7 +232,7 @@ export function receiptAssuranceTier(doc, opts = {}) {
91
232
  * if given it supersedes trustedKeys — a receipt is verified only against keys valid (and not
92
233
  * revoked) at its issuance time.
93
234
  */
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 } = {}) {
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 } = {}) {
95
236
  // Production key custody: a registry (rotation + revocation) supersedes a flat
96
237
  // trustedKeys list. A flat list is coerced to an always-valid registry, so
97
238
  // existing callers are unchanged.
@@ -118,10 +259,19 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
118
259
  }
119
260
  const evidence = log || createEvidenceLog({ strict: strictEvidence });
120
261
 
121
- async function check({ selector = {}, receipt = null, observedAction = null, consumptionMode = 'consume' } = {}) {
262
+ async function check({ selector = {}, receipt = null, observedAction = null, consumptionMode = 'consume', admissibilityProfile = null, reliancePacket: presentedPacket = null, admissibility = null } = {}) {
122
263
  const requirement = manifest ? findActionRequirement(manifest, selector) : null;
123
264
  const action = requirement?.action_type || selector.action_type || selector.action || null;
124
- const requiredTier = requirement?.assurance_class || selector.assurance_class || 'software';
265
+ // Assurance tier the action requires (cryptographically checked below). For a
266
+ // manifest-guarded action the tier MUST be declared explicitly: never fall
267
+ // back to the weakest 'software' tier because assurance_class was omitted —
268
+ // that would let a guarded, possibly critical, action accept a bare
269
+ // machine-signed receipt (a fail-open). A guarded requirement with no tier
270
+ // is a misconfiguration and fails closed just below. Only selector-only
271
+ // checks (no manifest requirement) use the documented 'software' default.
272
+ const requiredTier = requirement
273
+ ? requirement.assurance_class
274
+ : (selector.assurance_class || 'software');
125
275
  const observed = observedAction || selector.observedAction || selector.actionDetails || null;
126
276
 
127
277
  async function decide(allow, status, reason, extra = {}) {
@@ -176,6 +326,14 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
176
326
  if (!receipt) {
177
327
  return decide(false, RECEIPT_REQUIRED_STATUS, 'receipt_required');
178
328
  }
329
+ // A manifest-guarded action that declares no assurance_class is a
330
+ // misconfiguration. Fail CLOSED rather than defaulting to the weakest tier
331
+ // (which would accept a bare machine-signed receipt for a guarded action).
332
+ // validateActionRiskManifest also rejects such a manifest at author time;
333
+ // this is defense in depth for a manifest loaded without re-validation.
334
+ if (requirement && requirement.receipt_required !== false && !requiredTier) {
335
+ return decide(false, RECEIPT_REQUIRED_STATUS, 'manifest_missing_assurance_class');
336
+ }
179
337
  // Signature / freshness / action-binding / outcome. Production key custody:
180
338
  // resolve the issuer keys valid (and not revoked) at THIS receipt's issuance
181
339
  // time. A revoked or out-of-window key is excluded, so its signature does not
@@ -187,20 +345,42 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
187
345
  if (!v.ok) {
188
346
  return decide(false, RECEIPT_REQUIRED_STATUS, `receipt_rejected:${v.reason}`, { rejected: v });
189
347
  }
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.
348
+ // Assurance tier. CRYPTOGRAPHICALLY VERIFIED never inferred from
349
+ // self-asserted payload fields. The credited tier is the HIGHER of two
350
+ // independent proof paths:
351
+ // (a) pinned assurance proof (payload.assurance_proof verified against
352
+ // pinned approverKeys) or a caller-supplied verifyAssurance hook;
353
+ // (b) self-contained embedded per-signer evidence (EP-QUORUM-v1 /
354
+ // WebAuthn device signoff) re-verified via verifyQuorum /
355
+ // verifyWebAuthnSignoff (DoD audit fix).
356
+ // A receipt that only CLAIMS a higher tier earns 'software' and is refused.
194
357
  const assurance = evaluateReceiptAssurance(receipt, requiredTier, {
195
358
  approverKeys: approver_keys || approverKeys,
196
359
  verifyAssurance,
197
360
  });
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',
361
+ const tierResult = receiptAssuranceTier(receipt, {
362
+ rpId, detail: true, approverKeys: approver_keys || approverKeys, verifyAssurance,
363
+ });
364
+ // Take the strongest tier either path proves.
365
+ const have = (TIER_RANK[assurance.have] ?? 0) >= (TIER_RANK[tierResult.tier] ?? 0)
366
+ ? assurance.have : tierResult.tier;
367
+ const needRank = TIER_RANK[requiredTier];
368
+ // Fail CLOSED on an unknown / mis-cased required tier: never silently treat
369
+ // it as 'software'. If a manifest asks for a tier this gate does not model,
370
+ // no receipt can satisfy it.
371
+ if (needRank === undefined) {
372
+ return decide(false, RECEIPT_REQUIRED_STATUS, 'unknown_required_tier', { have_tier: have, need_tier: requiredTier, assurance_tier_source: 'cryptographic_verification' });
373
+ }
374
+ if ((TIER_RANK[have] ?? 0) < needRank) {
375
+ // The credited tier (from either proof path) is below what the action
376
+ // requires. The canonical machine-readable reason is 'assurance_too_low';
377
+ // main's proof-path detail (e.g. 'assurance_proof_required') is surfaced
378
+ // separately so callers keep the diagnostic without changing the contract.
379
+ return decide(false, RECEIPT_REQUIRED_STATUS, 'assurance_too_low', {
380
+ have_tier: have, need_tier: requiredTier,
381
+ assurance_tier_source: 'cryptographic_verification',
382
+ assurance_detail: assurance.reason || null,
383
+ tier_evidence: { quorum: tierResult.quorum, signoff: tierResult.signoff },
204
384
  });
205
385
  }
206
386
  // The high-risk action packs define material fields that must be observed
@@ -208,7 +388,30 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
208
388
  // claim cannot authorize a different real mutation.
209
389
  const executionBinding = verifyExecutionBinding({ requirement, receipt, observedAction: observed });
210
390
  if (!executionBinding.ok) {
211
- return decide(false, RECEIPT_REQUIRED_STATUS, 'execution_binding_failed', { execution_binding: executionBinding, have_tier: have, assurance_tier_source: 'proof' });
391
+ return decide(false, RECEIPT_REQUIRED_STATUS, 'execution_binding_failed', { execution_binding: executionBinding, have_tier: have, assurance_tier_source: 'cryptographic_verification' });
392
+ }
393
+ // OPT-IN admissibility pinning. When the caller pins a required admissibility
394
+ // profile {id, profile_hash} (gate-level requiredAdmissibilityProfile, a
395
+ // per-call admissibilityProfile, or selector.admissibilityProfile), the gate
396
+ // REFUSES unless a presented reliance packet's admissibility block was computed
397
+ // against the SAME pinned profile_hash AND carries an 'admissible' verdict. The
398
+ // gate does NOT re-evaluate raw evidence and does NOT define the bar — the
399
+ // relying party's own evaluator produced the verdict OFFLINE against its pinned
400
+ // profile. Checked BEFORE consumption so a mismatch never burns the receipt.
401
+ // When no profile is pinned, this whole block is inert — behavior is
402
+ // byte-for-byte unchanged from the pre-admissibility gate.
403
+ const pinnedProfile = admissibilityProfile || selector.admissibilityProfile || requiredAdmissibilityProfile;
404
+ if (pinnedProfile) {
405
+ const presentedAdmissibility = admissibility ?? presentedPacket ?? selector.reliancePacket ?? selector.admissibility ?? null;
406
+ const adm = verifyAdmissibilityAgainstPinnedProfile(pinnedProfile, presentedAdmissibility);
407
+ if (!adm.ok) {
408
+ return decide(false, RECEIPT_REQUIRED_STATUS, adm.reason, {
409
+ admissibility_check: adm,
410
+ pinned_profile: { id: pinnedProfile.id ?? null, profile_hash: pinnedProfile.profile_hash ?? null },
411
+ have_tier: have,
412
+ assurance_tier_source: 'cryptographic_verification',
413
+ });
414
+ }
212
415
  }
213
416
  // One-time consumption (replay defense). Require a stable, issuer-generated
214
417
  // receipt_id — never fall back to a content hash, whose canonicalization can
@@ -232,7 +435,16 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
232
435
  if (!fresh) {
233
436
  return decide(false, RECEIPT_REQUIRED_STATUS, 'replay_refused', { consumption_key: receiptId });
234
437
  }
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 });
438
+ const allowExtra = { signer: v.signer, outcome: v.outcome, have_tier: have, assurance_tier_source: 'cryptographic_verification', execution_binding: executionBinding, consumption_mode: consumptionMode };
439
+ // Carry the admissibility block (from the presented packet) onto the decision
440
+ // so a reliance packet built from this decision embeds the verdict the relying
441
+ // party's evaluator computed. Only when something was actually presented.
442
+ const presentedAdmForAllow = admissibility ?? presentedPacket ?? selector.reliancePacket ?? selector.admissibility ?? null;
443
+ if (presentedAdmForAllow) {
444
+ const admBlock = presentedAdmForAllow.admissibility !== undefined ? presentedAdmForAllow.admissibility : presentedAdmForAllow;
445
+ if (admBlock) allowExtra.admissibility = admBlock;
446
+ }
447
+ return decide(true, 200, 'allow', allowExtra);
236
448
  }
237
449
 
238
450
  /** Express/Connect middleware: refuse the route unless a sufficient receipt is present. */
@@ -286,9 +498,9 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
286
498
  * If the side effect throws, the reservation is released so the approval can
287
499
  * be retried safely.
288
500
  */
289
- async function run({ selector = {}, receipt = null, observedAction = null } = {}, fn, opts = {}) {
501
+ async function run({ selector = {}, receipt = null, observedAction = null, admissibilityProfile = null, reliancePacket: presentedPacket = null, admissibility = null } = {}, fn, opts = {}) {
290
502
  if (typeof fn !== 'function') throw new Error('EMILIA Gate run(): fn is required');
291
- const authorization = await check({ selector, receipt, observedAction, consumptionMode: 'reserve' });
503
+ const authorization = await check({ selector, receipt, observedAction, consumptionMode: 'reserve', admissibilityProfile, reliancePacket: presentedPacket, admissibility });
292
504
  if (!authorization.allow) {
293
505
  return { ok: false, status: authorization.status, body: authorization.challenge, authorization };
294
506
  }
@@ -327,7 +539,13 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
327
539
  const observedAction = typeof opts.observedAction === 'function'
328
540
  ? opts.observedAction(...args)
329
541
  : (opts.observedAction || selector.observedAction || null);
330
- const out = await run({ selector, receipt, observedAction }, () => fn(...args), { recordExecution: opts.recordExecution });
542
+ const admissibilityProfile = typeof opts.admissibilityProfile === 'function'
543
+ ? opts.admissibilityProfile(...args)
544
+ : (opts.admissibilityProfile ?? null);
545
+ const presentedPacket = typeof opts.reliancePacket === 'function'
546
+ ? opts.reliancePacket(...args)
547
+ : (opts.reliancePacket ?? opts.admissibility ?? null);
548
+ const out = await run({ selector, receipt, observedAction, admissibilityProfile, reliancePacket: presentedPacket }, () => fn(...args), { recordExecution: opts.recordExecution });
331
549
  if (!out.ok) {
332
550
  const e = new Error(`EMILIA Gate refused (${out.authorization.reason})`);
333
551
  e.code = 'EMILIA_RECEIPT_REQUIRED';
@@ -338,13 +556,22 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
338
556
  };
339
557
  }
340
558
 
341
- function reliancePacket({ authorization, execution = null, binding = null } = {}) {
559
+ function reliancePacket({ authorization, execution = null, binding = null, admissibility = null } = {}) {
560
+ // The admissibility block rides on the authorization decision's evidence when
561
+ // a reliance packet was presented at check() time; an explicit `admissibility`
562
+ // arg overrides it. buildReliancePacket fails closed on a non-'admissible'
563
+ // block, so a do_not_rely verdict can never be laundered into rely here.
564
+ const adm = admissibility
565
+ ?? authorization?.evidence?.admissibility
566
+ ?? authorization?.admissibility
567
+ ?? null;
342
568
  return buildReliancePacket({
343
569
  decision: authorization,
344
570
  execution,
345
571
  evidence,
346
572
  manifest,
347
573
  binding,
574
+ admissibility: adm,
348
575
  });
349
576
  }
350
577
 
@@ -446,6 +673,8 @@ export default {
446
673
  createGate,
447
674
  createTrustedActionFirewall,
448
675
  receiptAssuranceTier,
676
+ verifyAdmissibilityAgainstPinnedProfile,
677
+ ADMISSIBILITY_VERDICTS,
449
678
  MemoryConsumptionStore,
450
679
  createEvidenceLog,
451
680
  ASSURANCE_TIERS,