trustdex 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,202 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ import { canonicalJson, sha256Json } from './canonical.mjs';
4
+ import {
5
+ normalizeRuntimeToolInventory,
6
+ runtimeToolInventoryDigest
7
+ } from './runtime-evidence.mjs';
8
+
9
+ const KIND = 'agent-runtime-tool-inventory-attestation';
10
+ const ALGORITHM = 'ed25519';
11
+ const MAX_TTL_SECONDS = 86_400;
12
+ const MAX_FUTURE_SKEW_SECONDS = 300;
13
+ const FIELDS = new Set([
14
+ 'schemaVersion', 'kind', 'algorithm', 'inventoryDigest', 'runtime', 'capturedAt',
15
+ 'captureMethod', 'producerKeyId', 'createdAt', 'expiresAt', 'signature'
16
+ ]);
17
+
18
+ function requireObject(value, message) {
19
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(message);
20
+ return value;
21
+ }
22
+
23
+ function requireExactKeys(value, expected, message) {
24
+ const keys = Object.keys(requireObject(value, message));
25
+ if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) throw new Error(message);
26
+ return value;
27
+ }
28
+
29
+ function requireDigest(value, field) {
30
+ if (typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value)) {
31
+ throw new Error(`Invalid runtime inventory attestation digest: ${field}`);
32
+ }
33
+ return value;
34
+ }
35
+
36
+ function requireTimestamp(value, field) {
37
+ if (typeof value !== 'string' || Number.isNaN(Date.parse(value)) ||
38
+ new Date(value).toISOString() !== value) {
39
+ throw new Error(`Invalid runtime inventory attestation timestamp: ${field}`);
40
+ }
41
+ return value;
42
+ }
43
+
44
+ function publicKeyId(publicKey) {
45
+ const key = publicKey?.type === 'public' ? publicKey : crypto.createPublicKey(publicKey);
46
+ if (key.asymmetricKeyType !== 'ed25519') {
47
+ throw new Error('Runtime inventory attestations require an Ed25519 key.');
48
+ }
49
+ const der = key.export({ type: 'spki', format: 'der' });
50
+ return crypto.createHash('sha256').update(der).digest('hex');
51
+ }
52
+
53
+ function payload(record) {
54
+ return {
55
+ schemaVersion: record.schemaVersion,
56
+ kind: record.kind,
57
+ algorithm: record.algorithm,
58
+ inventoryDigest: record.inventoryDigest,
59
+ runtime: record.runtime,
60
+ capturedAt: record.capturedAt,
61
+ captureMethod: record.captureMethod,
62
+ producerKeyId: record.producerKeyId,
63
+ createdAt: record.createdAt,
64
+ expiresAt: record.expiresAt
65
+ };
66
+ }
67
+
68
+ export function validateRuntimeInventoryAttestation(record) {
69
+ requireExactKeys(record, FIELDS, 'Invalid runtime inventory attestation.');
70
+ if (record.schemaVersion !== 1 || record.kind !== KIND || record.algorithm !== ALGORITHM) {
71
+ throw new Error('Unsupported runtime inventory attestation schema.');
72
+ }
73
+ requireDigest(record.inventoryDigest, 'inventoryDigest');
74
+ requireDigest(record.producerKeyId, 'producerKeyId');
75
+ if (!['claude', 'codex'].includes(record.runtime)) throw new Error('Invalid attested runtime.');
76
+ if (!['runtime-export', 'runtime-adapter'].includes(record.captureMethod)) {
77
+ throw new Error('Invalid attested capture method.');
78
+ }
79
+ requireTimestamp(record.capturedAt, 'capturedAt');
80
+ requireTimestamp(record.createdAt, 'createdAt');
81
+ requireTimestamp(record.expiresAt, 'expiresAt');
82
+ if (Date.parse(record.expiresAt) <= Date.parse(record.createdAt)) {
83
+ throw new Error('Runtime inventory attestation expiry must follow creation time.');
84
+ }
85
+ if (Date.parse(record.expiresAt) - Date.parse(record.createdAt) > MAX_TTL_SECONDS * 1000) {
86
+ throw new Error('Runtime inventory attestation lifetime exceeds 24 hours.');
87
+ }
88
+ if (Date.parse(record.capturedAt) > Date.parse(record.createdAt) + MAX_FUTURE_SKEW_SECONDS * 1000) {
89
+ throw new Error('Runtime inventory capture cannot postdate attestation creation by more than five minutes.');
90
+ }
91
+ if (typeof record.signature !== 'string' ||
92
+ !/^[A-Za-z0-9+/]{86}==$/.test(record.signature) ||
93
+ Buffer.from(record.signature, 'base64').toString('base64') !== record.signature) {
94
+ throw new Error('Invalid runtime inventory attestation signature.');
95
+ }
96
+ return record;
97
+ }
98
+
99
+ export function createRuntimeInventoryAttestation(inventoryInput, privateKeyPem, options = {}) {
100
+ const inventory = normalizeRuntimeToolInventory(inventoryInput);
101
+ const ttlSeconds = options.ttlSeconds === undefined ? 300 : options.ttlSeconds;
102
+ if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > MAX_TTL_SECONDS) {
103
+ throw new Error('Runtime inventory attestation TTL must be an integer from 1 to 86400 seconds.');
104
+ }
105
+ const createdAt = options.createdAt || new Date().toISOString();
106
+ requireTimestamp(createdAt, 'createdAt');
107
+ if (Date.parse(inventory.capturedAt) > Date.parse(createdAt) + MAX_FUTURE_SKEW_SECONDS * 1000) {
108
+ throw new Error('Runtime inventory capture cannot postdate attestation creation by more than five minutes.');
109
+ }
110
+ const expiresAt = new Date(Date.parse(createdAt) + ttlSeconds * 1000).toISOString();
111
+ const publicKey = crypto.createPublicKey(privateKeyPem);
112
+ const record = {
113
+ schemaVersion: 1,
114
+ kind: KIND,
115
+ algorithm: ALGORITHM,
116
+ inventoryDigest: runtimeToolInventoryDigest(inventory),
117
+ runtime: inventory.runtime,
118
+ capturedAt: inventory.capturedAt,
119
+ captureMethod: inventory.captureMethod,
120
+ producerKeyId: publicKeyId(publicKey),
121
+ createdAt,
122
+ expiresAt
123
+ };
124
+ const signature = crypto.sign(
125
+ null,
126
+ Buffer.from(canonicalJson(payload(record))),
127
+ privateKeyPem
128
+ );
129
+ return { ...record, signature: signature.toString('base64') };
130
+ }
131
+
132
+ function invalid(reason) {
133
+ return { valid: false, reason, producerKeyId: null, inventoryDigest: null, attestationDigest: null };
134
+ }
135
+
136
+ export function verifyRuntimeInventoryAttestation(inventoryInput, recordInput, publicKeyPem, options = {}) {
137
+ let inventory;
138
+ let record;
139
+ try {
140
+ inventory = normalizeRuntimeToolInventory(inventoryInput);
141
+ record = validateRuntimeInventoryAttestation(recordInput);
142
+ } catch (error) {
143
+ return invalid(error.message);
144
+ }
145
+
146
+ let keyId;
147
+ try { keyId = publicKeyId(publicKeyPem); } catch {
148
+ return invalid('Invalid runtime inventory attestation public key.');
149
+ }
150
+ if (record.producerKeyId !== keyId) return invalid('Runtime inventory producer key does not match.');
151
+ const inventoryDigest = runtimeToolInventoryDigest(inventory);
152
+ if (record.inventoryDigest !== inventoryDigest ||
153
+ record.runtime !== inventory.runtime ||
154
+ record.capturedAt !== inventory.capturedAt ||
155
+ record.captureMethod !== inventory.captureMethod) {
156
+ return invalid('Runtime inventory does not match its attestation.');
157
+ }
158
+
159
+ const nowMs = options.now === undefined ? Date.now() : Date.parse(options.now);
160
+ if (!Number.isFinite(nowMs)) return invalid('Invalid runtime inventory verification time.');
161
+ if (nowMs >= Date.parse(record.expiresAt)) return invalid('Runtime inventory attestation expired.');
162
+ const futureSkewSeconds = options.futureSkewSeconds === undefined
163
+ ? MAX_FUTURE_SKEW_SECONDS
164
+ : options.futureSkewSeconds;
165
+ if (!Number.isSafeInteger(futureSkewSeconds) || futureSkewSeconds < 0) {
166
+ return invalid('Invalid runtime inventory future-skew limit.');
167
+ }
168
+ if (Date.parse(record.createdAt) > nowMs + futureSkewSeconds * 1000) {
169
+ return invalid('Runtime inventory attestation creation time is too far in the future.');
170
+ }
171
+ if (Date.parse(inventory.capturedAt) > nowMs + futureSkewSeconds * 1000) {
172
+ return invalid('Runtime inventory capture time is too far in the future.');
173
+ }
174
+ if (options.maxAgeSeconds !== undefined) {
175
+ if (!Number.isSafeInteger(options.maxAgeSeconds) || options.maxAgeSeconds < 0) {
176
+ return invalid('Invalid runtime inventory maximum age.');
177
+ }
178
+ if (nowMs - Date.parse(inventory.capturedAt) > options.maxAgeSeconds * 1000) {
179
+ return invalid('Runtime inventory capture is too old.');
180
+ }
181
+ }
182
+
183
+ let valid = false;
184
+ try {
185
+ valid = crypto.verify(
186
+ null,
187
+ Buffer.from(canonicalJson(payload(record))),
188
+ publicKeyPem,
189
+ Buffer.from(record.signature, 'base64')
190
+ );
191
+ } catch {
192
+ return invalid('Runtime inventory attestation signature verification failed.');
193
+ }
194
+ if (!valid) return invalid('Runtime inventory attestation signature verification failed.');
195
+ return {
196
+ valid: true,
197
+ reason: null,
198
+ producerKeyId: keyId,
199
+ inventoryDigest,
200
+ attestationDigest: sha256Json(record)
201
+ };
202
+ }
@@ -39,7 +39,7 @@ function requireTimestamp(value, field) {
39
39
  return value;
40
40
  }
