@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
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
#
|
|
3
|
+
# EMILIA Gate — reference Terraform module (EP-GATE-TF-v1).
|
|
4
|
+
#
|
|
5
|
+
# The Terraform sibling of the Helm chart (deploy/helm, EP-GATE-HELM-v1) for
|
|
6
|
+
# BYOC installs: the deployer's cluster, the deployer's keys. Same container
|
|
7
|
+
# contract as the chart — EP_GATE_* env, the action-risk manifest mounted at
|
|
8
|
+
# /etc/emilia-gate/action-risk-manifest.json, pinned issuer keys arriving as
|
|
9
|
+
# EP_GATE_ISSUER_KEYS from a Secret the DEPLOYER created. Three resources,
|
|
10
|
+
# nothing hidden:
|
|
11
|
+
#
|
|
12
|
+
# kubernetes_config_map_v1.manifest — the action-risk manifest (policy);
|
|
13
|
+
# kubernetes_deployment_v1.gate — the gate pods, hardened defaults
|
|
14
|
+
# (non-root, read-only rootfs, no privilege escalation, all capabilities
|
|
15
|
+
# dropped, no service-account token);
|
|
16
|
+
# kubernetes_service_v1.gate — cluster-internal Service in front of
|
|
17
|
+
# the pods (ClusterIP by default).
|
|
18
|
+
#
|
|
19
|
+
# Fail-closed wiring:
|
|
20
|
+
# - the issuer-keys secretKeyRef is NOT optional: if the deployer has not
|
|
21
|
+
# provisioned pinned issuer keys, the pods do not start — a gate without
|
|
22
|
+
# pinned issuers must never come up permissive;
|
|
23
|
+
# - EP_GATE_EVIDENCE_STRICT defaults to true: the gate refuses to authorize
|
|
24
|
+
# an action it cannot durably account for;
|
|
25
|
+
# - the manifest is validated as JSON at plan time (see variables.tf) and a
|
|
26
|
+
# sha256 of it is annotated onto the pod template, so every manifest change
|
|
27
|
+
# rolls the pods — a stale policy can't keep serving silently.
|
|
28
|
+
|
|
29
|
+
locals {
|
|
30
|
+
module_version = "EP-GATE-TF-v1"
|
|
31
|
+
|
|
32
|
+
labels = merge({
|
|
33
|
+
"app.kubernetes.io/name" = var.name
|
|
34
|
+
"app.kubernetes.io/component" = "trusted-action-firewall"
|
|
35
|
+
"app.kubernetes.io/part-of" = "emilia-protocol"
|
|
36
|
+
"emiliaprotocol.ai/module-contract" = local.module_version
|
|
37
|
+
"emiliaprotocol.ai/maturity" = "experimental"
|
|
38
|
+
}, var.extra_labels)
|
|
39
|
+
|
|
40
|
+
selector_labels = {
|
|
41
|
+
"app.kubernetes.io/name" = var.name
|
|
42
|
+
"app.kubernetes.io/component" = "trusted-action-firewall"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Same paths as the Helm chart's container contract.
|
|
46
|
+
manifest_mount_dir = "/etc/emilia-gate"
|
|
47
|
+
manifest_file = "action-risk-manifest.json"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# The policy the gate enforces. Plain ConfigMap: the manifest is deny-by-default
|
|
51
|
+
# POLICY, not a secret — auditors should be able to read it in-cluster.
|
|
52
|
+
resource "kubernetes_config_map_v1" "manifest" {
|
|
53
|
+
metadata {
|
|
54
|
+
name = "${var.name}-manifest"
|
|
55
|
+
namespace = var.namespace
|
|
56
|
+
labels = local.labels
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
data = {
|
|
60
|
+
(local.manifest_file) = var.manifest_json
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
resource "kubernetes_deployment_v1" "gate" {
|
|
65
|
+
metadata {
|
|
66
|
+
name = var.name
|
|
67
|
+
namespace = var.namespace
|
|
68
|
+
labels = local.labels
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
spec {
|
|
72
|
+
replicas = var.replicas
|
|
73
|
+
|
|
74
|
+
selector {
|
|
75
|
+
match_labels = local.selector_labels
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
template {
|
|
79
|
+
metadata {
|
|
80
|
+
labels = local.labels
|
|
81
|
+
annotations = {
|
|
82
|
+
# Roll the pods whenever the policy changes.
|
|
83
|
+
"emiliaprotocol.ai/manifest-sha256" = sha256(var.manifest_json)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
spec {
|
|
88
|
+
# The gate does not call the Kubernetes API; no token, no ambient authority.
|
|
89
|
+
automount_service_account_token = false
|
|
90
|
+
|
|
91
|
+
security_context {
|
|
92
|
+
run_as_non_root = true
|
|
93
|
+
run_as_user = var.run_as_user
|
|
94
|
+
run_as_group = var.run_as_user
|
|
95
|
+
fs_group = var.run_as_user
|
|
96
|
+
|
|
97
|
+
seccomp_profile {
|
|
98
|
+
type = "RuntimeDefault"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
container {
|
|
103
|
+
name = "gate"
|
|
104
|
+
image = var.image
|
|
105
|
+
image_pull_policy = var.image_pull_policy
|
|
106
|
+
|
|
107
|
+
port {
|
|
108
|
+
name = "http"
|
|
109
|
+
container_port = var.port
|
|
110
|
+
protocol = "TCP"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
env {
|
|
114
|
+
name = "NODE_ENV"
|
|
115
|
+
value = "production"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
env {
|
|
119
|
+
name = "EP_GATE_PORT"
|
|
120
|
+
value = tostring(var.port)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
env {
|
|
124
|
+
name = "EP_GATE_LOG_LEVEL"
|
|
125
|
+
value = var.log_level
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# Strict evidence log: never authorize an action the gate cannot
|
|
129
|
+
# durably account for.
|
|
130
|
+
env {
|
|
131
|
+
name = "EP_GATE_EVIDENCE_STRICT"
|
|
132
|
+
value = tostring(var.evidence_strict)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
env {
|
|
136
|
+
name = "EP_GATE_MANIFEST_PATH"
|
|
137
|
+
value = "${local.manifest_mount_dir}/${local.manifest_file}"
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
env {
|
|
141
|
+
name = "EP_GATE_METRICS_ENABLED"
|
|
142
|
+
value = tostring(var.metrics_enabled)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# Pinned issuer keys — REFERENCED from an existing Secret, never
|
|
146
|
+
# passed through Terraform. optional=false is the fail-closed
|
|
147
|
+
# switch: no pinned issuers, no pods.
|
|
148
|
+
env {
|
|
149
|
+
name = "EP_GATE_ISSUER_KEYS"
|
|
150
|
+
value_from {
|
|
151
|
+
secret_key_ref {
|
|
152
|
+
name = var.issuer_keys_secret_name
|
|
153
|
+
key = var.issuer_keys_secret_key
|
|
154
|
+
optional = false
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
dynamic "env" {
|
|
160
|
+
for_each = var.extra_env
|
|
161
|
+
content {
|
|
162
|
+
name = env.key
|
|
163
|
+
value = env.value
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
volume_mount {
|
|
168
|
+
name = "action-risk-manifest"
|
|
169
|
+
mount_path = local.manifest_mount_dir
|
|
170
|
+
read_only = true
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# Writable scratch for the runtime; rootfs stays read-only.
|
|
174
|
+
volume_mount {
|
|
175
|
+
name = "tmp"
|
|
176
|
+
mount_path = "/tmp"
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
resources {
|
|
180
|
+
requests = var.resources.requests
|
|
181
|
+
limits = var.resources.limits
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
dynamic "liveness_probe" {
|
|
185
|
+
for_each = var.liveness_path == null ? [] : [var.liveness_path]
|
|
186
|
+
content {
|
|
187
|
+
http_get {
|
|
188
|
+
path = liveness_probe.value
|
|
189
|
+
port = "http"
|
|
190
|
+
}
|
|
191
|
+
initial_delay_seconds = 5
|
|
192
|
+
period_seconds = 10
|
|
193
|
+
timeout_seconds = 2
|
|
194
|
+
failure_threshold = 3
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
dynamic "readiness_probe" {
|
|
199
|
+
for_each = var.readiness_path == null ? [] : [var.readiness_path]
|
|
200
|
+
content {
|
|
201
|
+
http_get {
|
|
202
|
+
path = readiness_probe.value
|
|
203
|
+
port = "http"
|
|
204
|
+
}
|
|
205
|
+
initial_delay_seconds = 5
|
|
206
|
+
period_seconds = 10
|
|
207
|
+
timeout_seconds = 2
|
|
208
|
+
failure_threshold = 3
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
security_context {
|
|
213
|
+
allow_privilege_escalation = false
|
|
214
|
+
read_only_root_filesystem = var.read_only_root_filesystem
|
|
215
|
+
|
|
216
|
+
capabilities {
|
|
217
|
+
drop = ["ALL"]
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
volume {
|
|
223
|
+
name = "action-risk-manifest"
|
|
224
|
+
config_map {
|
|
225
|
+
name = kubernetes_config_map_v1.manifest.metadata[0].name
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
volume {
|
|
230
|
+
name = "tmp"
|
|
231
|
+
empty_dir {}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
resource "kubernetes_service_v1" "gate" {
|
|
239
|
+
metadata {
|
|
240
|
+
name = var.name
|
|
241
|
+
namespace = var.namespace
|
|
242
|
+
labels = local.labels
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
spec {
|
|
246
|
+
type = var.service_type
|
|
247
|
+
selector = local.selector_labels
|
|
248
|
+
|
|
249
|
+
port {
|
|
250
|
+
name = "http"
|
|
251
|
+
port = var.service_port
|
|
252
|
+
target_port = "http"
|
|
253
|
+
protocol = "TCP"
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
#
|
|
3
|
+
# EMILIA Gate — reference Terraform module (EP-GATE-TF-v1) — outputs.
|
|
4
|
+
|
|
5
|
+
output "module_version" {
|
|
6
|
+
description = "Versioned identifier of this reference module."
|
|
7
|
+
value = "EP-GATE-TF-v1"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
output "service_name" {
|
|
11
|
+
description = "Name of the Kubernetes Service fronting the gate."
|
|
12
|
+
value = kubernetes_service_v1.gate.metadata[0].name
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
output "namespace" {
|
|
16
|
+
description = "Namespace the gate is deployed into."
|
|
17
|
+
value = kubernetes_service_v1.gate.metadata[0].namespace
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
output "service_endpoint" {
|
|
21
|
+
description = "In-cluster HTTP endpoint of the gate (cluster-DNS form). Point guarded callers at this."
|
|
22
|
+
value = "http://${kubernetes_service_v1.gate.metadata[0].name}.${kubernetes_service_v1.gate.metadata[0].namespace}.svc.cluster.local:${var.service_port}"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
output "deployment_name" {
|
|
26
|
+
description = "Name of the gate Deployment."
|
|
27
|
+
value = kubernetes_deployment_v1.gate.metadata[0].name
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
output "manifest_config_map_name" {
|
|
31
|
+
description = "Name of the ConfigMap carrying the action-risk manifest."
|
|
32
|
+
value = kubernetes_config_map_v1.manifest.metadata[0].name
|
|
33
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
#
|
|
3
|
+
# EMILIA Gate — reference Terraform module (EP-GATE-TF-v1) — inputs.
|
|
4
|
+
#
|
|
5
|
+
# KEY CUSTODY RULE (non-negotiable): issuer public keys are consumed from an
|
|
6
|
+
# EXISTING Kubernetes Secret that the deployer creates and controls. This
|
|
7
|
+
# module takes the secret's NAME only. It never accepts key material inline,
|
|
8
|
+
# never creates a Secret, and never reads secret data — so no key bytes ever
|
|
9
|
+
# enter Terraform state, plan output, or version control via this module.
|
|
10
|
+
|
|
11
|
+
variable "name" {
|
|
12
|
+
description = "Base name for all resources (Deployment, Service, ConfigMap prefix)."
|
|
13
|
+
type = string
|
|
14
|
+
default = "emilia-gate"
|
|
15
|
+
|
|
16
|
+
validation {
|
|
17
|
+
condition = can(regex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", var.name)) && length(var.name) <= 53
|
|
18
|
+
error_message = "name must be a DNS-1123 label (lowercase alphanumerics and '-', max 53 chars to leave room for suffixes)."
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
variable "namespace" {
|
|
23
|
+
description = "Kubernetes namespace to deploy into. Must already exist — this module does not create namespaces."
|
|
24
|
+
type = string
|
|
25
|
+
default = "default"
|
|
26
|
+
|
|
27
|
+
validation {
|
|
28
|
+
condition = can(regex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", var.namespace))
|
|
29
|
+
error_message = "namespace must be a DNS-1123 label."
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
variable "image" {
|
|
34
|
+
description = "Container image for the gate (registry/repo:tag or @digest). BYOC: build and push your own image; pin a digest or exact tag for production — never a floating tag."
|
|
35
|
+
type = string
|
|
36
|
+
|
|
37
|
+
validation {
|
|
38
|
+
condition = length(trimspace(var.image)) > 0
|
|
39
|
+
error_message = "image is required."
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
variable "image_pull_policy" {
|
|
44
|
+
description = "Kubernetes imagePullPolicy for the gate container."
|
|
45
|
+
type = string
|
|
46
|
+
default = "IfNotPresent"
|
|
47
|
+
|
|
48
|
+
validation {
|
|
49
|
+
condition = contains(["Always", "IfNotPresent", "Never"], var.image_pull_policy)
|
|
50
|
+
error_message = "image_pull_policy must be one of Always, IfNotPresent, Never."
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
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."
|
|
56
|
+
type = number
|
|
57
|
+
default = 2
|
|
58
|
+
|
|
59
|
+
validation {
|
|
60
|
+
condition = var.replicas >= 1 && floor(var.replicas) == var.replicas
|
|
61
|
+
error_message = "replicas must be a whole number >= 1."
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
variable "manifest_json" {
|
|
66
|
+
description = "The action-risk manifest (EP-ACTION-RISK-MANIFEST) as a JSON string — the deny-by-default policy the gate enforces. Rendered into a ConfigMap and mounted read-only. Must parse as JSON and contain an \"actions\" array; an unparseable manifest fails at plan time, not in the cluster."
|
|
67
|
+
type = string
|
|
68
|
+
|
|
69
|
+
validation {
|
|
70
|
+
condition = can(jsondecode(var.manifest_json)) && can(jsondecode(var.manifest_json).actions)
|
|
71
|
+
error_message = "manifest_json must be valid JSON with an \"actions\" field (EP-ACTION-RISK-MANIFEST shape)."
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
variable "issuer_keys_secret_name" {
|
|
76
|
+
description = "Name of an EXISTING Kubernetes Secret (in the same namespace) holding the pinned issuer public keys the gate trusts. Referenced by name only — key material must NEVER be passed to Terraform. Exposed to the container as EP_GATE_ISSUER_KEYS via a non-optional secretKeyRef: pods do not start without it (fail closed — a gate with no pinned issuers must never come up permissive)."
|
|
77
|
+
type = string
|
|
78
|
+
|
|
79
|
+
validation {
|
|
80
|
+
# DNS-1123 subdomain. This also structurally rejects pasted key material
|
|
81
|
+
# (PEM headers, JSON, base64url with '_') landing in this variable.
|
|
82
|
+
condition = can(regex("^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$", var.issuer_keys_secret_name)) && length(var.issuer_keys_secret_name) <= 253
|
|
83
|
+
error_message = "issuer_keys_secret_name must be the NAME of an existing Secret (DNS-1123 subdomain). Never inline key material here."
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
variable "issuer_keys_secret_key" {
|
|
88
|
+
description = "Key inside the issuer-keys Secret that holds the pinned keys (a JSON array of base64url SPKI-DER Ed25519 public keys, or a {kid: key} map). Matches the Helm chart's issuerKeys.secretKey."
|
|
89
|
+
type = string
|
|
90
|
+
default = "issuer-keys.json"
|
|
91
|
+
|
|
92
|
+
validation {
|
|
93
|
+
condition = length(trimspace(var.issuer_keys_secret_key)) > 0
|
|
94
|
+
error_message = "issuer_keys_secret_key must be non-empty."
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
variable "port" {
|
|
99
|
+
description = "Port the gate HTTP service listens on inside the container (EP_GATE_PORT). Matches the Helm chart's gate.port."
|
|
100
|
+
type = number
|
|
101
|
+
default = 8080
|
|
102
|
+
|
|
103
|
+
validation {
|
|
104
|
+
condition = var.port >= 1 && var.port <= 65535
|
|
105
|
+
error_message = "port must be 1-65535."
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
variable "service_port" {
|
|
110
|
+
description = "Port the Service exposes inside the cluster."
|
|
111
|
+
type = number
|
|
112
|
+
default = 8080
|
|
113
|
+
|
|
114
|
+
validation {
|
|
115
|
+
condition = var.service_port >= 1 && var.service_port <= 65535
|
|
116
|
+
error_message = "service_port must be 1-65535."
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
variable "service_type" {
|
|
121
|
+
description = "Kubernetes Service type. ClusterIP (default) keeps the gate cluster-internal; anything more exposed is the deployer's explicit choice."
|
|
122
|
+
type = string
|
|
123
|
+
default = "ClusterIP"
|
|
124
|
+
|
|
125
|
+
validation {
|
|
126
|
+
condition = contains(["ClusterIP", "NodePort", "LoadBalancer"], var.service_type)
|
|
127
|
+
error_message = "service_type must be one of ClusterIP, NodePort, LoadBalancer."
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
variable "log_level" {
|
|
132
|
+
description = "Gate log verbosity (EP_GATE_LOG_LEVEL)."
|
|
133
|
+
type = string
|
|
134
|
+
default = "info"
|
|
135
|
+
|
|
136
|
+
validation {
|
|
137
|
+
condition = contains(["error", "warn", "info", "debug"], var.log_level)
|
|
138
|
+
error_message = "log_level must be one of error, warn, info, debug."
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
variable "evidence_strict" {
|
|
143
|
+
description = "Strict evidence log (EP_GATE_EVIDENCE_STRICT): when true the gate fails CLOSED if a decision record cannot be durably written — it never authorizes an action it cannot account for. Keep true."
|
|
144
|
+
type = bool
|
|
145
|
+
default = true
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
variable "metrics_enabled" {
|
|
149
|
+
description = "Sets EP_GATE_METRICS_ENABLED. Scrape wiring (ServiceMonitor etc.) is out of scope for this module — see the Helm chart."
|
|
150
|
+
type = bool
|
|
151
|
+
default = false
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
variable "liveness_path" {
|
|
155
|
+
description = "HTTP path for the liveness probe. Set to null to disable."
|
|
156
|
+
type = string
|
|
157
|
+
default = "/healthz"
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
variable "readiness_path" {
|
|
161
|
+
description = "HTTP path for the readiness probe. Set to null to disable."
|
|
162
|
+
type = string
|
|
163
|
+
default = "/readyz"
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
variable "resources" {
|
|
167
|
+
description = "Container resource requests/limits (Kubernetes quantity strings)."
|
|
168
|
+
type = object({
|
|
169
|
+
requests = map(string)
|
|
170
|
+
limits = map(string)
|
|
171
|
+
})
|
|
172
|
+
default = {
|
|
173
|
+
requests = { cpu = "100m", memory = "128Mi" }
|
|
174
|
+
limits = { cpu = "500m", memory = "256Mi" }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
variable "run_as_user" {
|
|
179
|
+
description = "UID/GID the gate container runs as (must be non-root; the pod security context enforces runAsNonRoot). Matches the Helm chart's default of 10001."
|
|
180
|
+
type = number
|
|
181
|
+
default = 10001
|
|
182
|
+
|
|
183
|
+
validation {
|
|
184
|
+
condition = var.run_as_user > 0
|
|
185
|
+
error_message = "run_as_user must be non-zero: the gate never runs as root."
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
variable "read_only_root_filesystem" {
|
|
190
|
+
description = "Mount the container's root filesystem read-only (hardened default). A writable emptyDir is mounted at /tmp for runtime scratch either way."
|
|
191
|
+
type = bool
|
|
192
|
+
default = true
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
variable "extra_env" {
|
|
196
|
+
description = "Additional plain-text environment variables for the gate container (e.g. a durable consumption-store URL host/port). Do NOT put secrets here — values in this map land in Terraform state. Reference deployer-managed Secrets instead."
|
|
197
|
+
type = map(string)
|
|
198
|
+
default = {}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
variable "extra_labels" {
|
|
202
|
+
description = "Additional labels merged onto every resource."
|
|
203
|
+
type = map(string)
|
|
204
|
+
default = {}
|
|
205
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
#
|
|
3
|
+
# EMILIA Gate — reference Terraform module (EP-GATE-TF-v1).
|
|
4
|
+
# Provider and core version constraints. The kubernetes provider is the ONLY
|
|
5
|
+
# provider this module uses: everything lands in the deployer's own cluster,
|
|
6
|
+
# authenticated with the deployer's own credentials. No EMILIA-hosted backend,
|
|
7
|
+
# no remote state requirement, no callbacks.
|
|
8
|
+
|
|
9
|
+
terraform {
|
|
10
|
+
required_version = ">= 1.5.0"
|
|
11
|
+
|
|
12
|
+
required_providers {
|
|
13
|
+
kubernetes = {
|
|
14
|
+
source = "hashicorp/kubernetes"
|
|
15
|
+
version = ">= 2.23.0, < 3.0.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
package/eg1-conformance.js
CHANGED
|
@@ -43,6 +43,80 @@ const canon = (v) => (v == null ? JSON.stringify(v)
|
|
|
43
43
|
const sha256Hex = (v) => crypto.createHash('sha256').update(v, 'utf8').digest('hex');
|
|
44
44
|
const sha256Bytes = (v) => crypto.createHash('sha256').update(v).digest();
|
|
45
45
|
|
|
46
|
+
const RP_ID = 'emiliaprotocol.ai';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Mint a GENUINE WebAuthn ECDSA-P256 device signoff over an authorization
|
|
50
|
+
* context — the same structure @emilia-protocol/verify verifyWebAuthnSignoff
|
|
51
|
+
* checks. This is what earns a receipt its class_a tier: a real per-signer
|
|
52
|
+
* assertion, not a self-asserted `outcome` string. Used to build the Class-A and
|
|
53
|
+
* quorum evidence the EG-1 harness embeds so the Gate can CRYPTOGRAPHICALLY
|
|
54
|
+
* credit the tier.
|
|
55
|
+
*/
|
|
56
|
+
export function mintDeviceSignoff({ actionHash, approver, issuedAtMs = Date.now(), nonce, prevContextHash = undefined } = {}) {
|
|
57
|
+
const signer = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
58
|
+
const context = {
|
|
59
|
+
ep_version: '1.0', context_type: 'ep.signoff.v1',
|
|
60
|
+
action_hash: actionHash,
|
|
61
|
+
policy: 'policy_eg1',
|
|
62
|
+
nonce: nonce || ('sig_' + crypto.randomBytes(16).toString('hex')),
|
|
63
|
+
approver,
|
|
64
|
+
initiator: 'ent_agent_eg1',
|
|
65
|
+
issued_at: new Date(issuedAtMs).toISOString(),
|
|
66
|
+
expires_at: new Date(issuedAtMs + 5 * 60_000).toISOString(),
|
|
67
|
+
...(prevContextHash !== undefined ? { prev_context_hash: prevContextHash } : {}),
|
|
68
|
+
};
|
|
69
|
+
const challenge = crypto.createHash('sha256').update(canon(context), 'utf8').digest().toString('base64url');
|
|
70
|
+
const clientData = Buffer.from(JSON.stringify({ type: 'webauthn.get', challenge, origin: `https://www.${RP_ID}` }), 'utf8');
|
|
71
|
+
const authData = Buffer.concat([
|
|
72
|
+
crypto.createHash('sha256').update(RP_ID, 'utf8').digest(),
|
|
73
|
+
Buffer.from([0x05]), // UP | UV
|
|
74
|
+
Buffer.from([0, 0, 0, 1]),
|
|
75
|
+
]);
|
|
76
|
+
const signed = Buffer.concat([authData, crypto.createHash('sha256').update(clientData).digest()]);
|
|
77
|
+
const signature = crypto.sign('sha256', signed, signer.privateKey).toString('base64url');
|
|
78
|
+
return {
|
|
79
|
+
signoff: {
|
|
80
|
+
'@type': 'ep.signoff',
|
|
81
|
+
context,
|
|
82
|
+
webauthn: {
|
|
83
|
+
authenticator_data: authData.toString('base64url'),
|
|
84
|
+
client_data_json: clientData.toString('base64url'),
|
|
85
|
+
signature,
|
|
86
|
+
},
|
|
87
|
+
approver_public_key: signer.publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'),
|
|
88
|
+
},
|
|
89
|
+
approver_public_key: signer.publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'),
|
|
90
|
+
context,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Mint a GENUINE EP-QUORUM-v1 evidence document: N distinct humans, each on a
|
|
96
|
+
* distinct device key, each with a real WebAuthn assertion bound to the SAME
|
|
97
|
+
* action_hash, within a window. verifyQuorum returns valid for it. This is what
|
|
98
|
+
* earns a receipt its `quorum` tier — never a bare {signers,threshold} block.
|
|
99
|
+
*/
|
|
100
|
+
export function mintQuorumEvidence({ actionHash, threshold = 2, approvers, issuedAtMs = Date.now() } = {}) {
|
|
101
|
+
const people = approvers || Array.from({ length: threshold }, (_, i) => ({ role: `approver_${i + 1}`, approver: `ep:approver:eg1_${i + 1}` }));
|
|
102
|
+
const members = people.map((p, i) => {
|
|
103
|
+
const s = mintDeviceSignoff({ actionHash, approver: p.approver, issuedAtMs: issuedAtMs + i * 1000 });
|
|
104
|
+
return { role: p.role, approver_public_key: s.approver_public_key, signoff: { '@type': s.signoff['@type'], context: s.signoff.context, webauthn: s.signoff.webauthn } };
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
'@type': 'ep.quorum',
|
|
108
|
+
action_hash: actionHash,
|
|
109
|
+
policy: {
|
|
110
|
+
mode: 'threshold',
|
|
111
|
+
required: threshold,
|
|
112
|
+
approvers: people,
|
|
113
|
+
distinct_humans: true,
|
|
114
|
+
window_sec: 900,
|
|
115
|
+
},
|
|
116
|
+
members,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
46
120
|
// The default high-risk action EG-1 exercises: a Class-A money movement, which
|
|
47
121
|
// the default gate manifest guards (selector { protocol:'mcp', tool:'release_payment' }).
|
|
48
122
|
export const EG1_DEFAULT_SELECTOR = Object.freeze({ protocol: 'mcp', tool: 'release_payment' });
|
|
@@ -87,6 +161,10 @@ export function createEg1Harness({ now = Date.now, action = EG1_DEFAULT_ACTION,
|
|
|
87
161
|
let counter = 0;
|
|
88
162
|
const nowMs = () => (typeof now === 'function' ? now() : now);
|
|
89
163
|
|
|
164
|
+
// The action_hash the self-contained human device assertions are bound to.
|
|
165
|
+
// Derived from the action so the signoff/quorum evidence is about THIS action.
|
|
166
|
+
const actionHash = crypto.createHash('sha256').update(canon(action), 'utf8').digest('hex');
|
|
167
|
+
|
|
90
168
|
function assuranceContext(payload) {
|
|
91
169
|
return {
|
|
92
170
|
'@version': 'EP-ASSURANCE-CONTEXT-v1',
|
|
@@ -140,26 +218,46 @@ export function createEg1Harness({ now = Date.now, action = EG1_DEFAULT_ACTION,
|
|
|
140
218
|
/**
|
|
141
219
|
* Mint a scenario receipt.
|
|
142
220
|
* @param {object} o
|
|
143
|
-
* @param {'allow'|'allow_with_signoff'} [o.outcome] 'allow'=software
|
|
144
|
-
*
|
|
221
|
+
* @param {'allow'|'allow_with_signoff'} [o.outcome] 'allow'=software; 'allow_with_signoff'
|
|
222
|
+
* embeds a REAL WebAuthn device signoff so the receipt cryptographically earns class_a.
|
|
223
|
+
* @param {object|boolean} [o.quorum] request quorum-tier evidence. Truthy -> a REAL
|
|
224
|
+
* EP-QUORUM-v1 doc (distinct humans + distinct keys + per-signer assertions). If an
|
|
225
|
+
* object with `threshold`/`signers`, its size sets the quorum size.
|
|
226
|
+
* @param {boolean} [o.fakeQuorum] embed an UNVERIFIABLE self-asserted quorum block
|
|
227
|
+
* ({signers,threshold}) with no per-signer signatures — used to prove the Gate
|
|
228
|
+
* REFUSES it (must NOT be credited quorum). For adversarial tests only.
|
|
145
229
|
* @param {object} [o.tamper] fields assigned to the claim AFTER signing (breaks the signature)
|
|
146
230
|
*/
|
|
147
|
-
function mint({ outcome = 'allow_with_signoff', quorum = null, tamper = null, extra = {} } = {}) {
|
|
231
|
+
function mint({ outcome = 'allow_with_signoff', quorum = null, fakeQuorum = false, tamper = null, extra = {} } = {}) {
|
|
232
|
+
const claim = { ...action, outcome, approver: 'ep:approver:eg1', ...extra };
|
|
148
233
|
const payload = {
|
|
149
234
|
receipt_id: `${idPrefix}_${++counter}`,
|
|
150
235
|
subject: 'agent:eg1-conformance',
|
|
151
236
|
issuer: 'ep:org:eg1',
|
|
152
237
|
created_at: new Date(nowMs()).toISOString(),
|
|
153
|
-
claim
|
|
154
|
-
...action,
|
|
155
|
-
outcome,
|
|
156
|
-
approver: 'ep:approver:eg1',
|
|
157
|
-
...(quorum ? { quorum } : {}),
|
|
158
|
-
...extra,
|
|
159
|
-
},
|
|
238
|
+
claim,
|
|
160
239
|
};
|
|
161
|
-
if (
|
|
240
|
+
if (fakeQuorum) {
|
|
241
|
+
// Self-asserted ONLY — NO members / NO signatures / NO pinned proof. The
|
|
242
|
+
// Gate must refuse to credit this as quorum (assurance_too_low). For
|
|
243
|
+
// adversarial tests that prove payload claims are never trusted.
|
|
244
|
+
payload.quorum = { signers: ['ep:a', 'ep:b'], threshold: 2 };
|
|
245
|
+
} else if (outcome === 'allow_with_signoff' || quorum) {
|
|
246
|
+
// Genuine, per-signer-verifiable evidence. The PRIMARY proof is the pinned
|
|
247
|
+
// EP-ASSURANCE-PROOF-v1 (verified against the harness's pinned approverKeys),
|
|
248
|
+
// which is what the EG-1 gate/custody path checks. We ALSO embed self-contained
|
|
249
|
+
// evidence (EP-QUORUM-v1 / WebAuthn device signoff) so a relying party that
|
|
250
|
+
// does NOT pin keys can still cryptographically credit the tier (DoD audit fix).
|
|
162
251
|
payload.assurance_proof = assuranceProof(payload, quorum);
|
|
252
|
+
if (quorum) {
|
|
253
|
+
const threshold = Number.isInteger(quorum.threshold) ? quorum.threshold
|
|
254
|
+
: (Array.isArray(quorum.signers) ? quorum.signers.length : 2);
|
|
255
|
+
payload.quorum = mintQuorumEvidence({ actionHash, threshold, issuedAtMs: nowMs() });
|
|
256
|
+
} else {
|
|
257
|
+
const s = mintDeviceSignoff({ actionHash, approver: 'ep:approver:eg1', issuedAtMs: nowMs() });
|
|
258
|
+
payload.signoff = s.signoff;
|
|
259
|
+
payload.approver_public_key = s.approver_public_key;
|
|
260
|
+
}
|
|
163
261
|
}
|
|
164
262
|
const value = crypto.sign(null, Buffer.from(canon(payload), 'utf8'), privateKey).toString('base64url');
|
|
165
263
|
const receipt = { '@version': 'EP-RECEIPT-v1', payload, signature: { algorithm: 'Ed25519', value } };
|
|
@@ -167,7 +265,7 @@ export function createEg1Harness({ now = Date.now, action = EG1_DEFAULT_ACTION,
|
|
|
167
265
|
return receipt;
|
|
168
266
|
}
|
|
169
267
|
|
|
170
|
-
return { publicKey: pub, approverKeys, mint, action, now: nowMs };
|
|
268
|
+
return { publicKey: pub, approverKeys, mint, action, actionHash, now: nowMs };
|
|
171
269
|
}
|
|
172
270
|
|
|
173
271
|
/**
|
|
@@ -270,4 +368,4 @@ export async function runEg1({ invoke, harness, action } = {}) {
|
|
|
270
368
|
};
|
|
271
369
|
}
|
|
272
370
|
|
|
273
|
-
export default { EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR, createEg1Harness, makeGateInvoke, runEg1 };
|
|
371
|
+
export default { EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR, createEg1Harness, makeGateInvoke, runEg1, mintDeviceSignoff, mintQuorumEvidence };
|