@emilia-protocol/gate 0.9.5 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/LICENSE +190 -0
  3. package/README.md +95 -16
  4. package/action-control-manifest.js +26 -0
  5. package/action-escrow-custodian.js +395 -0
  6. package/action-escrow-evidence.js +859 -0
  7. package/action-escrow-package.js +1669 -0
  8. package/action-escrow-postgres.js +338 -0
  9. package/action-escrow-state.js +397 -0
  10. package/action-escrow-verifiers.js +332 -0
  11. package/action-escrow.js +2448 -0
  12. package/adapters/_kit.js +47 -5
  13. package/adapters/aws.js +15 -4
  14. package/adapters/github-demo.mjs +25 -22
  15. package/adapters/github.js +1 -1
  16. package/adapters/jira.js +1 -0
  17. package/adapters/linear.js +1 -0
  18. package/adapters/salesforce.js +1 -0
  19. package/adapters/stripe.js +1 -1
  20. package/adapters/supabase.js +26 -4
  21. package/adapters/vercel.js +33 -4
  22. package/aec-execution.js +1 -3
  23. package/breakglass.js +285 -51
  24. package/control-plane.js +341 -0
  25. package/coverage.js +722 -0
  26. package/custody-demo.mjs +13 -3
  27. package/demo.mjs +9 -3
  28. package/deploy/helm/README.md +16 -8
  29. package/deploy/helm/emilia-gate/Chart.yaml +3 -1
  30. package/deploy/helm/emilia-gate/templates/NOTES.txt +3 -2
  31. package/deploy/helm/emilia-gate/templates/deployment.yaml +55 -1
  32. package/deploy/helm/emilia-gate/values.yaml +18 -2
  33. package/deploy/helm/emilia-gate-service/Chart.yaml +11 -0
  34. package/deploy/helm/emilia-gate-service/README.md +81 -0
  35. package/deploy/helm/emilia-gate-service/templates/NOTES.txt +12 -0
  36. package/deploy/helm/emilia-gate-service/templates/_helpers.tpl +83 -0
  37. package/deploy/helm/emilia-gate-service/templates/deployment.yaml +153 -0
  38. package/deploy/helm/emilia-gate-service/templates/migration-job.yaml +73 -0
  39. package/deploy/helm/emilia-gate-service/templates/networkpolicy.yaml +113 -0
  40. package/deploy/helm/emilia-gate-service/templates/pdb.yaml +14 -0
  41. package/deploy/helm/emilia-gate-service/templates/service.yaml +20 -0
  42. package/deploy/helm/emilia-gate-service/templates/serviceaccount.yaml +8 -0
  43. package/deploy/helm/emilia-gate-service/tests/fixtures/001_gate.sql +26 -0
  44. package/deploy/helm/emilia-gate-service/tests/fixtures/002_runtime_access.sql +32 -0
  45. package/deploy/helm/emilia-gate-service/tests/fixtures/gate.config.mjs +29 -0
  46. package/deploy/helm/emilia-gate-service/tests/render-check.sh +145 -0
  47. package/deploy/helm/emilia-gate-service/values.schema.json +145 -0
  48. package/deploy/helm/emilia-gate-service/values.yaml +166 -0
  49. package/deploy/sql/001-runtime.sql +707 -0
  50. package/deploy/terraform/README.md +24 -14
  51. package/deploy/terraform/main.tf +59 -0
  52. package/deploy/terraform/service/README.md +81 -0
  53. package/deploy/terraform/service/main.tf +718 -0
  54. package/deploy/terraform/service/outputs.tf +19 -0
  55. package/deploy/terraform/service/tests/validate.sh +24 -0
  56. package/deploy/terraform/service/tests/validation.tftest.hcl +168 -0
  57. package/deploy/terraform/service/variables.tf +459 -0
  58. package/deploy/terraform/service/versions.tf +10 -0
  59. package/deploy/terraform/variables.tf +95 -2
  60. package/deployment-attestation.js +248 -0
  61. package/eg1-conformance.js +46 -12
  62. package/enterprise.js +6 -2
  63. package/ep-assure.mjs +3 -2
  64. package/evidence-postgres.js +357 -0
  65. package/evidence.js +10 -3
  66. package/execution-binding.js +141 -26
  67. package/index.js +556 -115
  68. package/key-registry.js +51 -19
  69. package/mcp.js +4 -2
  70. package/network-witness.js +500 -0
  71. package/package.json +39 -5
  72. package/reliance-kernel.js +5 -6
  73. package/reliance-packet.js +43 -10
  74. package/reports/assurance-package.js +45 -19
  75. package/reports/reperform.js +78 -18
  76. package/reports/underwriter.js +1 -1
  77. package/settlement.js +300 -0
  78. package/store-postgres.js +14 -0
  79. package/store.js +26 -5
  80. package/strict-json.js +103 -0
  81. package/witness-postgres.js +97 -0