41
41
 
42
- function normalizeInventory(input) {
42
+ export function normalizeRuntimeToolInventory(input) {
43
43
  const inventory = requireExactKeys(input, new Set([
44
44
  'schemaVersion', 'kind', 'runtime', 'capturedAt', 'captureMethod', 'servers'
45
45
  ]), 'Invalid runtime tool inventory.');
@@ -71,6 +71,10 @@ function normalizeInventory(input) {
71
71
  };
72
72
  }
73
73
 
74
+ export function runtimeToolInventoryDigest(input) {
75
+ return sha256Json(normalizeRuntimeToolInventory(input));
76
+ }
77
+
74
78
  function serverStatus(binding, observed) {
75
79
  if (!binding) return 'unexpected';
76
80
  if (!observed) return 'not-observed';
@@ -95,15 +99,41 @@ function createSummary(servers) {
95
99
  };
96
100
  }
97
101
 
98
- function evidenceStatus(summary) {
102
+ function baseEvidenceStatus(summary) {
99
103
  if (summary.blockedCount || summary.unexpectedCount) return 'block';
100
104
  if (summary.reviewCount) return 'needs-review';
101
105
  if (summary.notObservedCount) return 'restricted';
102
106
  return 'matched';
103
107
  }
104
108
 
109
+ function normalizeAuthentication(input, expectedInventoryDigest) {
110
+ if (input === undefined || input === null) {
111
+ return { status: 'unverified', producerKeyId: null, attestationDigest: null };
112
+ }
113
+ requireExactKeys(input, new Set([
114
+ 'valid', 'reason', 'producerKeyId', 'inventoryDigest', 'attestationDigest'
115
+ ]), 'Invalid runtime inventory authentication.');
116
+ if (input.valid !== true || input.reason !== null) {
117
+ throw new Error('Runtime inventory authentication is not verified.');
118
+ }
119
+ if (requireDigest(input.inventoryDigest, 'inventoryDigest') !== expectedInventoryDigest) {
120
+ throw new Error('Runtime inventory authentication digest mismatch.');
121
+ }
122
+ return {
123
+ status: 'verified',
124
+ producerKeyId: requireDigest(input.producerKeyId, 'producerKeyId'),
125
+ attestationDigest: requireDigest(input.attestationDigest, 'attestationDigest')
126
+ };
127
+ }
128
+
129
+ function evidenceStatus(summary, authentication, schemaVersion = 2) {
130
+ if (summary.blockedCount || summary.unexpectedCount) return 'block';
131
+ if (schemaVersion >= 2 && authentication.status !== 'verified') return 'needs-review';
132
+ return baseEvidenceStatus(summary);
133
+ }
134
+
105
135
  function evidenceDigest(snapshot) {
106
- return sha256Json({
136
+ const projected = {
107
137
  schemaVersion: snapshot.schemaVersion,
108
138
  kind: snapshot.kind,
109
139
  runtime: snapshot.runtime,
@@ -114,7 +144,13 @@ function evidenceDigest(snapshot) {
114
144
  status: snapshot.status,
115
145
  summary: snapshot.summary,
116
146
  servers: snapshot.servers
117
- });
147
+ };
148
+ if (snapshot.schemaVersion >= 2) {
149
+ projected.inventoryAuthentication = snapshot.inventoryAuthentication;
150
+ projected.producerKeyId = snapshot.producerKeyId;
151
+ projected.attestationDigest = snapshot.attestationDigest;
152
+ }
153
+ return sha256Json(projected);
118
154
  }
119
155
 
120
156
  function inventoryFromEvidence(snapshot) {
@@ -130,9 +166,11 @@ function inventoryFromEvidence(snapshot) {
130
166
  };
131
167
  }
132
168
 
133
- export function createEffectiveRuntimeEvidence(bindingInput, inventoryInput) {
169
+ export function createEffectiveRuntimeEvidence(bindingInput, inventoryInput, authenticationInput = null) {
134
170
  const bindings = validateToolPermissionBindingSnapshot(bindingInput);
135
- const inventory = normalizeInventory(inventoryInput);
171
+ const inventory = normalizeRuntimeToolInventory(inventoryInput);
172
+ const inventoryDigest = runtimeToolInventoryDigest(inventory);
173
+ const authentication = normalizeAuthentication(authenticationInput, inventoryDigest);
136
174
  if (bindings.runtime !== inventory.runtime) {
137
175
  throw new Error('Runtime inventory does not match the binding runtime.');
138
176
  }
@@ -157,15 +195,18 @@ export function createEffectiveRuntimeEvidence(bindingInput, inventoryInput) {
157
195
  });
158
196
  const summary = createSummary(servers);
159
197
  const snapshot = {
160
- schemaVersion: 1,
198
+ schemaVersion: 2,
161
199
  kind: KIND,
162
200
  createdAt: new Date().toISOString(),
163
201
  runtime: inventory.runtime,
164
202
  capturedAt: inventory.capturedAt,
165
203
  captureMethod: inventory.captureMethod,
166
204
  bindingDigest: bindings.digest,
167
- inventoryDigest: sha256Json(inventory),
168
- status: evidenceStatus(summary),
205
+ inventoryDigest,
206
+ inventoryAuthentication: authentication.status,
207
+ producerKeyId: authentication.producerKeyId,
208
+ attestationDigest: authentication.attestationDigest,
209
+ status: evidenceStatus(summary, authentication),
169
210
  summary,
170
211
  servers
171
212
  };
@@ -207,11 +248,15 @@ function validateServer(server) {
207
248
  }
208
249
 
209
250
  function validateEvidence(snapshot) {
210
- requireExactKeys(snapshot, new Set([
251
+ const baseFields = [
211
252
  'schemaVersion', 'kind', 'createdAt', 'runtime', 'capturedAt', 'captureMethod',
212
253
  'bindingDigest', 'inventoryDigest', 'status', 'summary', 'servers', 'digest'
213
- ]), 'Invalid effective runtime evidence snapshot.');
214
- if (snapshot.schemaVersion !== 1 || snapshot.kind !== KIND) {
254
+ ];
255
+ const expectedFields = new Set(snapshot?.schemaVersion === 2
256
+ ? [...baseFields, 'inventoryAuthentication', 'producerKeyId', 'attestationDigest']
257
+ : baseFields);
258
+ requireExactKeys(snapshot, expectedFields, 'Invalid effective runtime evidence snapshot.');
259
+ if (![1, 2].includes(snapshot.schemaVersion) || snapshot.kind !== KIND) {
215
260
  throw new Error('Unsupported effective runtime evidence schema.');
216
261
  }
217
262
  if (!RUNTIMES.has(snapshot.runtime)) throw new Error('Invalid effective runtime evidence runtime.');
@@ -221,6 +266,23 @@ function validateEvidence(snapshot) {
221
266
  requireDigest(snapshot.bindingDigest, 'bindingDigest');
222
267
  requireDigest(snapshot.inventoryDigest, 'inventoryDigest');
223
268
  requireDigest(snapshot.digest, 'digest');
269
+ let authentication = { status: 'unverified', producerKeyId: null, attestationDigest: null };
270
+ if (snapshot.schemaVersion === 2) {
271
+ if (!['unverified', 'verified'].includes(snapshot.inventoryAuthentication)) {
272
+ throw new Error('Invalid runtime inventory authentication state.');
273
+ }
274
+ if (snapshot.inventoryAuthentication === 'verified') {
275
+ authentication = normalizeAuthentication({
276
+ valid: true,
277
+ reason: null,
278
+ producerKeyId: snapshot.producerKeyId,
279
+ inventoryDigest: snapshot.inventoryDigest,
280
+ attestationDigest: snapshot.attestationDigest
281
+ }, snapshot.inventoryDigest);
282
+ } else if (snapshot.producerKeyId !== null || snapshot.attestationDigest !== null) {
283
+ throw new Error('Unverified runtime inventory cannot retain attestation metadata.');
284
+ }
285
+ }
224
286
  if (!EVIDENCE_STATUSES.has(snapshot.status)) throw new Error('Invalid effective runtime evidence status.');
225
287
  if (!Array.isArray(snapshot.servers)) throw new Error('Effective runtime evidence is missing servers.');
226
288
  for (const server of snapshot.servers) validateServer(server);
@@ -239,7 +301,9 @@ function validateEvidence(snapshot) {
239
301
  if (snapshot.inventoryDigest !== sha256Json(inventoryFromEvidence(snapshot))) {
240
302
  throw new Error('Effective runtime inventory digest mismatch.');
241
303
  }
242
- if (snapshot.status !== evidenceStatus(summary)) throw new Error('Effective runtime evidence status mismatch.');
304
+ if (snapshot.status !== evidenceStatus(summary, authentication, snapshot.schemaVersion)) {
305
+ throw new Error('Effective runtime evidence status mismatch.');
306
+ }
243
307
  if (snapshot.digest !== evidenceDigest(snapshot)) throw new Error('Effective runtime evidence digest mismatch.');
244
308
  return snapshot;
245
309
  }
@@ -263,6 +327,16 @@ export function diffEffectiveRuntimeEvidence(beforeInput, afterInput) {
263
327
  if (before.captureMethod !== after.captureMethod) {
264
328
  addChange(changes, 'review', 'capture-method-changed', null, 'captureMethod', before.captureMethod, after.captureMethod);
265
329
  }
330
+ const beforeAuthentication = before.schemaVersion === 2 ? before.inventoryAuthentication : 'unverified';
331
+ const afterAuthentication = after.schemaVersion === 2 ? after.inventoryAuthentication : 'unverified';
332
+ if (beforeAuthentication !== afterAuthentication) {
333
+ addChange(changes, 'review', 'inventory-authentication-changed', null, 'inventoryAuthentication', beforeAuthentication, afterAuthentication);
334
+ }
335
+ const beforeKeyId = before.schemaVersion === 2 ? before.producerKeyId : null;
336
+ const afterKeyId = after.schemaVersion === 2 ? after.producerKeyId : null;
337
+ if (beforeKeyId !== afterKeyId) {
338
+ addChange(changes, 'review', 'producer-key-changed', null, 'producerKeyId', beforeKeyId, afterKeyId);
339
+ }
266
340
 
267
341
  const left = new Map(before.servers.map((server) => [server.name, server]));
268
342
  const right = new Map(after.servers.map((server) => [server.name, server]));
@@ -306,7 +380,7 @@ export function diffEffectiveRuntimeEvidence(beforeInput, afterInput) {
306
380
  reviews: changes.filter((change) => change.impact === 'review').length
307
381
  };
308
382
  return {
309
- schemaVersion: 1,
383
+ schemaVersion: before.schemaVersion === 2 || after.schemaVersion === 2 ? 2 : 1,
310
384
  runtime: before.runtime,
311
385
  status: summary.expansions || summary.reviews ? 'needs-review' :
312
386
  summary.restrictions ? 'restricted' : 'unchanged',