@emilia-protocol/gate 0.9.1 → 0.9.2

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/index.js CHANGED
@@ -73,6 +73,13 @@ export {
73
73
  } from './action-control-manifest.js';
74
74
  export { EXECUTION_BINDING_VERSION, canonicalize, hashCanonical, materialFieldsFor, verifyExecutionBinding } from './execution-binding.js';
75
75
  export { RELIANCE_PACKET_VERSION, ADMISSIBILITY_VERDICTS, buildReliancePacket } from './reliance-packet.js';
76
+ export {
77
+ EXTERNAL_VERIFICATION_STATEMENT_VERSION,
78
+ EXTERNAL_VERIFICATION_DOMAIN,
79
+ externalVerificationDigest,
80
+ signExternalVerificationStatement,
81
+ verifyExternalVerificationStatement,
82
+ } from './reports/external-verification.js';
76
83
  export {
77
84
  EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR,
78
85
  createEg1Harness, makeGateInvoke, runEg1, mintDeviceSignoff, mintQuorumEvidence,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emilia-protocol/gate",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "EMILIA Gate \u2014 the Trusted Action Firewall. Deny-by-default enforcement for consequential machine actions: an action runs only with a valid, in-scope, sufficiently-assured, non-replayed EMILIA authorization receipt (proof a named human authorized this exact action). Composes require-receipt + verify; adds assurance-tier enforcement, one-time consumption (replay defense), a tamper-evident evidence log, default high-risk action packs, execution-field binding, reliance packets, an MCP drop-in, a GitHub system-of-record adapter, and EG-1 conformance. Framework-agnostic, fails closed. Reference implementation, experimental.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -34,7 +34,8 @@
34
34
  "./breakglass": "./breakglass.js",
35
35
  "./store-postgres": "./store-postgres.js",
36
36
  "./reports/auditor-workpaper": "./reports/auditor-workpaper.js",
37
- "./reports/reperform": "./reports/reperform.js"
37
+ "./reports/reperform": "./reports/reperform.js",
38
+ "./reports/external-verification": "./reports/external-verification.js"
38
39
  },
39
40
  "files": [
40
41
  "index.js",
@@ -91,7 +92,8 @@
91
92
  "deploy/terraform/versions.tf",
92
93
  "deploy/terraform/README.md",
93
94
  "reports/auditor-workpaper.js",
94
- "reports/reperform.js"
95
+ "reports/reperform.js",
96
+ "reports/external-verification.js"
95
97
  ],
