@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.
@@ -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
+ }
@@ -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, 'allow_with_signoff'=Class-A
144
- * @param {object} [o.quorum] a quorum block (signers/threshold) -> quorum tier
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 (outcome === 'allow_with_signoff' || quorum) {
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 };