@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/README.md +7 -0
- package/action-control-manifest.js +9 -3
- package/breakglass.js +349 -0
- package/demo.mjs +10 -1
- package/deploy/helm/README.md +110 -0
- package/deploy/helm/emilia-gate/Chart.yaml +39 -0
- package/deploy/helm/emilia-gate/templates/NOTES.txt +48 -0
- package/deploy/helm/emilia-gate/templates/configmap.yaml +27 -0
- package/deploy/helm/emilia-gate/templates/deployment.yaml +134 -0
- package/deploy/helm/emilia-gate/templates/service.yaml +28 -0
- package/deploy/helm/emilia-gate/templates/servicemonitor.yaml +41 -0
- package/deploy/helm/emilia-gate/values.yaml +169 -0
- package/deploy/terraform/README.md +138 -0
- package/deploy/terraform/main.tf +256 -0
- package/deploy/terraform/outputs.tf +33 -0
- package/deploy/terraform/variables.tf +205 -0
- package/deploy/terraform/versions.tf +18 -0
- package/eg1-conformance.js +111 -13
- package/enterprise.js +174 -0
- package/index.js +257 -28
- package/metering.js +227 -0
- package/metrics.js +232 -0
- package/package.json +41 -5
- package/reliance-packet.js +80 -2
- package/reports/art14.js +0 -0
- package/reports/auditor-workpaper.js +538 -0
- package/reports/reperform.js +365 -0
- package/reports/underwriter.js +340 -0
- package/roster.js +265 -0
- package/siem.js +236 -0
- package/store-postgres.js +129 -0
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
|
|
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
|
-
*
|
|
75
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
|
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,
|