96
98
  "scripts": {
97
99
  "test": "node --test",
@@ -0,0 +1,223 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EP-EXTERNAL-VERIFICATION-STATEMENT-v1.
4
+ *
5
+ * A signed statement a NON-EMILIA verifier can issue after it re-performs an
6
+ * evidence log, replays an admissibility profile, or runs a conformance harness.
7
+ * This is the missing adoption rail between "our verifier works" and "an
8
+ * outside party says exactly what they checked."
9
+ *
10
+ * Scope is intentionally narrow:
11
+ * - the statement signs a procedure, inputs, result, and limitations;
12
+ * - it does NOT authorize an action;
13
+ * - it does NOT certify business correctness;
14
+ * - acceptance is by a relying party pinning the external verifier key.
15
+ */
16
+ import crypto from 'node:crypto';
17
+ // In-package canonicalize (byte-identical to lib/canonical-json.js): reports must
18
+ // never import outside the package root or the published tarball cannot resolve it.
19
+ import { canonicalize } from '../execution-binding.js';
20
+
21
+ export const EXTERNAL_VERIFICATION_STATEMENT_VERSION = 'EP-EXTERNAL-VERIFICATION-STATEMENT-v1';
22
+ export const EXTERNAL_VERIFICATION_DOMAIN = 'EP-EXTERNAL-VERIFICATION-STATEMENT-v1\0';
23
+
24
+ const SHA256_RE = /^sha256:[0-9a-f]{64}$/i;
25
+
26
+ function sha256hex(bytes) {
27
+ return crypto.createHash('sha256').update(bytes).digest('hex');
28
+ }
29
+
30
+ function publicKeyToB64u(key) {
31
+ return crypto.createPublicKey(key).export({ type: 'spki', format: 'der' }).toString('base64url');
32
+ }
33
+
34
+ function keyIdFor(publicKeyB64u) {
35
+ return `ep:external-verifier-key:sha256:${sha256hex(Buffer.from(publicKeyB64u, 'base64url')).slice(0, 16)}`;
36
+ }
37
+
38
+ function signingBytes(unsignedStatement) {
39
+ return Buffer.from(EXTERNAL_VERIFICATION_DOMAIN + canonicalize(unsignedStatement), 'utf8');
40
+ }
41
+
42
+ function unsigned(statement) {
43
+ if (!statement || typeof statement !== 'object' || Array.isArray(statement)) {
44
+ throw new Error('statement must be an object');
45
+ }
46
+ const { signature: _signature, ...body } = statement;
47
+ return body;
48
+ }
49
+
50
+ /** Digest of the signed statement body, excluding the signature envelope. */
51
+ export function externalVerificationDigest(statement) {
52
+ return `sha256:${sha256hex(signingBytes(unsigned(statement)))}`;
53
+ }
54
+
55
+ function normalizeChecks(checks) {
56
+ if (!Array.isArray(checks)) return [];
57
+ return checks.map((c) => ({
58
+ id: String(c?.id ?? ''),
59
+ ok: c?.ok === true,
60
+ ...(c?.detail !== undefined ? { detail: c.detail } : {}),
61
+ })).filter((c) => c.id);
62
+ }
63
+
64
+ /**
65
+ * Build and sign an external-verifier statement.
66
+ *
67
+ * @param {object} args
68
+ * @param {object} args.verifier {id, name?, organization?}
69
+ * @param {object} args.subject what was checked, e.g. {kind:'evidence_log', head:'sha256:...'}
70
+ * @param {object} args.procedure {id, version?, tool?, command?}
71
+ * @param {object} args.result {status, checks?, artifact_digest?}
72
+ * @param {object} [args.inputs] stable digests/ids the procedure consumed
73
+ * @param {string[]} [args.limitations] honest non-claims
74
+ * @param {string|number} [args.generated_at] ISO or epoch millis
75
+ * @param {crypto.KeyObject} privateKey Ed25519 private key
76
+ */
77
+ export function signExternalVerificationStatement(args, privateKey) {
78
+ if (!privateKey) throw new Error('privateKey is required');
79
+ const generatedAt = args?.generated_at !== undefined
80
+ ? new Date(args.generated_at).toISOString()
81
+ : new Date().toISOString();
82
+ const publicKey = publicKeyToB64u(privateKey);
83
+ const body = {
84
+ '@version': EXTERNAL_VERIFICATION_STATEMENT_VERSION,
85
+ generated_at: generatedAt,
86
+ verifier: {
87
+ id: args?.verifier?.id ?? keyIdFor(publicKey),
88
+ ...(args?.verifier?.name ? { name: args.verifier.name } : {}),
89
+ ...(args?.verifier?.organization ? { organization: args.verifier.organization } : {}),
90
+ },
91
+ subject: args?.subject ?? {},
92
+ procedure: args?.procedure ?? {},
93
+ inputs: args?.inputs ?? {},
94
+ result: {
95
+ status: String(args?.result?.status ?? 'unknown'),
96
+ checks: normalizeChecks(args?.result?.checks),
97
+ ...(args?.result?.artifact_digest ? { artifact_digest: args.result.artifact_digest } : {}),
98
+ },
99
+ limitations: Array.isArray(args?.limitations) && args.limitations.length
100
+ ? args.limitations.map(String)
101
+ : [
102
+ 'This statement records the external verifier procedure and result; it does not authorize the action.',
103
+ 'It does not certify business correctness, legal compliance, or human wisdom.',
104
+ 'Acceptance depends on the relying party pinning the verifier key out of band.',
105
+ 'The statement carries no expiry and no consumer binding; it is replayable verbatim, and generated_at is asserted by the signer, not verified.',
106
+ ],
107
+ };
108
+
109
+ const digest = externalVerificationDigest(body);
110
+ const sig = crypto.sign(null, signingBytes(body), privateKey).toString('base64url');
111
+ return Object.freeze({
112
+ ...body,
113
+ signature: {
114
+ algorithm: 'Ed25519',
115
+ key_id: keyIdFor(publicKey),
116
+ public_key: publicKey,
117
+ statement_digest: digest,
118
+ signature_b64u: sig,
119
+ },
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Verify a signed external-verifier statement against pinned verifier keys.
125
+ *
126
+ * @param {object} statement
127
+ * @param {{pinnedVerifierKeys:Array<{verifier_id?:string,key_id?:string,public_key:string}>}} opts
128
+ * @returns {{verified:boolean, accepted:boolean, checks:object, reason?:string, statement_digest?:string}}
129
+ */
130
+ export function verifyExternalVerificationStatement(statement, opts = {}) {
131
+ const fail = (reason, extra = {}) => ({
132
+ verified: false,
133
+ accepted: false,
134
+ checks: {
135
+ version: statement?.['@version'] === EXTERNAL_VERIFICATION_STATEMENT_VERSION,
136
+ signature: false,
137
+ pinned_verifier_key: false,
138
+ statement_digest: false,
139
+ },
140
+ reason,
141
+ ...extra,
142
+ });
143
+
144
+ if (statement?.['@version'] !== EXTERNAL_VERIFICATION_STATEMENT_VERSION) {
145
+ return fail('unsupported_version');
146
+ }
147
+ const sig = statement.signature;
148
+ if (!sig || sig.algorithm !== 'Ed25519' || typeof sig.public_key !== 'string' || typeof sig.signature_b64u !== 'string') {
149
+ return fail('signature_missing_or_malformed');
150
+ }
151
+ if (typeof sig.statement_digest !== 'string' || !SHA256_RE.test(sig.statement_digest)) {
152
+ return fail('statement_digest_missing_or_malformed');
153
+ }
154
+
155
+ let digest;
156
+ try {
157
+ digest = externalVerificationDigest(statement);
158
+ } catch {
159
+ return fail('statement_uncanonicalizable');
160
+ }
161
+ if (digest !== sig.statement_digest) {
162
+ return fail('statement_digest_mismatch', { statement_digest: digest });
163
+ }
164
+
165
+ const pinned = Array.isArray(opts.pinnedVerifierKeys) ? opts.pinnedVerifierKeys : [];
166
+ const verifierId = statement.verifier?.id ?? null;
167
+ // key_id is always DERIVED from the carried public key. The envelope's key_id is
168
+ // outside the signed bytes, so it is attacker-malleable; if present it must match
169
+ // the derived value or the statement is refused.
170
+ const keyId = keyIdFor(sig.public_key);
171
+ if (sig.key_id !== undefined && sig.key_id !== keyId) {
172
+ return fail('key_id_mismatch', { statement_digest: digest });
173
+ }
174
+ // A pin grants an identity, not just a key: every usable pin entry must name the
175
+ // verifier_id it vouches for. A pin that matches the key but omits verifier_id
176
+ // (or names a different one) never binds the statement's claimed identity.
177
+ const keyMatched = pinned.filter((k) => k?.public_key === sig.public_key
178
+ && (k.key_id === undefined || k.key_id === keyId));
179
+ const pin = keyMatched.find((k) => typeof k.verifier_id === 'string' && k.verifier_id === verifierId);
180
+ if (!pin) {
181
+ return {
182
+ verified: false,
183
+ accepted: false,
184
+ checks: { version: true, signature: false, pinned_verifier_key: false, statement_digest: true },
185
+ reason: keyMatched.length ? 'pin_missing_or_mismatched_verifier_id' : 'verifier_key_not_pinned',
186
+ statement_digest: digest,
187
+ };
188
+ }
189
+
190
+ let ok = false;
191
+ try {
192
+ const publicKey = crypto.createPublicKey({ key: Buffer.from(sig.public_key, 'base64url'), type: 'spki', format: 'der' });
193
+ ok = crypto.verify(null, signingBytes(unsigned(statement)), publicKey, Buffer.from(sig.signature_b64u, 'base64url'));
194
+ } catch {
195
+ ok = false;
196
+ }
197
+ if (!ok) {
198
+ return {
199
+ verified: false,
200
+ accepted: false,
201
+ checks: { version: true, signature: false, pinned_verifier_key: true, statement_digest: true },
202
+ reason: 'signature_invalid',
203
+ statement_digest: digest,
204
+ };
205
+ }
206
+
207
+ return {
208
+ verified: true,
209
+ accepted: true,
210
+ checks: { version: true, signature: true, pinned_verifier_key: true, statement_digest: true },
211
+ verifier_id: verifierId,
212
+ key_id: keyId,
213
+ statement_digest: digest,
214
+ };
215
+ }
216
+
217
+ export default {
218
+ EXTERNAL_VERIFICATION_STATEMENT_VERSION,
219
+ EXTERNAL_VERIFICATION_DOMAIN,
220
+ externalVerificationDigest,
221
+ signExternalVerificationStatement,
222
+ verifyExternalVerificationStatement,
223
+ };