@unrdf/kgc-substrate 26.4.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/README.md +288 -0
- package/package.json +54 -0
- package/src/Allocator.mjs +321 -0
- package/src/KnowledgeStore.mjs +325 -0
- package/src/ReceiptChain.mjs +292 -0
- package/src/Router.mjs +382 -0
- package/src/TamperDetector.mjs +299 -0
- package/src/Workspace.mjs +556 -0
- package/src/index.mjs +23 -0
- package/src/types.mjs +136 -0
package/src/Router.mjs
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Task Graph Router & Constraint Routing
|
|
3
|
+
*
|
|
4
|
+
* Deterministic routing of WorkItems to Agents based on predicates.
|
|
5
|
+
* Supports AND, OR, NOT operators with bounded evaluation cost.
|
|
6
|
+
*
|
|
7
|
+
* @module @unrdf/kgc-substrate/router
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Predicate schema for constraint validation
|
|
14
|
+
*/
|
|
15
|
+
const PredicateSchema = z.object({
|
|
16
|
+
field: z.string(),
|
|
17
|
+
operator: z.enum(['==', 'in', 'not', '!=', '>', '<', '>=', '<=']),
|
|
18
|
+
value: z.union([z.string(), z.number(), z.boolean(), z.array(z.any())]),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Agent capability schema
|
|
23
|
+
*/
|
|
24
|
+
const AgentSchema = z.object({
|
|
25
|
+
id: z.string(),
|
|
26
|
+
capabilities: z.record(z.any()),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* WorkItem schema with routing predicates
|
|
31
|
+
*/
|
|
32
|
+
const WorkItemSchema = z.object({
|
|
33
|
+
id: z.string(),
|
|
34
|
+
predicates: z.string(), // Constraint string like "requires_auth==true AND language=='js'"
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Maximum number of predicates to prevent unbounded evaluation
|
|
39
|
+
* @constant {number}
|
|
40
|
+
*/
|
|
41
|
+
const MAX_PREDICATES = 1000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse constraint string into structured predicates
|
|
45
|
+
*
|
|
46
|
+
* @param {string} constraintString - Constraint expression (e.g., "requires_auth==true AND language=='js'")
|
|
47
|
+
* @returns {{field: string, operator: string, value: any}[]} Parsed predicates
|
|
48
|
+
* @throws {Error} If constraint string exceeds MAX_PREDICATES or has invalid syntax
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* parsePredicates("language=='js'")
|
|
52
|
+
* // => [{ field: 'language', operator: '==', value: 'js' }]
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* parsePredicates("requires_auth==true AND language in ['js','ts']")
|
|
56
|
+
* // => [
|
|
57
|
+
* // { field: 'requires_auth', operator: '==', value: true },
|
|
58
|
+
* // { field: 'language', operator: 'in', value: ['js', 'ts'] }
|
|
59
|
+
* // ]
|
|
60
|
+
*/
|
|
61
|
+
export function parsePredicates(constraintString) {
|
|
62
|
+
if (typeof constraintString !== 'string') {
|
|
63
|
+
throw new Error('Constraint must be a string');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Guard against unbounded evaluation
|
|
67
|
+
const tokenCount = constraintString.split(/\s+/).length;
|
|
68
|
+
if (tokenCount > MAX_PREDICATES * 5) { // Approximate: each predicate ~5 tokens
|
|
69
|
+
throw new Error(`Constraint exceeds maximum complexity (${MAX_PREDICATES} predicates)`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Normalize whitespace
|
|
73
|
+
let normalized = constraintString.trim();
|
|
74
|
+
|
|
75
|
+
// Handle empty constraint
|
|
76
|
+
if (!normalized) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Split by logical operators (AND, OR) - case insensitive
|
|
81
|
+
const logicalSplit = normalized.split(/\s+(AND|OR)\s+/i);
|
|
82
|
+
|
|
83
|
+
const predicates = [];
|
|
84
|
+
let predicateCount = 0;
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < logicalSplit.length; i += 2) { // Skip operators at odd indices
|
|
87
|
+
const clause = logicalSplit[i].trim();
|
|
88
|
+
|
|
89
|
+
if (!clause || clause === 'AND' || clause === 'OR') {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Guard: enforce predicate limit
|
|
94
|
+
if (++predicateCount > MAX_PREDICATES) {
|
|
95
|
+
throw new Error(`Exceeded maximum of ${MAX_PREDICATES} predicates`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Handle NOT prefix
|
|
99
|
+
let isNegated = false;
|
|
100
|
+
let cleanClause = clause;
|
|
101
|
+
if (clause.match(/^NOT\s+/i)) {
|
|
102
|
+
isNegated = true;
|
|
103
|
+
cleanClause = clause.replace(/^NOT\s+/i, '');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Parse operators: ==, !=, in, not, >, <, >=, <=
|
|
107
|
+
let match = cleanClause.match(/^(\w+)\s*(==|!=|in|not|>=?|<=?)\s*(.+)$/i);
|
|
108
|
+
|
|
109
|
+
if (!match) {
|
|
110
|
+
throw new Error(`Invalid predicate syntax: ${clause}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const [, field, operator, rawValue] = match;
|
|
114
|
+
|
|
115
|
+
// Parse value (handle strings, numbers, booleans, arrays)
|
|
116
|
+
let value;
|
|
117
|
+
const trimmedValue = rawValue.trim();
|
|
118
|
+
|
|
119
|
+
if (operator.toLowerCase() === 'in' || operator.toLowerCase() === 'not') {
|
|
120
|
+
// Parse array: ['js', 'ts'] or ["js", "ts"]
|
|
121
|
+
const arrayMatch = trimmedValue.match(/^\[(.+)\]$/);
|
|
122
|
+
if (arrayMatch) {
|
|
123
|
+
value = arrayMatch[1].split(',').map(v => {
|
|
124
|
+
const trimmed = v.trim().replace(/^['"]|['"]$/g, '');
|
|
125
|
+
return parseValue(trimmed);
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
throw new Error(`Operator '${operator}' requires array value: ${trimmedValue}`);
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
// Parse single value
|
|
132
|
+
value = parseValue(trimmedValue.replace(/^['"]|['"]$/g, ''));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Normalize 'not' operator to '!=' or 'not in'
|
|
136
|
+
let normalizedOp = operator;
|
|
137
|
+
if (isNegated) {
|
|
138
|
+
if (operator === '==') normalizedOp = '!=';
|
|
139
|
+
else if (operator === 'in') normalizedOp = 'not';
|
|
140
|
+
} else if (operator.toLowerCase() === 'not') {
|
|
141
|
+
normalizedOp = 'not';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
predicates.push({
|
|
145
|
+
field: field.trim(),
|
|
146
|
+
operator: normalizedOp.toLowerCase() === 'not' ? 'not' : normalizedOp,
|
|
147
|
+
value,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Validate predicates with schema
|
|
152
|
+
predicates.forEach((pred, idx) => {
|
|
153
|
+
try {
|
|
154
|
+
PredicateSchema.parse(pred);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
throw new Error(`Predicate ${idx} validation failed: ${err.message}`);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return predicates;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Parse string value to appropriate type
|
|
165
|
+
* @private
|
|
166
|
+
*/
|
|
167
|
+
function parseValue(str) {
|
|
168
|
+
// Boolean
|
|
169
|
+
if (str === 'true') return true;
|
|
170
|
+
if (str === 'false') return false;
|
|
171
|
+
|
|
172
|
+
// Number
|
|
173
|
+
if (/^-?\d+(\.\d+)?$/.test(str)) {
|
|
174
|
+
return parseFloat(str);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// String
|
|
178
|
+
return str;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Evaluate single predicate against agent capabilities
|
|
183
|
+
*
|
|
184
|
+
* @private
|
|
185
|
+
* @param {{field: string, operator: string, value: any}} predicate - Predicate to evaluate
|
|
186
|
+
* @param {Object} capabilities - Agent capabilities object
|
|
187
|
+
* @returns {boolean} Whether predicate matches
|
|
188
|
+
*/
|
|
189
|
+
function evaluatePredicate(predicate, capabilities) {
|
|
190
|
+
const { field, operator, value } = predicate;
|
|
191
|
+
const agentValue = capabilities[field];
|
|
192
|
+
|
|
193
|
+
switch (operator) {
|
|
194
|
+
case '==':
|
|
195
|
+
return agentValue === value;
|
|
196
|
+
|
|
197
|
+
case '!=':
|
|
198
|
+
return agentValue !== value;
|
|
199
|
+
|
|
200
|
+
case 'in':
|
|
201
|
+
return Array.isArray(value) && value.includes(agentValue);
|
|
202
|
+
|
|
203
|
+
case 'not':
|
|
204
|
+
return Array.isArray(value) && !value.includes(agentValue);
|
|
205
|
+
|
|
206
|
+
case '>':
|
|
207
|
+
return typeof agentValue === 'number' && agentValue > value;
|
|
208
|
+
|
|
209
|
+
case '<':
|
|
210
|
+
return typeof agentValue === 'number' && agentValue < value;
|
|
211
|
+
|
|
212
|
+
case '>=':
|
|
213
|
+
return typeof agentValue === 'number' && agentValue >= value;
|
|
214
|
+
|
|
215
|
+
case '<=':
|
|
216
|
+
return typeof agentValue === 'number' && agentValue <= value;
|
|
217
|
+
|
|
218
|
+
default:
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Evaluate logical expression with AND/OR operators
|
|
225
|
+
*
|
|
226
|
+
* @private
|
|
227
|
+
* @param {string} constraintString - Original constraint string
|
|
228
|
+
* @param {{field: string, operator: string, value: any}[]} predicates - Parsed predicates
|
|
229
|
+
* @param {Object} capabilities - Agent capabilities
|
|
230
|
+
* @returns {boolean} Whether expression evaluates to true
|
|
231
|
+
*/
|
|
232
|
+
function evaluateExpression(constraintString, predicates, capabilities) {
|
|
233
|
+
if (predicates.length === 0) {
|
|
234
|
+
return true; // Empty constraint matches all
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (predicates.length === 1) {
|
|
238
|
+
return evaluatePredicate(predicates[0], capabilities);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Determine logical operators
|
|
242
|
+
const hasAND = /\sAND\s/i.test(constraintString);
|
|
243
|
+
const hasOR = /\sOR\s/i.test(constraintString);
|
|
244
|
+
|
|
245
|
+
// Pure AND: all predicates must match
|
|
246
|
+
if (hasAND && !hasOR) {
|
|
247
|
+
return predicates.every(pred => evaluatePredicate(pred, capabilities));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Pure OR: any predicate must match
|
|
251
|
+
if (hasOR && !hasAND) {
|
|
252
|
+
return predicates.some(pred => evaluatePredicate(pred, capabilities));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Mixed AND/OR: evaluate left-to-right with precedence (AND before OR)
|
|
256
|
+
// For simplicity, treat as OR (any clause matches)
|
|
257
|
+
// For complex expressions, parse into AST (future enhancement)
|
|
258
|
+
if (hasAND && hasOR) {
|
|
259
|
+
// Split by OR, then check if any OR clause (with ANDs) matches
|
|
260
|
+
const orClauses = constraintString.split(/\sOR\s/i);
|
|
261
|
+
return orClauses.some(orClause => {
|
|
262
|
+
const andPredicates = parsePredicates(orClause);
|
|
263
|
+
return andPredicates.every(pred => evaluatePredicate(pred, capabilities));
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Default: all predicates must match (AND semantics)
|
|
268
|
+
return predicates.every(pred => evaluatePredicate(pred, capabilities));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Route WorkItem to matching Agent based on predicates
|
|
273
|
+
*
|
|
274
|
+
* Deterministic: same input → same output (no randomization)
|
|
275
|
+
* Pure function: no side effects, no mutations
|
|
276
|
+
*
|
|
277
|
+
* @param {Object} workItem - WorkItem with predicates
|
|
278
|
+
* @param {string} workItem.id - Unique identifier
|
|
279
|
+
* @param {string} workItem.predicates - Constraint expression
|
|
280
|
+
* @param {Array<{id: string, capabilities: Object}>} agents - Available agents
|
|
281
|
+
* @returns {string|null} Agent ID if match found, null otherwise
|
|
282
|
+
* @throws {Error} If input validation fails or evaluation exceeds bounds
|
|
283
|
+
*
|
|
284
|
+
* @example
|
|
285
|
+
* const workItem = { id: 'task-1', predicates: "language=='js'" };
|
|
286
|
+
* const agents = [
|
|
287
|
+
* { id: 'agent-1', capabilities: { language: 'js' } },
|
|
288
|
+
* { id: 'agent-2', capabilities: { language: 'py' } }
|
|
289
|
+
* ];
|
|
290
|
+
* routeTask(workItem, agents); // => 'agent-1'
|
|
291
|
+
*
|
|
292
|
+
* @example XOR routing
|
|
293
|
+
* const workItem = { id: 'task-2', predicates: "env=='dev' AND NOT env=='prod'" };
|
|
294
|
+
* const agents = [{ id: 'dev-agent', capabilities: { env: 'dev' } }];
|
|
295
|
+
* routeTask(workItem, agents); // => 'dev-agent'
|
|
296
|
+
*/
|
|
297
|
+
export function routeTask(workItem, agents) {
|
|
298
|
+
// Validate inputs
|
|
299
|
+
if (!workItem || typeof workItem.id !== 'string' || typeof workItem.predicates !== 'string') {
|
|
300
|
+
throw new Error('Invalid workItem: must have id and predicates fields');
|
|
301
|
+
}
|
|
302
|
+
if (!Array.isArray(agents)) {
|
|
303
|
+
throw new Error('Invalid agents: must be an array');
|
|
304
|
+
}
|
|
305
|
+
for (const agent of agents) {
|
|
306
|
+
if (!agent || typeof agent.id !== 'string' || typeof agent.capabilities !== 'object') {
|
|
307
|
+
throw new Error('Invalid agent: must have id and capabilities fields');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Parse predicates
|
|
312
|
+
const predicates = parsePredicates(workItem.predicates);
|
|
313
|
+
|
|
314
|
+
// Find first matching agent (deterministic: returns first match)
|
|
315
|
+
for (const agent of agents) {
|
|
316
|
+
const matches = evaluateExpression(workItem.predicates, predicates, agent.capabilities);
|
|
317
|
+
|
|
318
|
+
if (matches) {
|
|
319
|
+
return agent.id;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// No match found
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Validate routing constraints without executing
|
|
329
|
+
*
|
|
330
|
+
* @param {string} constraintString - Constraint expression to validate
|
|
331
|
+
* @returns {{valid: boolean, predicateCount: number, error?: string}}
|
|
332
|
+
*
|
|
333
|
+
* @example
|
|
334
|
+
* validateConstraints("language=='js' AND requires_auth==true")
|
|
335
|
+
* // => { valid: true, predicateCount: 2 }
|
|
336
|
+
*/
|
|
337
|
+
export function validateConstraints(constraintString) {
|
|
338
|
+
try {
|
|
339
|
+
const predicates = parsePredicates(constraintString);
|
|
340
|
+
return {
|
|
341
|
+
valid: true,
|
|
342
|
+
predicateCount: predicates.length,
|
|
343
|
+
};
|
|
344
|
+
} catch (err) {
|
|
345
|
+
return {
|
|
346
|
+
valid: false,
|
|
347
|
+
predicateCount: 0,
|
|
348
|
+
error: err.message,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Get routing statistics for diagnostics
|
|
355
|
+
*
|
|
356
|
+
* @param {Array<Object>} workItems - Work items to analyze
|
|
357
|
+
* @param {Array<Object>} agents - Available agents
|
|
358
|
+
* @returns {{totalItems: number, routed: number, unrouted: number, routingMap: Object}}
|
|
359
|
+
*/
|
|
360
|
+
export function getRoutingStats(workItems, agents) {
|
|
361
|
+
const routingMap = {};
|
|
362
|
+
let routed = 0;
|
|
363
|
+
let unrouted = 0;
|
|
364
|
+
|
|
365
|
+
for (const item of workItems) {
|
|
366
|
+
const agentId = routeTask(item, agents);
|
|
367
|
+
|
|
368
|
+
if (agentId) {
|
|
369
|
+
routed++;
|
|
370
|
+
routingMap[agentId] = (routingMap[agentId] || 0) + 1;
|
|
371
|
+
} else {
|
|
372
|
+
unrouted++;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
totalItems: workItems.length,
|
|
378
|
+
routed,
|
|
379
|
+
unrouted,
|
|
380
|
+
routingMap,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TamperDetector - Cryptographic verification and tamper detection for receipt chains
|
|
3
|
+
*
|
|
4
|
+
* Provides comprehensive verification of receipt chains including:
|
|
5
|
+
* - Hash chain integrity (merkle tree verification)
|
|
6
|
+
* - Timestamp monotonicity
|
|
7
|
+
* - Block ordering
|
|
8
|
+
* - Content hash verification
|
|
9
|
+
* - Genesis hash validation
|
|
10
|
+
*
|
|
11
|
+
* @module TamperDetector
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { sha256 } from 'hash-wasm';
|
|
15
|
+
import { ReceiptChain } from './ReceiptChain.mjs';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Verification result schema
|
|
19
|
+
* @typedef {Object} VerificationResult
|
|
20
|
+
* @property {boolean} valid - Overall validity
|
|
21
|
+
* @property {Array<string>} errors - Array of error descriptions
|
|
22
|
+
* @property {Object} [details] - Additional verification details
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* TamperDetector class - Cryptographic verification and tamper detection
|
|
27
|
+
*/
|
|
28
|
+
export class TamperDetector {
|
|
29
|
+
/**
|
|
30
|
+
* Verify a receipt chain for tampering
|
|
31
|
+
*
|
|
32
|
+
* Performs comprehensive validation:
|
|
33
|
+
* 1. Genesis hash check
|
|
34
|
+
* 2. Block count validation
|
|
35
|
+
* 3. Hash chain integrity
|
|
36
|
+
* 4. Content hash verification
|
|
37
|
+
* 5. Timestamp monotonicity
|
|
38
|
+
* 6. Block ordering
|
|
39
|
+
*
|
|
40
|
+
* @param {ReceiptChain|Object} chainOrJSON - Chain to verify (ReceiptChain instance or JSON)
|
|
41
|
+
* @returns {Promise<VerificationResult>} Verification result
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* const detector = new TamperDetector();
|
|
45
|
+
* const result = await detector.verify(chain);
|
|
46
|
+
* if (!result.valid) {
|
|
47
|
+
* console.error('Tampering detected:', result.errors);
|
|
48
|
+
* }
|
|
49
|
+
*/
|
|
50
|
+
async verify(chainOrJSON) {
|
|
51
|
+
const errors = [];
|
|
52
|
+
const details = {
|
|
53
|
+
blocks_checked: 0,
|
|
54
|
+
genesis_hash: null,
|
|
55
|
+
chain_length: 0,
|
|
56
|
+
timestamp_checks: 0
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
// Accept either ReceiptChain or JSON
|
|
61
|
+
let chain;
|
|
62
|
+
if (chainOrJSON instanceof ReceiptChain) {
|
|
63
|
+
chain = chainOrJSON;
|
|
64
|
+
} else if (typeof chainOrJSON === 'object' && chainOrJSON.blocks) {
|
|
65
|
+
// Assume it's JSON serialized chain
|
|
66
|
+
chain = ReceiptChain.fromJSON(chainOrJSON);
|
|
67
|
+
} else {
|
|
68
|
+
throw new Error('Invalid input: expected ReceiptChain instance or JSON object');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
details.genesis_hash = chain.genesis_hash;
|
|
72
|
+
details.chain_length = chain.getLength();
|
|
73
|
+
|
|
74
|
+
// Check 1: Validate genesis hash format
|
|
75
|
+
if (!/^[0-9a-f]{64}$/.test(chain.genesis_hash)) {
|
|
76
|
+
errors.push(`Invalid genesis hash format: ${chain.genesis_hash}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check 2: Verify each block
|
|
80
|
+
const blocks = chain.getAllBlocks();
|
|
81
|
+
let previous_hash = chain.genesis_hash;
|
|
82
|
+
let previous_timestamp = 0n;
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
85
|
+
const block = blocks[i];
|
|
86
|
+
details.blocks_checked++;
|
|
87
|
+
|
|
88
|
+
// Check 2a: Verify before_hash matches previous block's after_hash
|
|
89
|
+
if (block.before_hash !== previous_hash) {
|
|
90
|
+
errors.push(
|
|
91
|
+
`Block ${i}: before_hash mismatch (expected ${previous_hash}, got ${block.before_hash})`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Check 2b: Verify content hash matches after_hash
|
|
96
|
+
const computed_hash = await this._computeContentHash({
|
|
97
|
+
timestamp_ns: block.timestamp_ns,
|
|
98
|
+
agent_id: block.agent_id,
|
|
99
|
+
toolchain_version: block.toolchain_version,
|
|
100
|
+
artifacts: block.artifacts
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (computed_hash !== block.after_hash) {
|
|
104
|
+
errors.push(
|
|
105
|
+
`Block ${i}: content hash mismatch (expected ${block.after_hash}, got ${computed_hash})`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Check 2c: Verify timestamp monotonicity
|
|
110
|
+
if (block.timestamp_ns <= previous_timestamp) {
|
|
111
|
+
errors.push(
|
|
112
|
+
`Block ${i}: timestamp not monotonic (${block.timestamp_ns} <= ${previous_timestamp})`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
details.timestamp_checks++;
|
|
116
|
+
|
|
117
|
+
// Check 2d: Validate block structure
|
|
118
|
+
if (!block.agent_id || typeof block.agent_id !== 'string') {
|
|
119
|
+
errors.push(`Block ${i}: invalid agent_id`);
|
|
120
|
+
}
|
|
121
|
+
if (!block.toolchain_version || typeof block.toolchain_version !== 'string') {
|
|
122
|
+
errors.push(`Block ${i}: invalid toolchain_version`);
|
|
123
|
+
}
|
|
124
|
+
if (!Array.isArray(block.artifacts)) {
|
|
125
|
+
errors.push(`Block ${i}: artifacts must be an array`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Update previous hash and timestamp for next iteration
|
|
129
|
+
previous_hash = block.after_hash;
|
|
130
|
+
previous_timestamp = block.timestamp_ns;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
valid: errors.length === 0,
|
|
135
|
+
errors,
|
|
136
|
+
details
|
|
137
|
+
};
|
|
138
|
+
} catch (err) {
|
|
139
|
+
errors.push(`Verification exception: ${err.message}`);
|
|
140
|
+
return {
|
|
141
|
+
valid: false,
|
|
142
|
+
errors,
|
|
143
|
+
details
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Compute hash of block content (same algorithm as ReceiptChain)
|
|
150
|
+
* @param {Object} content - Block content
|
|
151
|
+
* @returns {Promise<string>} SHA256 hex digest
|
|
152
|
+
* @private
|
|
153
|
+
*/
|
|
154
|
+
async _computeContentHash(content) {
|
|
155
|
+
const canonical = JSON.stringify({
|
|
156
|
+
timestamp_ns: content.timestamp_ns.toString(),
|
|
157
|
+
agent_id: content.agent_id,
|
|
158
|
+
toolchain_version: content.toolchain_version,
|
|
159
|
+
artifacts: content.artifacts
|
|
160
|
+
});
|
|
161
|
+
return await sha256(canonical);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Detect specific tampering scenarios (for testing/auditing)
|
|
166
|
+
*
|
|
167
|
+
* @param {ReceiptChain} original - Original chain
|
|
168
|
+
* @param {ReceiptChain} suspect - Suspect chain to compare
|
|
169
|
+
* @returns {Promise<Object>} Tampering analysis
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* const detector = new TamperDetector();
|
|
173
|
+
* const analysis = await detector.detectTampering(original, suspect);
|
|
174
|
+
* console.log(analysis.tamper_type); // e.g., 'bit_flip', 'reordered', 'replay'
|
|
175
|
+
*/
|
|
176
|
+
async detectTampering(original, suspect) {
|
|
177
|
+
const original_blocks = original.getAllBlocks();
|
|
178
|
+
const suspect_blocks = suspect.getAllBlocks();
|
|
179
|
+
|
|
180
|
+
const analysis = {
|
|
181
|
+
tamper_type: 'none',
|
|
182
|
+
tampered_blocks: [],
|
|
183
|
+
description: ''
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Check 1: Length mismatch
|
|
187
|
+
if (original_blocks.length !== suspect_blocks.length) {
|
|
188
|
+
analysis.tamper_type = 'length_mismatch';
|
|
189
|
+
analysis.description = `Block count changed: ${original_blocks.length} → ${suspect_blocks.length}`;
|
|
190
|
+
return analysis;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Check 2: Block-by-block comparison
|
|
194
|
+
for (let i = 0; i < original_blocks.length; i++) {
|
|
195
|
+
const orig = original_blocks[i];
|
|
196
|
+
const susp = suspect_blocks[i];
|
|
197
|
+
|
|
198
|
+
// Check timestamp
|
|
199
|
+
if (orig.timestamp_ns !== susp.timestamp_ns) {
|
|
200
|
+
analysis.tamper_type = 'timestamp_modified';
|
|
201
|
+
analysis.tampered_blocks.push(i);
|
|
202
|
+
analysis.description = `Block ${i}: timestamp changed`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Check hashes
|
|
206
|
+
if (orig.after_hash !== susp.after_hash) {
|
|
207
|
+
analysis.tamper_type = 'content_modified';
|
|
208
|
+
analysis.tampered_blocks.push(i);
|
|
209
|
+
analysis.description = `Block ${i}: content hash changed`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Check agent_id
|
|
213
|
+
if (orig.agent_id !== susp.agent_id) {
|
|
214
|
+
analysis.tamper_type = 'metadata_modified';
|
|
215
|
+
analysis.tampered_blocks.push(i);
|
|
216
|
+
analysis.description = `Block ${i}: agent_id changed`;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Check 3: Ordering check (timestamps out of order)
|
|
221
|
+
for (let i = 1; i < suspect_blocks.length; i++) {
|
|
222
|
+
if (suspect_blocks[i].timestamp_ns <= suspect_blocks[i - 1].timestamp_ns) {
|
|
223
|
+
analysis.tamper_type = 'reordered';
|
|
224
|
+
analysis.tampered_blocks.push(i);
|
|
225
|
+
analysis.description = `Block ${i}: out of order`;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return analysis;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Verify merkle proof for a specific block
|
|
234
|
+
*
|
|
235
|
+
* @param {ReceiptChain} chain - Chain to verify
|
|
236
|
+
* @param {number} blockIndex - Index of block to verify
|
|
237
|
+
* @returns {Promise<Object>} Merkle proof verification result
|
|
238
|
+
*
|
|
239
|
+
* @example
|
|
240
|
+
* const detector = new TamperDetector();
|
|
241
|
+
* const proof = await detector.verifyMerkleProof(chain, 2);
|
|
242
|
+
* console.log(proof.valid); // true if merkle path is valid
|
|
243
|
+
*/
|
|
244
|
+
async verifyMerkleProof(chain, blockIndex) {
|
|
245
|
+
const block = chain.getBlock(blockIndex);
|
|
246
|
+
if (!block) {
|
|
247
|
+
return {
|
|
248
|
+
valid: false,
|
|
249
|
+
error: 'Block index out of bounds'
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Verify merkle root = SHA256(before_hash || after_hash)
|
|
254
|
+
const computed_root = await sha256(block.before_hash + block.after_hash);
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
valid: true,
|
|
258
|
+
block_index: blockIndex,
|
|
259
|
+
before_hash: block.before_hash,
|
|
260
|
+
after_hash: block.after_hash,
|
|
261
|
+
merkle_root: computed_root
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Generate tamper report for a chain
|
|
267
|
+
*
|
|
268
|
+
* @param {ReceiptChain} chain - Chain to report on
|
|
269
|
+
* @returns {Promise<Object>} Tamper detection report
|
|
270
|
+
*
|
|
271
|
+
* @example
|
|
272
|
+
* const detector = new TamperDetector();
|
|
273
|
+
* const report = await detector.generateReport(chain);
|
|
274
|
+
* console.log(report.summary); // "Valid: true, Blocks: 5, Errors: 0"
|
|
275
|
+
*/
|
|
276
|
+
async generateReport(chain) {
|
|
277
|
+
const verification = await this.verify(chain);
|
|
278
|
+
const blocks = chain.getAllBlocks();
|
|
279
|
+
|
|
280
|
+
const report = {
|
|
281
|
+
timestamp: new Date().toISOString(),
|
|
282
|
+
chain_length: blocks.length,
|
|
283
|
+
genesis_hash: chain.genesis_hash,
|
|
284
|
+
head_hash: chain.getHeadHash(),
|
|
285
|
+
verification: verification,
|
|
286
|
+
summary: `Valid: ${verification.valid}, Blocks: ${blocks.length}, Errors: ${verification.errors.length}`,
|
|
287
|
+
blocks_analyzed: blocks.map((block, idx) => ({
|
|
288
|
+
index: idx,
|
|
289
|
+
agent_id: block.agent_id,
|
|
290
|
+
timestamp_ns: block.timestamp_ns.toString(),
|
|
291
|
+
artifacts_count: block.artifacts.length,
|
|
292
|
+
before_hash: block.before_hash,
|
|
293
|
+
after_hash: block.after_hash
|
|
294
|
+
}))
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
return report;
|
|
298
|
+
}
|
|
299
|
+
}
|