@@ -52,9 +52,9 @@ variable "image_pull_policy" {
52
52
  }
53
53
 
54
54
  variable "replicas" {
55
- description = "Number of gate replicas. Replay defense across >1 replica requires a shared consumption store (see README) — the module deploys the pods either way, but fleet-safety is the deployer's configuration responsibility."
55
+ description = "Number of legacy gate replicas. Values above one require explicit Secret-backed shared consumption and evidence backend references."
56
56
  type = number
57
- default = 2
57
+ default = 1
58
58
 
59
59
  validation {
60
60
  condition = var.replicas >= 1 && floor(var.replicas) == var.replicas
@@ -95,6 +95,99 @@ variable "issuer_keys_secret_key" {
95
95
  }
96
96
  }
97
97
 
98
+ variable "shared_consumption_backend" {
99
+ description = "Secret-backed environment reference for a shared durable consumption backend used by a custom legacy image. Required with replicas > 1."
100
+ type = object({
101
+ env_name = string
102
+ secret_name = string
103
+ secret_key = string
104
+ })
105
+ default = null
106
+ nullable = true
107
+
108
+ validation {
109
+ condition = var.shared_consumption_backend == null ? true : (
110
+ can(regex("^[A-Za-z_][A-Za-z0-9_]*$", var.shared_consumption_backend.env_name))
111
+ && trimspace(var.shared_consumption_backend.secret_name) != ""
112
+ && trimspace(var.shared_consumption_backend.secret_key) != ""
113
+ )
114
+ error_message = "shared_consumption_backend must provide a valid env_name and non-empty existing Secret name/key."
115
+ }
116
+ }
117
+
118
+ variable "shared_evidence_backend" {
119
+ description = "Secret-backed environment reference for a shared durable evidence backend used by a custom legacy image. Required with replicas > 1."
120
+ type = object({
121
+ env_name = string
122
+ secret_name = string
123
+ secret_key = string
124
+ })
125
+ default = null
126
+ nullable = true
127
+
128
+ validation {
129
+ condition = var.shared_evidence_backend == null ? true : (
130
+ can(regex("^[A-Za-z_][A-Za-z0-9_]*$", var.shared_evidence_backend.env_name))
131
+ && trimspace(var.shared_evidence_backend.secret_name) != ""
132
+ && trimspace(var.shared_evidence_backend.secret_key) != ""
133
+ )
134
+ error_message = "shared_evidence_backend must provide a valid env_name and non-empty existing Secret name/key."
135
+ }
136
+ }
137
+
138
+ variable "github_token_secret_name" {
139
+ description = "Optional existing Secret containing a GitHub token. Only the Secret reference enters Terraform state."
140
+ type = string
141
+ default = null
142
+ nullable = true
143
+
144
+ validation {
145
+ condition = var.github_token_secret_name == null ? true : can(regex("^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$", var.github_token_secret_name))
146
+ error_message = "github_token_secret_name must be null or a Kubernetes Secret name."
147
+ }
148
+ }
149
+
150
+ variable "github_token_secret_key" {
151
+ description = "Key in github_token_secret_name containing the GitHub token."
152
+ type = string
153
+ default = "token"
154
+
155
+ validation {
156
+ condition = trimspace(var.github_token_secret_key) != ""
157
+ error_message = "github_token_secret_key must be non-empty."
158
+ }
159
+ }
160
+
161
+ variable "github_token_env_name" {
162
+ description = "Environment variable populated from github_token_secret_name."
163
+ type = string
164
+ default = "GITHUB_TOKEN"
165
+
166
+ validation {
167
+ condition = can(regex("^[A-Za-z_][A-Za-z0-9_]*$", var.github_token_env_name))
168
+ error_message = "github_token_env_name must be a valid environment variable name."
169
+ }
170
+ }
171
+
172
+ variable "secret_env" {
173
+ description = "Additional environment variables populated from existing Kubernetes Secrets. Secret values are never module inputs."
174
+ type = map(object({
175
+ secret_name = string
176
+ secret_key = string
177
+ }))
178
+ default = {}
179
+
180
+ validation {
181
+ condition = alltrue([
182
+ for env_name, reference in var.secret_env :
183
+ can(regex("^[A-Za-z_][A-Za-z0-9_]*$", env_name))
184
+ && trimspace(reference.secret_name) != ""
185
+ && trimspace(reference.secret_key) != ""
186
+ ])
187
+ error_message = "secret_env keys must be environment variable names and each Secret name/key must be non-empty."
188
+ }
189
+ }
190
+
98
191
  variable "port" {
99
192
  description = "Port the gate HTTP service listens on inside the container (EP_GATE_PORT). Matches the Helm chart's gate.port."
100
193
  type = number
@@ -0,0 +1,248 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Relying-party evaluation of evidence that an expected Gate workload is
4
+ * running. This module introduces no attestation format and no trust root: the
5
+ * profile pins a verifier selected by the relying party, and that verifier may
6
+ * consume EAT/RATS, App Attest, Play Integrity, TPM, confidential-compute, or
7
+ * workload-identity evidence.
8
+ */
9
+ import { canonicalize, hashCanonical } from './execution-binding.js';
10
+
11
+ export const DEPLOYMENT_PROFILE_VERSION = 'EP-GATE-DEPLOYMENT-PROFILE-v1';
12
+ export const DEPLOYMENT_ATTESTATION_VERDICTS = Object.freeze([
13
+ 'attested',
14
+ 'refuse_profile_invalid',
15
+ 'refuse_verifier_unpinned',
16
+ 'refuse_evidence_invalid',
17
+ 'refuse_verifier_error',
18
+ 'refuse_context_mismatch',
19
+ 'refuse_measurement_mismatch',
20
+ 'refuse_stale',
21
+ ]);
22
+
23
+ const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
24
+ const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?Z$/;
25
+
26
+ function isPlainObject(value) {
27
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
28
+ const prototype = Object.getPrototypeOf(value);
29
+ return prototype === Object.prototype || prototype === null;
30
+ }
31
+
32
+ function strictInstantMs(value) {
33
+ if (typeof value !== 'string') return NaN;
34
+ const match = value.match(RFC3339);
35
+ if (!match) return NaN;
36
+ const [, y, m, d, h, min, s] = match;
37
+ const calendar = new Date(0);
38
+ calendar.setUTCFullYear(Number(y), Number(m) - 1, Number(d));
39
+ calendar.setUTCHours(Number(h), Number(min), Number(s), 0);
40
+ if (calendar.toISOString().slice(0, 19) !== `${y}-${m}-${d}T${h}:${min}:${s}`) return NaN;
41
+ const parsed = Date.parse(value);
42
+ return Number.isFinite(parsed) ? parsed : NaN;
43
+ }
44
+
45
+ function string(value, max = 512) {
46
+ return typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
47
+ }
48
+
49
+ function digest(value) {
50
+ return typeof value === 'string' && DIGEST_RE.test(value);
51
+ }
52
+
53
+ function exactKeys(value, allowed) {
54
+ return isPlainObject(value) && Object.keys(value).every((key) => allowed.has(key));
55
+ }
56
+
57
+ function validateProfile(profile) {
58
+ if (!exactKeys(profile, new Set([
59
+ '@version', 'profile_id', 'verifier_id', 'evidence_type', 'gate_id', 'environment_id',
60
+ 'audience', 'nonce', 'max_age_sec', 'max_future_skew_sec', 'required_measurements',
61
+ ]))) return 'profile_shape_invalid';
62
+ if (profile['@version'] !== DEPLOYMENT_PROFILE_VERSION) return 'profile_version_invalid';
63
+ for (const field of ['profile_id', 'verifier_id', 'evidence_type', 'gate_id', 'environment_id', 'audience']) {
64
+ if (!string(profile[field])) return `profile_${field}_invalid`;
65
+ }
66
+ if (!string(profile.nonce, 1024)) return 'profile_nonce_invalid';
67
+ if (!Number.isSafeInteger(profile.max_age_sec) || profile.max_age_sec < 1 || profile.max_age_sec > 86_400) {
68
+ return 'profile_max_age_invalid';
69
+ }
70
+ if (!Number.isSafeInteger(profile.max_future_skew_sec)
71
+ || profile.max_future_skew_sec < 0 || profile.max_future_skew_sec > 300) {
72
+ return 'profile_future_skew_invalid';
73
+ }
74
+ if (!isPlainObject(profile.required_measurements)
75
+ || Object.keys(profile.required_measurements).length === 0
76
+ || Object.keys(profile.required_measurements).length > 32) return 'profile_measurements_invalid';
77
+ for (const [name, value] of Object.entries(profile.required_measurements)) {
78
+ if (!string(name, 128) || !digest(value)) return 'profile_measurements_invalid';
79
+ }
80
+ try { canonicalize(profile); } catch { return 'profile_canonicalization_invalid'; }
81
+ return null;
82
+ }
83
+
84
+ export function deploymentProfileDigest(profile) {
85
+ const invalid = validateProfile(profile);
86
+ if (invalid) throw new TypeError(invalid);
87
+ return `sha256:${hashCanonical(profile)}`;
88
+ }
89
+
90
+ function fail(verdict, profileHash = null, extra = {}) {
91
+ return {
92
+ accepted: false,
93
+ verified: false,
94
+ verdict,
95
+ profile_hash: profileHash,
96
+ checks: {
97
+ profile: verdict !== 'refuse_profile_invalid',
98
+ verifier: false,
99
+ evidence: false,
100
+ context: false,
101
+ freshness: false,
102
+ measurements: false,
103
+ ...extra,
104
+ },
105
+ };
106
+ }
107
+
108
+ function verifierFor(verifiers, verifierId) {
109
+ let verifier;
110
+ if (verifiers instanceof Map) verifier = verifiers.get(verifierId);
111
+ else if (isPlainObject(verifiers)) {
112
+ verifier = Object.getOwnPropertyDescriptor(verifiers, verifierId)?.value;
113
+ }
114
+ return typeof verifier === 'function' ? verifier : null;
115
+ }
116
+
117
+ /**
118
+ * Verify deployment evidence under a relying-party-pinned profile.
119
+ *
120
+ * The selected verifier is taken from `profile.verifier_id`, which is a trusted
121
+ * input. A presenter cannot select its own verifier by labeling the evidence.
122
+ * The verifier returns normalized claims; this kernel independently compares
123
+ * every context and measurement claim with the profile.
124
+ */
125
+ export async function verifyDeploymentAttestation(evidence, options = {}) {
126
+ let profile;
127
+ let invalid;
128
+ try {
129
+ invalid = validateProfile(options.profile);
130
+ profile = invalid ? options.profile : JSON.parse(canonicalize(options.profile));
131
+ } catch {
132
+ invalid = 'profile_hostile_input';
133
+ profile = null;
134
+ }
135
+ if (invalid) return { ...fail('refuse_profile_invalid'), reason: invalid };
136
+ const profileHash = deploymentProfileDigest(profile);
137
+ const verifier = verifierFor(options.verifiers, profile.verifier_id);
138
+ if (typeof verifier !== 'function') {
139
+ return { ...fail('refuse_verifier_unpinned', profileHash, { profile: true }), reason: 'pinned_verifier_missing' };
140
+ }
141
+
142
+ let claims;
143
+ try {
144
+ claims = await verifier(evidence, Object.freeze({
145
+ evidence_type: profile.evidence_type,
146
+ audience: profile.audience,
147
+ nonce: profile.nonce,
148
+ gate_id: profile.gate_id,
149
+ environment_id: profile.environment_id,
150
+ }));
151
+ } catch {
152
+ return {
153
+ ...fail('refuse_verifier_error', profileHash, { profile: true, verifier: true }),
154
+ reason: 'pinned_verifier_threw',
155
+ };
156
+ }
157
+ if (!isPlainObject(claims) || claims.verified !== true || !isPlainObject(claims.measurements)) {
158
+ return {
159
+ ...fail('refuse_evidence_invalid', profileHash, { profile: true, verifier: true }),
160
+ reason: 'attestation_not_verified',
161
+ };
162
+ }
163
+ try { claims = JSON.parse(canonicalize(claims)); } catch {
164
+ return {
165
+ ...fail('refuse_evidence_invalid', profileHash, { profile: true, verifier: true }),
166
+ reason: 'attestation_claims_not_canonical_json',
167
+ };
168
+ }
169
+
170
+ const contextMatches = claims.verifier_id === profile.verifier_id
171
+ && claims.evidence_type === profile.evidence_type
172
+ && claims.gate_id === profile.gate_id
173
+ && claims.environment_id === profile.environment_id
174
+ && claims.audience === profile.audience
175
+ && claims.nonce === profile.nonce;
176
+ if (!contextMatches) {
177
+ return {
178
+ ...fail('refuse_context_mismatch', profileHash, {
179
+ profile: true, verifier: true, evidence: true,
180
+ }),
181
+ reason: 'attestation_context_mismatch',
182
+ };
183
+ }
184
+
185
+ const now = options.now === undefined ? Date.now() : Number(options.now);
186
+ const issuedAt = strictInstantMs(claims.issued_at);
187
+ const expiresAt = strictInstantMs(claims.expires_at);
188
+ if (!Number.isFinite(now) || !Number.isFinite(issuedAt) || !Number.isFinite(expiresAt)
189
+ || expiresAt < issuedAt
190
+ || issuedAt > now + (profile.max_future_skew_sec * 1000)
191
+ || now - issuedAt > profile.max_age_sec * 1000
192
+ || now >= expiresAt) {
193
+ return {
194
+ ...fail('refuse_stale', profileHash, {
195
+ profile: true, verifier: true, evidence: true, context: true,
196
+ }),
197
+ reason: 'attestation_outside_freshness_window',
198
+ };
199
+ }
200
+
201
+ const mismatched = [];
202
+ const missing = [];
203
+ for (const [name, expected] of Object.entries(profile.required_measurements)) {
204
+ if (!Object.hasOwn(claims.measurements, name)) missing.push(name);
205
+ else if (claims.measurements[name] !== expected) mismatched.push(name);
206
+ }
207
+ if (missing.length || mismatched.length) {
208
+ return {
209
+ ...fail('refuse_measurement_mismatch', profileHash, {
210
+ profile: true, verifier: true, evidence: true, context: true, freshness: true,
211
+ }),
212
+ reason: 'attestation_measurement_mismatch',
213
+ missing_measurements: missing,
214
+ mismatched_measurements: mismatched,
215
+ };
216
+ }
217
+
218
+ return {
219
+ accepted: true,
220
+ verified: true,
221
+ verdict: 'attested',
222
+ reason: null,
223
+ profile_hash: profileHash,
224
+ verifier_id: profile.verifier_id,
225
+ evidence_type: profile.evidence_type,
226
+ gate_id: profile.gate_id,
227
+ environment_id: profile.environment_id,
228
+ issued_at: claims.issued_at,
229
+ expires_at: claims.expires_at,
230
+ measurements: Object.freeze({ ...profile.required_measurements }),
231
+ checks: {
232
+ profile: true,
233
+ verifier: true,
234
+ evidence: true,
235
+ context: true,
236
+ freshness: true,
237
+ measurements: true,
238
+ },
239
+ limitation: 'Attestation proves the expected workload measurements, not that every consequential route is forced through that workload.',
240
+ };
241
+ }
242
+
243
+ export default {
244
+ DEPLOYMENT_PROFILE_VERSION,
245
+ DEPLOYMENT_ATTESTATION_VERDICTS,
246
+ deploymentProfileDigest,
247
+ verifyDeploymentAttestation,
248
+ };
@@ -44,6 +44,7 @@ const sha256Hex = (v) => crypto.createHash('sha256').update(v, 'utf8').digest('h
44
44
  const sha256Bytes = (v) => crypto.createHash('sha256').update(v).digest();
45
45
 
46
46
  const RP_ID = 'emiliaprotocol.ai';
47
+ const RP_ORIGIN = `https://www.${RP_ID}`;
47
48
 
48
49
  /**
49
50
  * Mint a GENUINE WebAuthn ECDSA-P256 device signoff over an authorization
@@ -147,17 +148,32 @@ export function createEg1Harness({ now = Date.now, action = EG1_DEFAULT_ACTION,
147
148
  const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
148
149
  const pub = publicKey.export({ type: 'spki', format: 'der' }).toString('base64url');
149
150
  const approverA = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
151
+ const approverA2 = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
150
152
  const approverB = crypto.generateKeyPairSync('ed25519');
151
153
  const approverKeys = {
152
154
  'ep:key:eg1:class-a': {
155
+ approver_id: 'ep:approver:eg1:cfo',
153
156
  public_key: approverA.publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'),
154
157
  key_class: 'A',
155
158
  },
159
+ 'ep:key:eg1:class-a-2': {
160
+ approver_id: 'ep:approver:eg1:security-officer',
161
+ public_key: approverA2.publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'),
162
+ key_class: 'A',
163
+ },
156
164
  'ep:key:eg1:controller': {
165
+ approver_id: 'ep:approver:eg1:controller',
157
166
  public_key: approverB.publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'),
158
167
  key_class: 'B',
159
168
  },
160
169
  };
170
+ const quorumPolicy = {
171
+ mode: 'threshold', required: 2, distinct_humans: true, window_sec: 900,
172
+ approvers: [
173
+ { role: 'cfo', approver: approverKeys['ep:key:eg1:class-a'].approver_id },
174
+ { role: 'security_officer', approver: approverKeys['ep:key:eg1:class-a-2'].approver_id },
175
+ ],
176
+ };
161
177
  let counter = 0;
162
178
  const nowMs = () => (typeof now === 'function' ? now() : now);
163
179
 
@@ -173,20 +189,24 @@ export function createEg1Harness({ now = Date.now, action = EG1_DEFAULT_ACTION,
173
189
  };
174
190
  }
175
191
 
176
- function classASignoff(digest) {
192
+ function classASignoff(digest, {
193
+ keyId = 'ep:key:eg1:class-a',
194
+ keyPair = approverA,
195
+ approver = 'ep:approver:eg1:cfo',
196
+ } = {}) {
177
197
  const challenge = Buffer.from(digest).toString('base64url');
178
- const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.get', challenge, origin: 'https://www.emiliaprotocol.ai' }), 'utf8');
179
- const rpIdHash = crypto.createHash('sha256').update('www.emiliaprotocol.ai').digest();
198
+ const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.get', challenge, origin: RP_ORIGIN }), 'utf8');
199
+ const rpIdHash = crypto.createHash('sha256').update(RP_ID).digest();
180
200
  const authData = Buffer.concat([rpIdHash, Buffer.from([0x05]), Buffer.from([0, 0, 0, 0])]); // UP + UV
181
201
  const signedData = Buffer.concat([authData, sha256Bytes(clientDataJSON)]);
182
202
  return {
183
- approver: 'ep:approver:eg1:cfo',
184
- approver_key_id: 'ep:key:eg1:class-a',
203
+ approver,
204
+ approver_key_id: keyId,
185
205
  key_class: 'A',
186
206
  webauthn: {
187
207
  authenticator_data: authData.toString('base64url'),
188
208
  client_data_json: clientDataJSON.toString('base64url'),
189
- signature: crypto.sign('sha256', signedData, approverA.privateKey).toString('base64url'),
209
+ signature: crypto.sign('sha256', signedData, keyPair.privateKey).toString('base64url'),
190
210
  },
191
211
  };
192
212
  }
@@ -206,7 +226,13 @@ export function createEg1Harness({ now = Date.now, action = EG1_DEFAULT_ACTION,
206
226
  const digest = Buffer.from(contextHash.replace(/^sha256:/, ''), 'hex');
207
227
  const threshold = Number(quorum?.threshold ?? quorum?.m ?? 1);
208
228
  const signoffs = [classASignoff(digest)];
209
- if (threshold >= 2) signoffs.push(softwareSignoff(digest));
229
+ if (threshold >= 2) {
230
+ signoffs.push(classASignoff(digest, {
231
+ keyId: 'ep:key:eg1:class-a-2',
232
+ keyPair: approverA2,
233
+ approver: 'ep:approver:eg1:security-officer',
234
+ }));
235
+ }
210
236
  return {
211
237
  '@version': 'EP-ASSURANCE-PROOF-v1',
212
238
  context_hash: contextHash,
@@ -227,9 +253,12 @@ export function createEg1Harness({ now = Date.now, action = EG1_DEFAULT_ACTION,
227
253
  * ({signers,threshold}) with no per-signer signatures — used to prove the Gate
228
254
  * REFUSES it (must NOT be credited quorum). For adversarial tests only.
229
255
  * @param {object} [o.tamper] fields assigned to the claim AFTER signing (breaks the signature)
230
- */
256
+ */
231
257
  function mint({ outcome = 'allow_with_signoff', quorum = null, fakeQuorum = false, tamper = null, extra = {} } = {}) {
232
- const claim = { ...action, outcome, approver: 'ep:approver:eg1', ...extra };
258
+ const approver = outcome === 'allow'
259
+ ? 'ep:approver:eg1:controller'
260
+ : 'ep:approver:eg1:cfo';
261
+ const claim = { ...action, outcome, approver, ...extra };
233
262
  const payload = {
234
263
  receipt_id: `${idPrefix}_${++counter}`,
235
264
  subject: 'agent:eg1-conformance',
@@ -252,9 +281,14 @@ export function createEg1Harness({ now = Date.now, action = EG1_DEFAULT_ACTION,
252
281
  if (quorum) {
253
282
  const threshold = Number.isInteger(quorum.threshold) ? quorum.threshold
254
283
  : (Array.isArray(quorum.signers) ? quorum.signers.length : 2);
255
- payload.quorum = mintQuorumEvidence({ actionHash, threshold, issuedAtMs: nowMs() });
284
+ payload.quorum = mintQuorumEvidence({
285
+ actionHash,
286
+ threshold,
287
+ approvers: quorumPolicy.approvers.slice(0, threshold),
288
+ issuedAtMs: nowMs(),
289
+ });
256
290
  } else {
257
- const s = mintDeviceSignoff({ actionHash, approver: 'ep:approver:eg1', issuedAtMs: nowMs() });
291
+ const s = mintDeviceSignoff({ actionHash, approver, issuedAtMs: nowMs() });
258
292
  payload.signoff = s.signoff;
259
293
  payload.approver_public_key = s.approver_public_key;
260
294
  }
@@ -265,7 +299,7 @@ export function createEg1Harness({ now = Date.now, action = EG1_DEFAULT_ACTION,
265
299
  return receipt;
266
300
  }
267
301
 
268
- return { publicKey: pub, approverKeys, mint, action, actionHash, now: nowMs };
302
+ return { publicKey: pub, approverKeys, quorumPolicy, mint, action, actionHash, now: nowMs, rpId: RP_ID, allowedOrigins: [RP_ORIGIN] };
269
303
  }
270
304
 
271
305
  /**
package/enterprise.js CHANGED
@@ -21,6 +21,7 @@
21
21
  * from the wall clock implicitly, so verification is deterministic.
22
22
  */
23
23
  import crypto from 'node:crypto';
24
+ import { strictJsonGate } from './strict-json.js';
24
25
 
25
26
  export const ENTITLEMENT_VERSION = 'EP-GATE-ENTITLEMENT-v1';
26
27
  export const ENTITLEMENT_TIERS = ['community', 'team', 'business', 'enterprise', 'regulated'];
@@ -110,9 +111,12 @@ export function verifyEntitlement(entitlementJson, { issuerKeys, now = Date.now
110
111
 
111
112
  let doc = entitlementJson;
112
113
  if (typeof doc === 'string') {
113
- try { doc = JSON.parse(doc); } catch { return community('entitlement_unparseable'); }
114
+ try {
115
+ if (Buffer.byteLength(doc, 'utf8') > 1024 * 1024 || !strictJsonGate(doc).ok) return community('entitlement_unparseable');
116
+ doc = JSON.parse(doc);
117
+ } catch { return community('entitlement_unparseable'); }
114
118
  }
115
- if (!doc || typeof doc !== 'object') return community('entitlement_malformed');
119
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return community('entitlement_malformed');
116
120
  if (doc['@version'] !== ENTITLEMENT_VERSION) return community('unsupported_version');
117
121
 
118
122
  const p = doc.payload;
package/ep-assure.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  * { "package": <EP-ASSURANCE-PACKAGE-v1>, "keys": {...}, "now": <iso|ms> }
16
16
  * { "decisions": [...], "profile": <EP-RELIANCE-PROFILE-v1>, "keys": {...}, "now": <iso|ms> }
17
17
  *
18
- * keys (auditor-pinned, out of band): { approverKeys, logPublicKey, rpId, revokerKeys }
18
+ * keys (auditor-pinned, out of band): { approverKeys, logPublicKey, rpId, allowedOrigins, revokerKeys }
19
19
  * --json print the full EP-ASSURANCE-REPERFORMANCE-v1 document instead of text
20
20
  * --strict exit non-zero if ANY drift is found (default: only inadmissible-reliance drift)
21
21
  */
@@ -45,7 +45,8 @@ let doc;
45
45
  try {
46
46
  doc = reperformAssurancePackage(pkg, {
47
47
  approverKeys: keys.approverKeys || {}, logPublicKey: keys.logPublicKey || null,
48
- rpId: keys.rpId || null, revokerKeys: keys.revokerKeys || {}, now,
48
+ rpId: keys.rpId || null, allowedOrigins: keys.allowedOrigins || [],
49
+ revokerKeys: keys.revokerKeys || {}, now,
49
50
  });
50
51
  } catch (e) { fail(e.message); }
51
52