@git-stunts/git-warp 10.8.0 → 11.2.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.
- package/README.md +53 -32
- package/SECURITY.md +64 -0
- package/bin/cli/commands/check.js +168 -0
- package/bin/cli/commands/doctor/checks.js +422 -0
- package/bin/cli/commands/doctor/codes.js +46 -0
- package/bin/cli/commands/doctor/index.js +239 -0
- package/bin/cli/commands/doctor/types.js +89 -0
- package/bin/cli/commands/history.js +73 -0
- package/bin/cli/commands/info.js +139 -0
- package/bin/cli/commands/install-hooks.js +128 -0
- package/bin/cli/commands/materialize.js +99 -0
- package/bin/cli/commands/path.js +88 -0
- package/bin/cli/commands/query.js +194 -0
- package/bin/cli/commands/registry.js +28 -0
- package/bin/cli/commands/seek.js +592 -0
- package/bin/cli/commands/trust.js +154 -0
- package/bin/cli/commands/verify-audit.js +113 -0
- package/bin/cli/commands/view.js +45 -0
- package/bin/cli/infrastructure.js +336 -0
- package/bin/cli/schemas.js +177 -0
- package/bin/cli/shared.js +244 -0
- package/bin/cli/types.js +85 -0
- package/bin/presenters/index.js +6 -0
- package/bin/presenters/text.js +136 -0
- package/bin/warp-graph.js +5 -2346
- package/index.d.ts +32 -2
- package/index.js +2 -0
- package/package.json +8 -7
- package/src/domain/WarpGraph.js +106 -3252
- package/src/domain/errors/QueryError.js +2 -2
- package/src/domain/errors/TrustError.js +29 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AuditMessageCodec.js +137 -0
- package/src/domain/services/AuditReceiptService.js +471 -0
- package/src/domain/services/AuditVerifierService.js +693 -0
- package/src/domain/services/HttpSyncServer.js +36 -22
- package/src/domain/services/MessageCodecInternal.js +3 -0
- package/src/domain/services/MessageSchemaDetector.js +2 -2
- package/src/domain/services/SyncAuthService.js +69 -3
- package/src/domain/services/WarpMessageCodec.js +4 -1
- package/src/domain/trust/TrustCanonical.js +42 -0
- package/src/domain/trust/TrustCrypto.js +111 -0
- package/src/domain/trust/TrustEvaluator.js +180 -0
- package/src/domain/trust/TrustRecordService.js +274 -0
- package/src/domain/trust/TrustStateBuilder.js +209 -0
- package/src/domain/trust/canonical.js +68 -0
- package/src/domain/trust/reasonCodes.js +64 -0
- package/src/domain/trust/schemas.js +160 -0
- package/src/domain/trust/verdict.js +42 -0
- package/src/domain/types/git-cas.d.ts +20 -0
- package/src/domain/utils/RefLayout.js +59 -0
- package/src/domain/warp/PatchSession.js +18 -0
- package/src/domain/warp/Writer.js +18 -3
- package/src/domain/warp/_internal.js +26 -0
- package/src/domain/warp/_wire.js +58 -0
- package/src/domain/warp/_wiredMethods.d.ts +100 -0
- package/src/domain/warp/checkpoint.methods.js +397 -0
- package/src/domain/warp/fork.methods.js +323 -0
- package/src/domain/warp/materialize.methods.js +188 -0
- package/src/domain/warp/materializeAdvanced.methods.js +339 -0
- package/src/domain/warp/patch.methods.js +529 -0
- package/src/domain/warp/provenance.methods.js +284 -0
- package/src/domain/warp/query.methods.js +279 -0
- package/src/domain/warp/subscribe.methods.js +272 -0
- package/src/domain/warp/sync.methods.js +549 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +67 -1
- package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
- package/src/ports/CommitPort.js +10 -0
- package/src/ports/RefPort.js +17 -0
- package/src/hooks/post-merge.sh +0 -60
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuditVerifierService — verifies tamper-evident audit receipt chains.
|
|
3
|
+
*
|
|
4
|
+
* Walks audit chains backward from tip to genesis, validating:
|
|
5
|
+
* - Receipt schema (9 fields, correct types, version=1)
|
|
6
|
+
* - Chain linking (prevAuditCommit matches previous commit SHA)
|
|
7
|
+
* - Git parent consistency
|
|
8
|
+
* - Tick monotonicity (strictly decreasing backward)
|
|
9
|
+
* - Writer/graph consistency across the chain
|
|
10
|
+
* - OID format and length consistency
|
|
11
|
+
* - Trailer consistency (commit message trailers match CBOR receipt)
|
|
12
|
+
* - Tree structure (exactly one entry: receipt.cbor)
|
|
13
|
+
* - Genesis/continuation invariants
|
|
14
|
+
*
|
|
15
|
+
* @module domain/services/AuditVerifierService
|
|
16
|
+
* @see docs/specs/AUDIT_RECEIPT.md Section 8
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { buildAuditPrefix, buildAuditRef } from '../utils/RefLayout.js';
|
|
20
|
+
import { decodeAuditMessage } from './AuditMessageCodec.js';
|
|
21
|
+
import { TrustRecordService } from '../trust/TrustRecordService.js';
|
|
22
|
+
import { buildState } from '../trust/TrustStateBuilder.js';
|
|
23
|
+
import { evaluateWriters } from '../trust/TrustEvaluator.js';
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Constants
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
/** @type {RegExp} */
|
|
30
|
+
const OID_HEX_RE = /^[0-9a-f]+$/;
|
|
31
|
+
|
|
32
|
+
// ── Status codes ──────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/** Full chain verified from tip to genesis, no errors. */
|
|
35
|
+
const STATUS_VALID = 'VALID';
|
|
36
|
+
/** Chain verified from tip to --since boundary, no errors. */
|
|
37
|
+
const STATUS_PARTIAL = 'PARTIAL';
|
|
38
|
+
/** Structural integrity failure. */
|
|
39
|
+
const STATUS_BROKEN_CHAIN = 'BROKEN_CHAIN';
|
|
40
|
+
/** Content integrity failure (trailer vs CBOR). */
|
|
41
|
+
const STATUS_DATA_MISMATCH = 'DATA_MISMATCH';
|
|
42
|
+
/** Operational failure. */
|
|
43
|
+
const STATUS_ERROR = 'ERROR';
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// Helpers
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validates that a string is valid lowercase hex of length 40 or 64.
|
|
51
|
+
* @param {string} value
|
|
52
|
+
* @returns {{ valid: boolean, normalized: string, error?: string }}
|
|
53
|
+
*/
|
|
54
|
+
function validateOidFormat(value) {
|
|
55
|
+
if (typeof value !== 'string') {
|
|
56
|
+
return { valid: false, normalized: '', error: 'not a string' };
|
|
57
|
+
}
|
|
58
|
+
const normalized = value.toLowerCase();
|
|
59
|
+
if (!OID_HEX_RE.test(normalized)) {
|
|
60
|
+
return { valid: false, normalized, error: 'contains non-hex characters' };
|
|
61
|
+
}
|
|
62
|
+
if (normalized.length !== 40 && normalized.length !== 64) {
|
|
63
|
+
return { valid: false, normalized, error: `invalid length ${normalized.length}` };
|
|
64
|
+
}
|
|
65
|
+
return { valid: true, normalized };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Checks whether a receipt object has the expected 9 fields with correct types.
|
|
70
|
+
* @param {*} receipt
|
|
71
|
+
* @returns {string|null} Error message or null if valid
|
|
72
|
+
*/
|
|
73
|
+
function validateReceiptSchema(receipt) {
|
|
74
|
+
if (!receipt || typeof receipt !== 'object') {
|
|
75
|
+
return 'receipt is not an object';
|
|
76
|
+
}
|
|
77
|
+
const keys = Object.keys(receipt);
|
|
78
|
+
if (keys.length !== 9) {
|
|
79
|
+
return `expected 9 fields, got ${keys.length}`;
|
|
80
|
+
}
|
|
81
|
+
const required = [
|
|
82
|
+
'dataCommit', 'graphName', 'opsDigest', 'prevAuditCommit',
|
|
83
|
+
'tickEnd', 'tickStart', 'timestamp', 'version', 'writerId',
|
|
84
|
+
];
|
|
85
|
+
for (const k of required) {
|
|
86
|
+
if (!(k in receipt)) {
|
|
87
|
+
return `missing field: ${k}`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (receipt.version !== 1) {
|
|
91
|
+
return `unsupported version: ${receipt.version}`;
|
|
92
|
+
}
|
|
93
|
+
if (typeof receipt.graphName !== 'string' || receipt.graphName.length === 0) {
|
|
94
|
+
return 'graphName must be a non-empty string';
|
|
95
|
+
}
|
|
96
|
+
if (typeof receipt.writerId !== 'string' || receipt.writerId.length === 0) {
|
|
97
|
+
return 'writerId must be a non-empty string';
|
|
98
|
+
}
|
|
99
|
+
if (typeof receipt.dataCommit !== 'string') {
|
|
100
|
+
return 'dataCommit must be a string';
|
|
101
|
+
}
|
|
102
|
+
if (typeof receipt.opsDigest !== 'string') {
|
|
103
|
+
return 'opsDigest must be a string';
|
|
104
|
+
}
|
|
105
|
+
if (typeof receipt.prevAuditCommit !== 'string') {
|
|
106
|
+
return 'prevAuditCommit must be a string';
|
|
107
|
+
}
|
|
108
|
+
if (!Number.isInteger(receipt.tickStart) || receipt.tickStart < 1) {
|
|
109
|
+
return `tickStart must be integer >= 1, got ${receipt.tickStart}`;
|
|
110
|
+
}
|
|
111
|
+
if (!Number.isInteger(receipt.tickEnd) || receipt.tickEnd < receipt.tickStart) {
|
|
112
|
+
return `tickEnd must be integer >= tickStart, got ${receipt.tickEnd}`;
|
|
113
|
+
}
|
|
114
|
+
if (receipt.version === 1 && receipt.tickStart !== receipt.tickEnd) {
|
|
115
|
+
return `v1 requires tickStart === tickEnd, got ${receipt.tickStart} !== ${receipt.tickEnd}`;
|
|
116
|
+
}
|
|
117
|
+
if (!Number.isInteger(receipt.timestamp) || receipt.timestamp < 0) {
|
|
118
|
+
return `timestamp must be non-negative integer, got ${receipt.timestamp}`;
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Validates trailers against the CBOR receipt fields.
|
|
125
|
+
* @param {*} receipt
|
|
126
|
+
* @param {{ graph: string, writer: string, dataCommit: string, opsDigest: string, schema: number }} decoded
|
|
127
|
+
* @returns {string|null} Error message or null if consistent
|
|
128
|
+
*/
|
|
129
|
+
function validateTrailerConsistency(receipt, decoded) {
|
|
130
|
+
if (decoded.schema !== 1) {
|
|
131
|
+
return `trailer eg-schema must be 1, got ${decoded.schema}`;
|
|
132
|
+
}
|
|
133
|
+
if (decoded.graph !== receipt.graphName) {
|
|
134
|
+
return `trailer eg-graph '${decoded.graph}' !== receipt graphName '${receipt.graphName}'`;
|
|
135
|
+
}
|
|
136
|
+
if (decoded.writer !== receipt.writerId) {
|
|
137
|
+
return `trailer eg-writer '${decoded.writer}' !== receipt writerId '${receipt.writerId}'`;
|
|
138
|
+
}
|
|
139
|
+
if (decoded.dataCommit.toLowerCase() !== receipt.dataCommit.toLowerCase()) {
|
|
140
|
+
return `trailer eg-data-commit '${decoded.dataCommit}' !== receipt dataCommit '${receipt.dataCommit}'`;
|
|
141
|
+
}
|
|
142
|
+
if (decoded.opsDigest.toLowerCase() !== receipt.opsDigest.toLowerCase()) {
|
|
143
|
+
return `trailer eg-ops-digest '${decoded.opsDigest}' !== receipt opsDigest '${receipt.opsDigest}'`;
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ============================================================================
|
|
149
|
+
// Service
|
|
150
|
+
// ============================================================================
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @typedef {Object} ChainError
|
|
154
|
+
* @property {string} code - Machine-readable error code
|
|
155
|
+
* @property {string} message - Human-readable description
|
|
156
|
+
* @property {string} [commit] - The commit SHA where the error was found
|
|
157
|
+
*/
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @typedef {Object} ChainWarning
|
|
161
|
+
* @property {string} code - Machine-readable warning code
|
|
162
|
+
* @property {string} message - Human-readable description
|
|
163
|
+
*/
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @typedef {Object} ChainResult
|
|
167
|
+
* @property {string} writerId
|
|
168
|
+
* @property {string} ref
|
|
169
|
+
* @property {string} status - VALID | PARTIAL | BROKEN_CHAIN | DATA_MISMATCH | ERROR
|
|
170
|
+
* @property {number} receiptsVerified
|
|
171
|
+
* @property {number} receiptsScanned
|
|
172
|
+
* @property {string|null} tipCommit
|
|
173
|
+
* @property {string|null} tipAtStart
|
|
174
|
+
* @property {string|null} genesisCommit
|
|
175
|
+
* @property {string|null} stoppedAt
|
|
176
|
+
* @property {string|null} since
|
|
177
|
+
* @property {ChainError[]} errors
|
|
178
|
+
* @property {ChainWarning[]} warnings
|
|
179
|
+
*/
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @typedef {Object} TrustWarning
|
|
183
|
+
* @property {string} code
|
|
184
|
+
* @property {string} message
|
|
185
|
+
* @property {string[]} sources
|
|
186
|
+
*/
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @typedef {Object} VerifyResult
|
|
190
|
+
* @property {string} graph
|
|
191
|
+
* @property {string} verifiedAt
|
|
192
|
+
* @property {{ total: number, valid: number, partial: number, invalid: number }} summary
|
|
193
|
+
* @property {ChainResult[]} chains
|
|
194
|
+
* @property {TrustWarning|null} trustWarning
|
|
195
|
+
*/
|
|
196
|
+
|
|
197
|
+
export class AuditVerifierService {
|
|
198
|
+
/**
|
|
199
|
+
* @param {Object} options
|
|
200
|
+
* @param {import('../../ports/CommitPort.js').default & import('../../ports/RefPort.js').default & import('../../ports/BlobPort.js').default & import('../../ports/TreePort.js').default} options.persistence
|
|
201
|
+
* @param {import('../../ports/CodecPort.js').default} options.codec
|
|
202
|
+
* @param {import('../../ports/LoggerPort.js').default} [options.logger]
|
|
203
|
+
*/
|
|
204
|
+
constructor({ persistence, codec, logger }) {
|
|
205
|
+
this._persistence = persistence;
|
|
206
|
+
this._codec = codec;
|
|
207
|
+
this._logger = logger || null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Lists writer IDs from audit refs for a graph.
|
|
212
|
+
* @param {string} graphName
|
|
213
|
+
* @returns {Promise<string[]>}
|
|
214
|
+
* @private
|
|
215
|
+
*/
|
|
216
|
+
async _listWriterIds(graphName) {
|
|
217
|
+
const prefix = buildAuditPrefix(graphName);
|
|
218
|
+
const refs = await this._persistence.listRefs(prefix);
|
|
219
|
+
return refs
|
|
220
|
+
.map((/** @type {string} */ ref) => ref.slice(prefix.length))
|
|
221
|
+
.filter((/** @type {string} */ id) => id.length > 0);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Verifies all audit chains for a graph.
|
|
226
|
+
* @param {string} graphName
|
|
227
|
+
* @param {{ since?: string, trustWarning?: TrustWarning|null }} [options]
|
|
228
|
+
* @returns {Promise<VerifyResult>}
|
|
229
|
+
*/
|
|
230
|
+
async verifyAll(graphName, options = {}) {
|
|
231
|
+
const writerIds = await this._listWriterIds(graphName);
|
|
232
|
+
|
|
233
|
+
const chains = [];
|
|
234
|
+
for (const writerId of writerIds.sort()) {
|
|
235
|
+
const result = await this.verifyChain(graphName, writerId, { since: options.since });
|
|
236
|
+
chains.push(result);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const valid = chains.filter((c) => c.status === STATUS_VALID).length;
|
|
240
|
+
const partial = chains.filter((c) => c.status === STATUS_PARTIAL).length;
|
|
241
|
+
const invalid = chains.length - valid - partial;
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
graph: graphName,
|
|
245
|
+
verifiedAt: new Date().toISOString(),
|
|
246
|
+
summary: { total: chains.length, valid, partial, invalid },
|
|
247
|
+
chains,
|
|
248
|
+
trustWarning: options.trustWarning ?? null,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Verifies a single audit chain for a writer.
|
|
254
|
+
* @param {string} graphName
|
|
255
|
+
* @param {string} writerId
|
|
256
|
+
* @param {{ since?: string }} [options]
|
|
257
|
+
* @returns {Promise<ChainResult>}
|
|
258
|
+
*/
|
|
259
|
+
async verifyChain(graphName, writerId, options = {}) {
|
|
260
|
+
const ref = buildAuditRef(graphName, writerId);
|
|
261
|
+
const since = options.since || null;
|
|
262
|
+
|
|
263
|
+
/** @type {ChainResult} */
|
|
264
|
+
const result = {
|
|
265
|
+
writerId,
|
|
266
|
+
ref,
|
|
267
|
+
status: STATUS_VALID,
|
|
268
|
+
receiptsVerified: 0,
|
|
269
|
+
receiptsScanned: 0,
|
|
270
|
+
tipCommit: null,
|
|
271
|
+
tipAtStart: null,
|
|
272
|
+
genesisCommit: null,
|
|
273
|
+
stoppedAt: null,
|
|
274
|
+
since,
|
|
275
|
+
errors: [],
|
|
276
|
+
warnings: [],
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// Read tip
|
|
280
|
+
let tip;
|
|
281
|
+
try {
|
|
282
|
+
tip = await this._persistence.readRef(ref);
|
|
283
|
+
} catch {
|
|
284
|
+
// ref doesn't exist — no chain to verify
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
if (!tip) {
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
result.tipCommit = tip;
|
|
292
|
+
result.tipAtStart = tip;
|
|
293
|
+
|
|
294
|
+
// Walk the chain
|
|
295
|
+
await this._walkChain(graphName, writerId, tip, since, result);
|
|
296
|
+
|
|
297
|
+
// Ref-race detection: re-read tip after walk
|
|
298
|
+
await this._checkTipMoved(ref, result);
|
|
299
|
+
|
|
300
|
+
return result;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Walks the chain backward from tip, populating result.
|
|
305
|
+
* @param {string} graphName
|
|
306
|
+
* @param {string} writerId
|
|
307
|
+
* @param {string} tip
|
|
308
|
+
* @param {string|null} since
|
|
309
|
+
* @param {ChainResult} result
|
|
310
|
+
* @returns {Promise<void>}
|
|
311
|
+
* @private
|
|
312
|
+
*/
|
|
313
|
+
async _walkChain(graphName, writerId, tip, since, result) {
|
|
314
|
+
let current = tip;
|
|
315
|
+
/** @type {Record<string, *>|null} */ let prevReceipt = null;
|
|
316
|
+
/** @type {number|null} */ let chainOidLen = null;
|
|
317
|
+
|
|
318
|
+
while (current) {
|
|
319
|
+
result.receiptsScanned++;
|
|
320
|
+
|
|
321
|
+
// Read commit info
|
|
322
|
+
let commitInfo;
|
|
323
|
+
try {
|
|
324
|
+
commitInfo = await this._persistence.getNodeInfo(current);
|
|
325
|
+
} catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow catch type
|
|
326
|
+
this._addError(result, 'MISSING_RECEIPT_BLOB', `Cannot read commit ${current}: ${err?.message}`, current);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Read and validate receipt
|
|
331
|
+
const receiptResult = await this._readReceipt(current, commitInfo, result);
|
|
332
|
+
if (!receiptResult) {
|
|
333
|
+
return; // error already added
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const { receipt, decodedTrailers } = receiptResult;
|
|
337
|
+
|
|
338
|
+
// Schema validation (before OID checks — catches missing fields early)
|
|
339
|
+
const schemaErr = validateReceiptSchema(receipt);
|
|
340
|
+
if (schemaErr) {
|
|
341
|
+
this._addError(result, 'RECEIPT_SCHEMA_INVALID', schemaErr, current);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// OID format validation
|
|
346
|
+
if (!this._validateOids(receipt, result, current)) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// OID length consistency
|
|
351
|
+
const oidLen = receipt.dataCommit.length;
|
|
352
|
+
if (chainOidLen === null) {
|
|
353
|
+
chainOidLen = oidLen;
|
|
354
|
+
} else if (oidLen !== chainOidLen) {
|
|
355
|
+
this._addError(result, 'OID_LENGTH_MISMATCH',
|
|
356
|
+
`OID length changed from ${chainOidLen} to ${oidLen}`, current);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (receipt.prevAuditCommit.length !== oidLen) {
|
|
360
|
+
this._addError(result, 'OID_LENGTH_MISMATCH',
|
|
361
|
+
`prevAuditCommit length ${receipt.prevAuditCommit.length} !== dataCommit length ${oidLen}`, current);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Trailer consistency
|
|
366
|
+
const trailerErr = validateTrailerConsistency(receipt, decodedTrailers);
|
|
367
|
+
if (trailerErr) {
|
|
368
|
+
this._addError(result, 'TRAILER_MISMATCH', trailerErr, current);
|
|
369
|
+
result.status = STATUS_DATA_MISMATCH;
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Chain linking (against previous receipt, which is the NEXT commit in forward time)
|
|
374
|
+
if (prevReceipt) {
|
|
375
|
+
if (!this._validateChainLink(receipt, prevReceipt, current, result)) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Writer/graph consistency
|
|
381
|
+
if (receipt.writerId !== writerId) {
|
|
382
|
+
this._addError(result, 'WRITER_CONSISTENCY',
|
|
383
|
+
`receipt writerId '${receipt.writerId}' !== expected '${writerId}'`, current);
|
|
384
|
+
result.status = STATUS_BROKEN_CHAIN;
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (receipt.graphName !== graphName) {
|
|
388
|
+
this._addError(result, 'WRITER_CONSISTENCY',
|
|
389
|
+
`receipt graphName '${receipt.graphName}' !== expected '${graphName}'`, current);
|
|
390
|
+
result.status = STATUS_BROKEN_CHAIN;
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
result.receiptsVerified++;
|
|
395
|
+
|
|
396
|
+
// --since boundary: stop AFTER verifying this commit
|
|
397
|
+
if (since && current === since) {
|
|
398
|
+
result.stoppedAt = current;
|
|
399
|
+
if (result.errors.length === 0) {
|
|
400
|
+
result.status = STATUS_PARTIAL;
|
|
401
|
+
}
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Genesis check
|
|
406
|
+
const zeroHash = '0'.repeat(oidLen);
|
|
407
|
+
if (receipt.prevAuditCommit === zeroHash) {
|
|
408
|
+
result.genesisCommit = current;
|
|
409
|
+
if (commitInfo.parents.length !== 0) {
|
|
410
|
+
this._addError(result, 'GENESIS_HAS_PARENTS',
|
|
411
|
+
`Genesis commit has ${commitInfo.parents.length} parent(s)`, current);
|
|
412
|
+
result.status = STATUS_BROKEN_CHAIN;
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
// Reached genesis — if --since was specified but not found, error
|
|
416
|
+
if (since) {
|
|
417
|
+
this._addError(result, 'SINCE_NOT_FOUND',
|
|
418
|
+
`Commit ${since} not found in chain`, null);
|
|
419
|
+
result.status = STATUS_ERROR;
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (result.errors.length === 0) {
|
|
423
|
+
result.status = STATUS_VALID;
|
|
424
|
+
}
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Continuation check
|
|
429
|
+
if (commitInfo.parents.length !== 1) {
|
|
430
|
+
this._addError(result, 'CONTINUATION_NO_PARENT',
|
|
431
|
+
`Continuation commit has ${commitInfo.parents.length} parent(s), expected 1`, current);
|
|
432
|
+
result.status = STATUS_BROKEN_CHAIN;
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (commitInfo.parents[0] !== receipt.prevAuditCommit) {
|
|
436
|
+
this._addError(result, 'GIT_PARENT_MISMATCH',
|
|
437
|
+
`Git parent '${commitInfo.parents[0]}' !== prevAuditCommit '${receipt.prevAuditCommit}'`, current);
|
|
438
|
+
result.status = STATUS_BROKEN_CHAIN;
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
prevReceipt = receipt;
|
|
443
|
+
current = receipt.prevAuditCommit;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// If --since was specified but we reached the end without finding it
|
|
447
|
+
if (since) {
|
|
448
|
+
this._addError(result, 'SINCE_NOT_FOUND',
|
|
449
|
+
`Commit ${since} not found in chain`, null);
|
|
450
|
+
result.status = STATUS_ERROR;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Reads and decodes the receipt from a commit.
|
|
456
|
+
* @param {string} commitSha
|
|
457
|
+
* @param {{ message: string }} commitInfo
|
|
458
|
+
* @param {ChainResult} result
|
|
459
|
+
* @returns {Promise<{ receipt: *, decodedTrailers: * }|null>}
|
|
460
|
+
* @private
|
|
461
|
+
*/
|
|
462
|
+
async _readReceipt(commitSha, commitInfo, result) {
|
|
463
|
+
// Read tree
|
|
464
|
+
let treeOid;
|
|
465
|
+
try {
|
|
466
|
+
treeOid = await this._persistence.getCommitTree(commitSha);
|
|
467
|
+
} catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow catch type
|
|
468
|
+
this._addError(result, 'MISSING_RECEIPT_BLOB',
|
|
469
|
+
`Cannot read tree for ${commitSha}: ${err?.message}`, commitSha);
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Validate tree structure
|
|
474
|
+
let treeEntries;
|
|
475
|
+
try {
|
|
476
|
+
treeEntries = await this._persistence.readTreeOids(treeOid);
|
|
477
|
+
} catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow catch type
|
|
478
|
+
this._addError(result, 'RECEIPT_TREE_INVALID',
|
|
479
|
+
`Cannot read tree ${treeOid}: ${err?.message}`, commitSha);
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const entryNames = Object.keys(treeEntries);
|
|
484
|
+
if (entryNames.length !== 1 || entryNames[0] !== 'receipt.cbor') {
|
|
485
|
+
this._addError(result, 'RECEIPT_TREE_INVALID',
|
|
486
|
+
`Expected exactly one entry 'receipt.cbor', got [${entryNames.join(', ')}]`, commitSha);
|
|
487
|
+
result.status = STATUS_BROKEN_CHAIN;
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Read blob
|
|
492
|
+
const blobOid = treeEntries['receipt.cbor'];
|
|
493
|
+
let blobContent;
|
|
494
|
+
try {
|
|
495
|
+
blobContent = await this._persistence.readBlob(blobOid);
|
|
496
|
+
} catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow catch type
|
|
497
|
+
this._addError(result, 'MISSING_RECEIPT_BLOB',
|
|
498
|
+
`Cannot read receipt blob ${blobOid}: ${err?.message}`, commitSha);
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Decode CBOR
|
|
503
|
+
let receipt;
|
|
504
|
+
try {
|
|
505
|
+
receipt = this._codec.decode(blobContent);
|
|
506
|
+
} catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow catch type
|
|
507
|
+
this._addError(result, 'CBOR_DECODE_FAILED',
|
|
508
|
+
`CBOR decode failed: ${err?.message}`, commitSha);
|
|
509
|
+
result.status = STATUS_ERROR;
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Decode trailers
|
|
514
|
+
let decodedTrailers;
|
|
515
|
+
try {
|
|
516
|
+
decodedTrailers = decodeAuditMessage(commitInfo.message);
|
|
517
|
+
} catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow catch type
|
|
518
|
+
this._addError(result, 'TRAILER_MISMATCH',
|
|
519
|
+
`Trailer decode failed: ${err?.message}`, commitSha);
|
|
520
|
+
result.status = STATUS_DATA_MISMATCH;
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return { receipt, decodedTrailers };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Validates OID format for dataCommit, prevAuditCommit, and opsDigest.
|
|
529
|
+
* @param {*} receipt
|
|
530
|
+
* @param {ChainResult} result
|
|
531
|
+
* @param {string} commitSha
|
|
532
|
+
* @returns {boolean} true if valid
|
|
533
|
+
* @private
|
|
534
|
+
*/
|
|
535
|
+
_validateOids(receipt, result, commitSha) {
|
|
536
|
+
const dcCheck = validateOidFormat(receipt.dataCommit);
|
|
537
|
+
if (!dcCheck.valid) {
|
|
538
|
+
this._addError(result, 'OID_FORMAT_INVALID',
|
|
539
|
+
`dataCommit OID invalid: ${dcCheck.error}`, commitSha);
|
|
540
|
+
result.status = STATUS_BROKEN_CHAIN;
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const pacCheck = validateOidFormat(receipt.prevAuditCommit);
|
|
545
|
+
// prevAuditCommit may be all-zeros (genesis sentinel)
|
|
546
|
+
const isZero = /^0+$/.test(receipt.prevAuditCommit);
|
|
547
|
+
if (!pacCheck.valid && !isZero) {
|
|
548
|
+
this._addError(result, 'OID_FORMAT_INVALID',
|
|
549
|
+
`prevAuditCommit OID invalid: ${pacCheck.error}`, commitSha);
|
|
550
|
+
result.status = STATUS_BROKEN_CHAIN;
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Validates chain linking between current and previous (newer) receipt.
|
|
559
|
+
* @param {*} currentReceipt - The older receipt being validated
|
|
560
|
+
* @param {*} prevReceipt - The newer receipt (closer to tip)
|
|
561
|
+
* @param {string} commitSha
|
|
562
|
+
* @param {ChainResult} result
|
|
563
|
+
* @returns {boolean} true if valid
|
|
564
|
+
* @private
|
|
565
|
+
*/
|
|
566
|
+
_validateChainLink(currentReceipt, prevReceipt, commitSha, result) {
|
|
567
|
+
// Tick monotonicity: walking backward, current tick < prev tick
|
|
568
|
+
if (currentReceipt.tickEnd >= prevReceipt.tickStart) {
|
|
569
|
+
this._addError(result, 'TICK_MONOTONICITY',
|
|
570
|
+
`tick ${currentReceipt.tickEnd} >= previous ${prevReceipt.tickStart}`, commitSha);
|
|
571
|
+
result.status = STATUS_BROKEN_CHAIN;
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Tick gap warning
|
|
576
|
+
if (currentReceipt.tickEnd + 1 < prevReceipt.tickStart) {
|
|
577
|
+
result.warnings.push({
|
|
578
|
+
code: 'TICK_GAP',
|
|
579
|
+
message: `Gap between tick ${currentReceipt.tickEnd} and ${prevReceipt.tickStart}`,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Writer consistency
|
|
584
|
+
if (currentReceipt.writerId !== prevReceipt.writerId) {
|
|
585
|
+
this._addError(result, 'WRITER_CONSISTENCY',
|
|
586
|
+
`writerId changed from '${currentReceipt.writerId}' to '${prevReceipt.writerId}'`, commitSha);
|
|
587
|
+
result.status = STATUS_BROKEN_CHAIN;
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Graph consistency
|
|
592
|
+
if (currentReceipt.graphName !== prevReceipt.graphName) {
|
|
593
|
+
this._addError(result, 'WRITER_CONSISTENCY',
|
|
594
|
+
`graphName changed from '${currentReceipt.graphName}' to '${prevReceipt.graphName}'`, commitSha);
|
|
595
|
+
result.status = STATUS_BROKEN_CHAIN;
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Checks if the ref tip moved during verification (ref-race detection).
|
|
604
|
+
* @param {string} ref
|
|
605
|
+
* @param {ChainResult} result
|
|
606
|
+
* @returns {Promise<void>}
|
|
607
|
+
* @private
|
|
608
|
+
*/
|
|
609
|
+
async _checkTipMoved(ref, result) {
|
|
610
|
+
try {
|
|
611
|
+
const currentTip = await this._persistence.readRef(ref);
|
|
612
|
+
if (currentTip && currentTip !== result.tipAtStart) {
|
|
613
|
+
result.warnings.push({
|
|
614
|
+
code: 'TIP_MOVED_DURING_VERIFY',
|
|
615
|
+
message: `Ref tip moved from ${result.tipAtStart} to ${currentTip} during verification`,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
} catch {
|
|
619
|
+
// If we can't re-read, don't add a warning — it's best-effort
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Adds an error to the result and sets status if not already set.
|
|
625
|
+
* @param {ChainResult} result
|
|
626
|
+
* @param {string} code
|
|
627
|
+
* @param {string} message
|
|
628
|
+
* @param {string|null} commit
|
|
629
|
+
* @private
|
|
630
|
+
*/
|
|
631
|
+
_addError(result, code, message, commit) {
|
|
632
|
+
result.errors.push({ code, message, ...(commit ? { commit } : {}) });
|
|
633
|
+
if (result.status === STATUS_VALID || result.status === STATUS_PARTIAL) {
|
|
634
|
+
result.status = STATUS_ERROR;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Evaluates trust for all writers of a graph using signed evidence.
|
|
640
|
+
*
|
|
641
|
+
* Reads the trust record chain, builds state, discovers writers,
|
|
642
|
+
* and returns a TrustAssessment.
|
|
643
|
+
*
|
|
644
|
+
* @param {string} graphName
|
|
645
|
+
* @param {Object} [options]
|
|
646
|
+
* @param {string} [options.pin] - Pinned trust chain commit SHA
|
|
647
|
+
* @param {string} [options.mode] - Policy mode ('warn' or 'enforce')
|
|
648
|
+
* @returns {Promise<Record<string, *>>}
|
|
649
|
+
*/
|
|
650
|
+
async evaluateTrust(graphName, options = {}) {
|
|
651
|
+
const recordService = new TrustRecordService({
|
|
652
|
+
persistence: this._persistence,
|
|
653
|
+
codec: this._codec,
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
const records = await recordService.readRecords(graphName, options.pin ? { tip: options.pin } : {});
|
|
657
|
+
|
|
658
|
+
if (records.length === 0) {
|
|
659
|
+
return {
|
|
660
|
+
trustSchemaVersion: 1,
|
|
661
|
+
mode: 'signed_evidence_v1',
|
|
662
|
+
trustVerdict: 'not_configured',
|
|
663
|
+
trust: {
|
|
664
|
+
status: 'not_configured',
|
|
665
|
+
source: 'none',
|
|
666
|
+
sourceDetail: null,
|
|
667
|
+
evaluatedWriters: [],
|
|
668
|
+
untrustedWriters: [],
|
|
669
|
+
explanations: [],
|
|
670
|
+
evidenceSummary: {
|
|
671
|
+
recordsScanned: 0,
|
|
672
|
+
activeKeys: 0,
|
|
673
|
+
revokedKeys: 0,
|
|
674
|
+
activeBindings: 0,
|
|
675
|
+
revokedBindings: 0,
|
|
676
|
+
},
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const trustState = buildState(records);
|
|
682
|
+
const writerIds = await this._listWriterIds(graphName);
|
|
683
|
+
|
|
684
|
+
const policy = {
|
|
685
|
+
schemaVersion: 1,
|
|
686
|
+
mode: options.mode ?? 'warn',
|
|
687
|
+
writerPolicy: 'all_writers_must_be_trusted',
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
return evaluateWriters(writerIds, trustState, policy);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|