@emilia-protocol/gate 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,397 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EP-ACTION-ESCROW-STATE-STATEMENT-v1
4
+ *
5
+ * A portable, operator-signed statement over one exact durable Action Escrow
6
+ * snapshot. The signature authenticates an operator statement; it does not
7
+ * prove the operator's database was complete or that a custodian moved money.
8
+ */
9
+ import crypto from 'node:crypto';
10
+ import { canonicalize, hashCanonical } from './execution-binding.js';
11
+ import { ACTION_ESCROW_EVIDENCE_STAGES } from './action-escrow-evidence.js';
12
+
13
+ export const ACTION_ESCROW_STATE_STATEMENT_VERSION = 'EP-ACTION-ESCROW-STATE-STATEMENT-v1';
14
+ export const ACTION_ESCROW_STATE_STATEMENT_DOMAIN = `${ACTION_ESCROW_STATE_STATEMENT_VERSION}\0`;
15
+
16
+ const HASH = /^sha256:[0-9a-f]{64}$/;
17
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:/#@+-]{0,255}$/;
18
+ const BASE64URL = /^[A-Za-z0-9_-]+$/;
19
+ const TOP_KEYS = new Set(['version', 'issuer', 'payload', 'statement_digest', 'signature']);
20
+ const ISSUER_KEYS = new Set(['operator_id', 'key_id']);
21
+ const PAYLOAD_KEYS = new Set([
22
+ 'statement_id',
23
+ 'agreement_id',
24
+ 'binding_digest',
25
+ 'action_digest',
26
+ 'profile_digest',
27
+ 'state',
28
+ 'revision',
29
+ 'amendment_digests',
30
+ 'state_record_digest',
31
+ 'previous_statement_digest',
32
+ 'occurred_at',
33
+ ]);
34
+ const SIGNATURE_KEYS = new Set(['algorithm', 'signature_b64u']);
35
+
36
+ function isRecord(value) {
37
+ return value !== null
38
+ && typeof value === 'object'
39
+ && !Array.isArray(value)
40
+ && (Object.getPrototypeOf(value) === Object.prototype
41
+ || Object.getPrototypeOf(value) === null);
42
+ }
43
+
44
+ function exactKeys(value, keys) {
45
+ return isRecord(value)
46
+ && Object.keys(value).length === keys.size
47
+ && Object.keys(value).every((key) => keys.has(key));
48
+ }
49
+
50
+ function strictInstant(value) {
51
+ if (typeof value !== 'string') return NaN;
52
+ const match = value.match(
53
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?Z$/,
54
+ );
55
+ if (!match) return NaN;
56
+ const [, year, month, day, hour, minute, second] = match;
57
+ const calendar = new Date(0);
58
+ calendar.setUTCFullYear(Number(year), Number(month) - 1, Number(day));
59
+ calendar.setUTCHours(Number(hour), Number(minute), Number(second), 0);
60
+ if (calendar.toISOString().slice(0, 19)
61
+ !== `${year}-${month}-${day}T${hour}:${minute}:${second}`) return NaN;
62
+ const parsed = Date.parse(value);
63
+ return Number.isFinite(parsed) ? parsed : NaN;
64
+ }
65
+
66
+ function boundedCanonicalCopy(value) {
67
+ let nodes = 0;
68
+ let bytes = 0;
69
+ const seen = new WeakSet();
70
+
71
+ function copy(current, depth) {
72
+ nodes += 1;
73
+ if (nodes > 50_000 || depth > 64) throw new TypeError('state statement exceeds resource limits');
74
+ if (current === null || typeof current === 'boolean') return current;
75
+ if (typeof current === 'string') {
76
+ bytes += Buffer.byteLength(current, 'utf8');
77
+ if (bytes > 4 * 1024 * 1024) throw new TypeError('state statement exceeds string limit');
78
+ return current;
79
+ }
80
+ if (typeof current === 'number') {
81
+ if (!Number.isSafeInteger(current) || Object.is(current, -0)) {
82
+ throw new TypeError('state statement contains a non-canonical number');
83
+ }
84
+ return current;
85
+ }
86
+ if (!isRecord(current) && !Array.isArray(current)) {
87
+ throw new TypeError('state statement is not canonical JSON');
88
+ }
89
+ if (seen.has(current)) throw new TypeError('state statement contains an alias or cycle');
90
+ seen.add(current);
91
+ if (Array.isArray(current)) return current.map((entry) => copy(entry, depth + 1));
92
+ return Object.fromEntries(
93
+ Object.entries(current).map(([key, entry]) => [key, copy(entry, depth + 1)]),
94
+ );
95
+ }
96
+
97
+ return copy(value, 0);
98
+ }
99
+
100
+ function canonicalHash(value) {
101
+ return `sha256:${hashCanonical(value)}`;
102
+ }
103
+
104
+ function signingBody(statement) {
105
+ return {
106
+ version: statement.version,
107
+ issuer: statement.issuer,
108
+ payload: statement.payload,
109
+ };
110
+ }
111
+
112
+ function stateSigningBytes(statement) {
113
+ const body = boundedCanonicalCopy(signingBody(statement));
114
+ return Buffer.from(
115
+ ACTION_ESCROW_STATE_STATEMENT_DOMAIN + canonicalize(body),
116
+ 'utf8',
117
+ );
118
+ }
119
+
120
+ function deepFreeze(value) {
121
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
122
+ Object.freeze(value);
123
+ for (const child of Object.values(value)) deepFreeze(child);
124
+ return value;
125
+ }
126
+
127
+ function strictBase64url(value, length) {
128
+ if (typeof value !== 'string' || !BASE64URL.test(value) || value.length % 4 === 1) return null;
129
+ const bytes = Buffer.from(value, 'base64url');
130
+ if (bytes.toString('base64url') !== value || (length !== undefined && bytes.length !== length)) return null;
131
+ return bytes;
132
+ }
133
+
134
+ function validDigestList(value) {
135
+ return Array.isArray(value)
136
+ && value.length <= 1024
137
+ && value.every((entry) => typeof entry === 'string' && HASH.test(entry))
138
+ && new Set(value).size === value.length;
139
+ }
140
+
141
+ function payloadValid(payload) {
142
+ return exactKeys(payload, PAYLOAD_KEYS)
143
+ && typeof payload.statement_id === 'string' && ID.test(payload.statement_id)
144
+ && typeof payload.agreement_id === 'string' && ID.test(payload.agreement_id)
145
+ && HASH.test(payload.binding_digest)
146
+ && HASH.test(payload.action_digest)
147
+ && HASH.test(payload.profile_digest)
148
+ && ACTION_ESCROW_EVIDENCE_STAGES.includes(payload.state)
149
+ && Number.isSafeInteger(payload.revision) && payload.revision >= 0
150
+ && validDigestList(payload.amendment_digests)
151
+ && HASH.test(payload.state_record_digest)
152
+ && (payload.previous_statement_digest === null || HASH.test(payload.previous_statement_digest))
153
+ && Number.isFinite(strictInstant(payload.occurred_at));
154
+ }
155
+
156
+ function refuse(reason, checks) {
157
+ return {
158
+ valid: false,
159
+ reason,
160
+ checks,
161
+ statement_digest: null,
162
+ agreement_id: null,
163
+ binding_digest: null,
164
+ action_digest: null,
165
+ profile_digest: null,
166
+ state: null,
167
+ revision: null,
168
+ amendment_digests: [],
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Sign one exact state snapshot. Issuance may throw on invalid local input;
174
+ * verification below never throws.
175
+ */
176
+ export function signActionEscrowStateStatement({
177
+ statementId,
178
+ agreementId,
179
+ bindingDigest,
180
+ actionDigest,
181
+ profileDigest,
182
+ state,
183
+ revision,
184
+ amendmentDigests = [],
185
+ stateRecord,
186
+ previousStatementDigest = null,
187
+ occurredAt,
188
+ } = {}, {
189
+ operatorId,
190
+ keyId,
191
+ privateKey,
192
+ } = {}) {
193
+ const stateRecordCopy = boundedCanonicalCopy(stateRecord);
194
+ const statement = {
195
+ version: ACTION_ESCROW_STATE_STATEMENT_VERSION,
196
+ issuer: {
197
+ operator_id: operatorId,
198
+ key_id: keyId,
199
+ },
200
+ payload: {
201
+ statement_id: statementId,
202
+ agreement_id: agreementId,
203
+ binding_digest: bindingDigest,
204
+ action_digest: actionDigest,
205
+ profile_digest: profileDigest,
206
+ state,
207
+ revision,
208
+ amendment_digests: boundedCanonicalCopy(amendmentDigests),
209
+ state_record_digest: canonicalHash(stateRecordCopy),
210
+ previous_statement_digest: previousStatementDigest,
211
+ occurred_at: occurredAt,
212
+ },
213
+ };
214
+ if (!exactKeys(statement.issuer, ISSUER_KEYS)
215
+ || typeof operatorId !== 'string' || !ID.test(operatorId)
216
+ || typeof keyId !== 'string' || !ID.test(keyId)
217
+ || !payloadValid(statement.payload)) {
218
+ throw new TypeError('action-escrow state statement input is invalid');
219
+ }
220
+ const key = privateKey instanceof crypto.KeyObject ? privateKey : crypto.createPrivateKey(privateKey);
221
+ if (key.asymmetricKeyType !== 'ed25519') {
222
+ throw new TypeError('action-escrow state statement key must be Ed25519');
223
+ }
224
+ const bytes = stateSigningBytes(statement);
225
+ const statementDigest = `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`;
226
+ return deepFreeze({
227
+ ...statement,
228
+ statement_digest: statementDigest,
229
+ signature: {
230
+ algorithm: 'Ed25519',
231
+ signature_b64u: crypto.sign(null, bytes, key).toString('base64url'),
232
+ },
233
+ });
234
+ }
235
+
236
+ /**
237
+ * Verify one state statement against an exact snapshot and relying-party pins.
238
+ */
239
+ export function verifyActionEscrowStateStatement(statement, {
240
+ trustedKeys,
241
+ stateRecord,
242
+ expectedAgreementId,
243
+ expectedBindingDigest,
244
+ expectedActionDigest,
245
+ expectedProfileDigest,
246
+ expectedState,
247
+ expectedRevision,
248
+ expectedAmendmentDigests,
249
+ expectedPreviousStatementDigest,
250
+ now,
251
+ } = {}) {
252
+ const checks = {
253
+ structure: false,
254
+ payload: false,
255
+ issuer_pin: false,
256
+ signature: false,
257
+ statement_digest: false,
258
+ state_record: false,
259
+ expected_bindings: false,
260
+ time: false,
261
+ };
262
+ try {
263
+ checks.structure = exactKeys(statement, TOP_KEYS)
264
+ && statement.version === ACTION_ESCROW_STATE_STATEMENT_VERSION
265
+ && exactKeys(statement.issuer, ISSUER_KEYS)
266
+ && exactKeys(statement.signature, SIGNATURE_KEYS);
267
+ if (!checks.structure) return refuse('malformed_state_statement', checks);
268
+
269
+ checks.payload = payloadValid(statement.payload);
270
+ if (!checks.payload) return refuse('invalid_state_payload', checks);
271
+
272
+ const pin = isRecord(trustedKeys) && Object.hasOwn(trustedKeys, statement.issuer.key_id)
273
+ ? trustedKeys[statement.issuer.key_id]
274
+ : null;
275
+ checks.issuer_pin = exactKeys(pin, new Set(['operator_id', 'public_key']))
276
+ && pin.operator_id === statement.issuer.operator_id
277
+ && typeof pin.public_key === 'string';
278
+ if (!checks.issuer_pin) return refuse('operator_key_not_pinned', checks);
279
+
280
+ const publicBytes = strictBase64url(pin.public_key);
281
+ const signatureBytes = strictBase64url(statement.signature.signature_b64u, 64);
282
+ let publicKey = null;
283
+ try {
284
+ publicKey = crypto.createPublicKey({ key: publicBytes, format: 'der', type: 'spki' });
285
+ } catch {
286
+ publicKey = null;
287
+ }
288
+ const bytes = stateSigningBytes(statement);
289
+ checks.signature = statement.signature.algorithm === 'Ed25519'
290
+ && publicBytes !== null
291
+ && signatureBytes !== null
292
+ && publicKey?.asymmetricKeyType === 'ed25519'
293
+ && crypto.verify(null, bytes, publicKey, signatureBytes);
294
+ if (!checks.signature) return refuse('state_signature_invalid', checks);
295
+
296
+ const digest = `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`;
297
+ checks.statement_digest = statement.statement_digest === digest;
298
+ if (!checks.statement_digest) return refuse('state_statement_digest_mismatch', checks);
299
+
300
+ checks.state_record = canonicalHash(boundedCanonicalCopy(stateRecord))
301
+ === statement.payload.state_record_digest;
302
+ if (!checks.state_record) return refuse('state_record_digest_mismatch', checks);
303
+
304
+ const amendments = expectedAmendmentDigests;
305
+ checks.expected_bindings = typeof expectedAgreementId === 'string'
306
+ && statement.payload.agreement_id === expectedAgreementId
307
+ && statement.payload.binding_digest === expectedBindingDigest
308
+ && statement.payload.action_digest === expectedActionDigest
309
+ && statement.payload.profile_digest === expectedProfileDigest
310
+ && statement.payload.state === expectedState
311
+ && statement.payload.revision === expectedRevision
312
+ && Array.isArray(amendments)
313
+ && statement.payload.amendment_digests.length === amendments.length
314
+ && statement.payload.amendment_digests.every((entry, index) => entry === amendments[index])
315
+ && statement.payload.previous_statement_digest === expectedPreviousStatementDigest;
316
+ if (!checks.expected_bindings) return refuse('state_expected_binding_mismatch', checks);
317
+
318
+ const evaluation = now instanceof Date
319
+ ? now.getTime()
320
+ : typeof now === 'number' ? now : strictInstant(now);
321
+ checks.time = Number.isFinite(evaluation)
322
+ && strictInstant(statement.payload.occurred_at) <= evaluation;
323
+ if (!checks.time) return refuse('state_statement_from_future', checks);
324
+
325
+ return {
326
+ valid: true,
327
+ reason: 'verified',
328
+ checks,
329
+ statement_digest: digest,
330
+ agreement_id: statement.payload.agreement_id,
331
+ binding_digest: statement.payload.binding_digest,
332
+ action_digest: statement.payload.action_digest,
333
+ profile_digest: statement.payload.profile_digest,
334
+ state: statement.payload.state,
335
+ revision: statement.payload.revision,
336
+ amendment_digests: [...statement.payload.amendment_digests],
337
+ };
338
+ } catch {
339
+ return refuse('malformed_state_statement', checks);
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Build the callback expected by verifyActionEscrowEvidencePackage. The
345
+ * package carries both the exact durable snapshot and the signed statement
346
+ * over it; trust keys and time remain verifier configuration.
347
+ */
348
+ export function createActionEscrowStatePackageVerifier({
349
+ trustedKeys,
350
+ now,
351
+ minimumRevision = 0,
352
+ } = {}) {
353
+ if (!Number.isSafeInteger(minimumRevision) || minimumRevision < 0) {
354
+ throw new TypeError('minimumRevision must be a non-negative safe integer');
355
+ }
356
+ const pinnedKeys = boundedCanonicalCopy(trustedKeys);
357
+ return async function verifyPackagedState(packaged, expected = {}) {
358
+ if (!exactKeys(packaged, new Set(['snapshot', 'statement']))
359
+ || !isRecord(packaged.statement?.payload)
360
+ || !isRecord(packaged.snapshot)
361
+ || packaged.statement.payload.state !== packaged.snapshot.state
362
+ || packaged.statement.payload.revision !== packaged.snapshot.revision
363
+ || packaged.statement.payload.revision < minimumRevision) {
364
+ return refuse('malformed_packaged_state', {
365
+ structure: false,
366
+ payload: false,
367
+ issuer_pin: false,
368
+ signature: false,
369
+ statement_digest: false,
370
+ state_record: false,
371
+ expected_bindings: false,
372
+ time: false,
373
+ });
374
+ }
375
+ return verifyActionEscrowStateStatement(packaged.statement, {
376
+ trustedKeys: pinnedKeys,
377
+ stateRecord: packaged.snapshot,
378
+ expectedAgreementId: expected.agreementId,
379
+ expectedBindingDigest: expected.bindingDigest,
380
+ expectedActionDigest: expected.actionDigest,
381
+ expectedProfileDigest: expected.profileDigest,
382
+ expectedState: expected.stage,
383
+ expectedRevision: packaged.statement.payload.revision,
384
+ expectedAmendmentDigests: expected.amendmentDigests,
385
+ expectedPreviousStatementDigest: packaged.statement.payload.previous_statement_digest,
386
+ now,
387
+ });
388
+ };
389
+ }
390
+
391
+ export default {
392
+ ACTION_ESCROW_STATE_STATEMENT_VERSION,
393
+ ACTION_ESCROW_STATE_STATEMENT_DOMAIN,
394
+ signActionEscrowStateStatement,
395
+ verifyActionEscrowStateStatement,
396
+ createActionEscrowStatePackageVerifier,
397
+ };