@emilia-protocol/gate 0.9.5 → 0.10.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.
- package/CHANGELOG.md +70 -0
- package/LICENSE +190 -0
- package/README.md +75 -16
- package/action-control-manifest.js +26 -0
- package/adapters/_kit.js +47 -5
- package/adapters/aws.js +15 -4
- package/adapters/github-demo.mjs +25 -22
- package/adapters/github.js +1 -1
- package/adapters/jira.js +1 -0
- package/adapters/linear.js +1 -0
- package/adapters/salesforce.js +1 -0
- package/adapters/stripe.js +1 -1
- package/adapters/supabase.js +26 -4
- package/adapters/vercel.js +33 -4
- package/aec-execution.js +1 -3
- package/breakglass.js +285 -51
- package/control-plane.js +341 -0
- package/coverage.js +722 -0
- package/custody-demo.mjs +13 -3
- package/demo.mjs +9 -3
- package/deploy/helm/README.md +16 -8
- package/deploy/helm/emilia-gate/Chart.yaml +3 -1
- package/deploy/helm/emilia-gate/templates/NOTES.txt +3 -2
- package/deploy/helm/emilia-gate/templates/deployment.yaml +55 -1
- package/deploy/helm/emilia-gate/values.yaml +18 -2
- package/deploy/helm/emilia-gate-service/Chart.yaml +11 -0
- package/deploy/helm/emilia-gate-service/README.md +81 -0
- package/deploy/helm/emilia-gate-service/templates/NOTES.txt +12 -0
- package/deploy/helm/emilia-gate-service/templates/_helpers.tpl +83 -0
- package/deploy/helm/emilia-gate-service/templates/deployment.yaml +153 -0
- package/deploy/helm/emilia-gate-service/templates/migration-job.yaml +73 -0
- package/deploy/helm/emilia-gate-service/templates/networkpolicy.yaml +113 -0
- package/deploy/helm/emilia-gate-service/templates/pdb.yaml +14 -0
- package/deploy/helm/emilia-gate-service/templates/service.yaml +20 -0
- package/deploy/helm/emilia-gate-service/templates/serviceaccount.yaml +8 -0
- package/deploy/helm/emilia-gate-service/tests/fixtures/001_gate.sql +26 -0
- package/deploy/helm/emilia-gate-service/tests/fixtures/002_runtime_access.sql +32 -0
- package/deploy/helm/emilia-gate-service/tests/fixtures/gate.config.mjs +29 -0
- package/deploy/helm/emilia-gate-service/tests/render-check.sh +145 -0
- package/deploy/helm/emilia-gate-service/values.schema.json +145 -0
- package/deploy/helm/emilia-gate-service/values.yaml +166 -0
- package/deploy/sql/001-runtime.sql +707 -0
- package/deploy/terraform/README.md +24 -14
- package/deploy/terraform/main.tf +59 -0
- package/deploy/terraform/service/README.md +81 -0
- package/deploy/terraform/service/main.tf +718 -0
- package/deploy/terraform/service/outputs.tf +19 -0
- package/deploy/terraform/service/tests/validate.sh +24 -0
- package/deploy/terraform/service/tests/validation.tftest.hcl +168 -0
- package/deploy/terraform/service/variables.tf +459 -0
- package/deploy/terraform/service/versions.tf +10 -0
- package/deploy/terraform/variables.tf +95 -2
- package/deployment-attestation.js +248 -0
- package/eg1-conformance.js +46 -12
- package/enterprise.js +6 -2
- package/ep-assure.mjs +3 -2
- package/evidence-postgres.js +357 -0
- package/evidence.js +10 -3
- package/execution-binding.js +141 -26
- package/index.js +556 -115
- package/key-registry.js +51 -19
- package/mcp.js +4 -2
- package/network-witness.js +500 -0
- package/package.json +25 -5
- package/reliance-kernel.js +5 -6
- package/reliance-packet.js +43 -10
- package/reports/assurance-package.js +45 -19
- package/reports/reperform.js +78 -18
- package/reports/underwriter.js +1 -1
- package/settlement.js +300 -0
- package/store-postgres.js +14 -0
- package/store.js +26 -5
- package/strict-json.js +86 -0
- 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.
|
|
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 =
|
|
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
|
+
};
|
package/eg1-conformance.js
CHANGED
|
@@ -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:
|
|
179
|
-
const rpIdHash = crypto.createHash('sha256').update(
|
|
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
|
|
184
|
-
approver_key_id:
|
|
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,
|
|
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)
|
|
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
|
|
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({
|
|
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
|
|
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 {
|
|
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,
|
|
48
|
+
rpId: keys.rpId || null, allowedOrigins: keys.allowedOrigins || [],
|
|
49
|
+
revokerKeys: keys.revokerKeys || {}, now,
|
|
49
50
|
});
|
|
50
51
|
} catch (e) { fail(e.message); }
|
|
51
52
|
|