@git-stunts/git-warp 10.7.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 +214 -0
- package/bin/presenters/json.js +66 -0
- package/bin/presenters/text.js +543 -0
- package/bin/warp-graph.js +19 -2824
- package/index.d.ts +32 -2
- package/index.js +2 -0
- package/package.json +9 -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
|
@@ -184,17 +184,18 @@ function parseBody(body) {
|
|
|
184
184
|
* Initializes auth service from config if present.
|
|
185
185
|
*
|
|
186
186
|
* @param {{ keys: Record<string, string>, mode?: 'enforce'|'log-only', crypto?: *, logger?: *, wallClockMs?: () => number }|undefined} auth
|
|
187
|
+
* @param {string[]} [allowedWriters]
|
|
187
188
|
* @returns {{ auth: SyncAuthService|null, authMode: string|null }}
|
|
188
189
|
* @private
|
|
189
190
|
*/
|
|
190
|
-
function initAuth(auth) {
|
|
191
|
+
function initAuth(auth, allowedWriters) {
|
|
191
192
|
if (auth && auth.keys) {
|
|
192
193
|
const VALID_MODES = new Set(['enforce', 'log-only']);
|
|
193
194
|
const mode = auth.mode || 'enforce';
|
|
194
195
|
if (!VALID_MODES.has(mode)) {
|
|
195
196
|
throw new Error(`Invalid auth.mode: '${mode}'. Must be 'enforce' or 'log-only'.`);
|
|
196
197
|
}
|
|
197
|
-
return { auth: new SyncAuthService(auth), authMode: mode };
|
|
198
|
+
return { auth: new SyncAuthService({ ...auth, allowedWriters }), authMode: mode };
|
|
198
199
|
}
|
|
199
200
|
return { auth: null, authMode: null };
|
|
200
201
|
}
|
|
@@ -208,45 +209,58 @@ export default class HttpSyncServer {
|
|
|
208
209
|
* @param {string} [options.host='127.0.0.1'] - Host to bind
|
|
209
210
|
* @param {number} [options.maxRequestBytes=4194304] - Maximum request body size in bytes
|
|
210
211
|
* @param {{ keys: Record<string, string>, mode?: 'enforce'|'log-only', crypto?: import('../../ports/CryptoPort.js').default, logger?: import('../../ports/LoggerPort.js').default, wallClockMs?: () => number }} [options.auth] - Auth configuration
|
|
212
|
+
* @param {string[]} [options.allowedWriters] - Optional whitelist of allowed writer IDs
|
|
211
213
|
*/
|
|
212
|
-
constructor({ httpPort, graph, path = '/sync', host = '127.0.0.1', maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES, auth } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
|
|
214
|
+
constructor({ httpPort, graph, path = '/sync', host = '127.0.0.1', maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES, auth, allowedWriters } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
|
|
213
215
|
this._httpPort = httpPort;
|
|
214
216
|
this._graph = graph;
|
|
215
217
|
this._path = path && path.startsWith('/') ? path : `/${path || 'sync'}`;
|
|
216
218
|
this._host = host;
|
|
217
219
|
this._maxRequestBytes = maxRequestBytes;
|
|
218
220
|
this._server = null;
|
|
219
|
-
const authInit = initAuth(auth);
|
|
221
|
+
const authInit = initAuth(auth, allowedWriters);
|
|
220
222
|
this._auth = authInit.auth;
|
|
221
223
|
this._authMode = authInit.authMode;
|
|
224
|
+
if (allowedWriters && !authInit.auth) {
|
|
225
|
+
throw new Error('allowedWriters requires auth.keys to be configured');
|
|
226
|
+
}
|
|
222
227
|
}
|
|
223
228
|
|
|
224
229
|
/**
|
|
225
|
-
*
|
|
230
|
+
* Runs auth verification and writer whitelist checks. Returns an error
|
|
231
|
+
* response when enforcement blocks the request, or null to proceed.
|
|
226
232
|
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
* @private
|
|
230
|
-
*/
|
|
231
|
-
/**
|
|
232
|
-
* Runs auth verification if configured. Returns an error response to
|
|
233
|
-
* send, or null if the request should proceed.
|
|
233
|
+
* In log-only mode both checks record metrics/logs but always return
|
|
234
|
+
* null so the request proceeds.
|
|
234
235
|
*
|
|
235
|
-
* @param {
|
|
236
|
+
* @param {{ method: string, url: string, headers: { [x: string]: string }, body: Buffer|undefined }} request
|
|
237
|
+
* @param {*} parsed - Parsed sync request body
|
|
236
238
|
* @returns {Promise<{ status: number, headers: Object, body: string }|null>}
|
|
237
239
|
* @private
|
|
238
240
|
*/
|
|
239
|
-
async
|
|
241
|
+
async _authorize(request, parsed) {
|
|
240
242
|
if (!this._auth) {
|
|
241
243
|
return null;
|
|
242
244
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
+
|
|
246
|
+
// Signature verification (uses raw request headers + body hash)
|
|
247
|
+
const authResult = await this._auth.verify(request);
|
|
248
|
+
if (!authResult.ok) {
|
|
245
249
|
if (this._authMode === 'enforce') {
|
|
246
|
-
return errorResponse(
|
|
250
|
+
return errorResponse(authResult.status, authResult.reason);
|
|
247
251
|
}
|
|
248
252
|
this._auth.recordLogOnlyPassthrough();
|
|
249
253
|
}
|
|
254
|
+
|
|
255
|
+
// Writer whitelist (uses parsed body for writer IDs)
|
|
256
|
+
if (parsed.patches && typeof parsed.patches === 'object') {
|
|
257
|
+
const writerIds = Object.keys(parsed.patches);
|
|
258
|
+
const writerResult = this._auth.enforceWriters(writerIds);
|
|
259
|
+
if (!writerResult.ok) {
|
|
260
|
+
return errorResponse(writerResult.status, writerResult.reason);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
250
264
|
return null;
|
|
251
265
|
}
|
|
252
266
|
|
|
@@ -267,16 +281,16 @@ export default class HttpSyncServer {
|
|
|
267
281
|
return sizeError;
|
|
268
282
|
}
|
|
269
283
|
|
|
270
|
-
const authError = await this._checkAuth(request);
|
|
271
|
-
if (authError) {
|
|
272
|
-
return authError;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
284
|
const { error, parsed } = parseBody(request.body);
|
|
276
285
|
if (error) {
|
|
277
286
|
return error;
|
|
278
287
|
}
|
|
279
288
|
|
|
289
|
+
const authError = await this._authorize(request, parsed);
|
|
290
|
+
if (authError) {
|
|
291
|
+
return authError;
|
|
292
|
+
}
|
|
293
|
+
|
|
280
294
|
try {
|
|
281
295
|
const response = await this._graph.processSyncRequest(parsed);
|
|
282
296
|
return jsonResponse(response);
|
|
@@ -27,6 +27,7 @@ export const MESSAGE_TITLES = {
|
|
|
27
27
|
patch: 'warp:patch',
|
|
28
28
|
checkpoint: 'warp:checkpoint',
|
|
29
29
|
anchor: 'warp:anchor',
|
|
30
|
+
audit: 'warp:audit',
|
|
30
31
|
};
|
|
31
32
|
|
|
32
33
|
/**
|
|
@@ -44,6 +45,8 @@ export const TRAILER_KEYS = {
|
|
|
44
45
|
indexOid: 'eg-index-oid',
|
|
45
46
|
schema: 'eg-schema',
|
|
46
47
|
checkpointVersion: 'eg-checkpoint',
|
|
48
|
+
dataCommit: 'eg-data-commit',
|
|
49
|
+
opsDigest: 'eg-ops-digest',
|
|
47
50
|
};
|
|
48
51
|
|
|
49
52
|
/**
|
|
@@ -116,7 +116,7 @@ export function assertOpsCompatible(ops, maxSchema) {
|
|
|
116
116
|
* Detects the WARP message kind from a raw commit message.
|
|
117
117
|
*
|
|
118
118
|
* @param {string} message - The raw commit message
|
|
119
|
-
* @returns {'patch'|'checkpoint'|'anchor'|null} The message kind, or null if not a WARP message
|
|
119
|
+
* @returns {'patch'|'checkpoint'|'anchor'|'audit'|null} The message kind, or null if not a WARP message
|
|
120
120
|
*
|
|
121
121
|
* @example
|
|
122
122
|
* const kind = detectMessageKind(message);
|
|
@@ -134,7 +134,7 @@ export function detectMessageKind(message) {
|
|
|
134
134
|
const decoded = codec.decode(message);
|
|
135
135
|
const kind = decoded.trailers[TRAILER_KEYS.kind];
|
|
136
136
|
|
|
137
|
-
if (kind === 'patch' || kind === 'checkpoint' || kind === 'anchor') {
|
|
137
|
+
if (kind === 'patch' || kind === 'checkpoint' || kind === 'anchor' || kind === 'audit') {
|
|
138
138
|
return kind;
|
|
139
139
|
}
|
|
140
140
|
return null;
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import LRUCache from '../utils/LRUCache.js';
|
|
13
13
|
import defaultCrypto from '../utils/defaultCrypto.js';
|
|
14
14
|
import nullLogger from '../utils/nullLogger.js';
|
|
15
|
+
import { validateWriterId } from '../utils/RefLayout.js';
|
|
15
16
|
|
|
16
17
|
const SIG_VERSION = '1';
|
|
17
18
|
const SIG_PREFIX = 'warp-v1';
|
|
@@ -103,7 +104,7 @@ function fail(reason, status) {
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
/**
|
|
106
|
-
* @returns {{ authFailCount: number, replayRejectCount: number, nonceEvictions: number, clockSkewRejects: number, malformedRejects: number, logOnlyPassthroughs: number }}
|
|
107
|
+
* @returns {{ authFailCount: number, replayRejectCount: number, nonceEvictions: number, clockSkewRejects: number, malformedRejects: number, logOnlyPassthroughs: number, forbiddenWriterRejects: number }}
|
|
107
108
|
*/
|
|
108
109
|
function _freshMetrics() {
|
|
109
110
|
return {
|
|
@@ -113,6 +114,7 @@ function _freshMetrics() {
|
|
|
113
114
|
clockSkewRejects: 0,
|
|
114
115
|
malformedRejects: 0,
|
|
115
116
|
logOnlyPassthroughs: 0,
|
|
117
|
+
forbiddenWriterRejects: 0,
|
|
116
118
|
};
|
|
117
119
|
}
|
|
118
120
|
|
|
@@ -149,6 +151,23 @@ function _validateKeys(keys) {
|
|
|
149
151
|
}
|
|
150
152
|
}
|
|
151
153
|
|
|
154
|
+
/**
|
|
155
|
+
* @param {string[]|undefined} allowedWriters
|
|
156
|
+
* @returns {Set<string>|null}
|
|
157
|
+
*/
|
|
158
|
+
function _validateAllowedWriters(allowedWriters) {
|
|
159
|
+
if (!allowedWriters) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
if (allowedWriters.length === 0) {
|
|
163
|
+
throw new Error('allowedWriters must be a non-empty array when provided');
|
|
164
|
+
}
|
|
165
|
+
for (const w of allowedWriters) {
|
|
166
|
+
validateWriterId(w);
|
|
167
|
+
}
|
|
168
|
+
return new Set(allowedWriters);
|
|
169
|
+
}
|
|
170
|
+
|
|
152
171
|
export default class SyncAuthService {
|
|
153
172
|
/**
|
|
154
173
|
* @param {Object} options
|
|
@@ -159,8 +178,9 @@ export default class SyncAuthService {
|
|
|
159
178
|
* @param {import('../../ports/CryptoPort.js').default} [options.crypto] - Crypto port
|
|
160
179
|
* @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger port
|
|
161
180
|
* @param {() => number} [options.wallClockMs] - Wall clock function
|
|
181
|
+
* @param {string[]} [options.allowedWriters] - Optional whitelist of allowed writer IDs. If set, sync requests with unlisted writers are rejected with 403.
|
|
162
182
|
*/
|
|
163
|
-
constructor({ keys, mode = 'enforce', nonceCapacity, maxClockSkewMs, crypto, logger, wallClockMs } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
|
|
183
|
+
constructor({ keys, mode = 'enforce', nonceCapacity, maxClockSkewMs, crypto, logger, wallClockMs, allowedWriters } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
|
|
164
184
|
_validateKeys(keys);
|
|
165
185
|
this._keys = keys;
|
|
166
186
|
this._mode = mode;
|
|
@@ -170,6 +190,7 @@ export default class SyncAuthService {
|
|
|
170
190
|
this._maxClockSkewMs = typeof maxClockSkewMs === 'number' ? maxClockSkewMs : MAX_CLOCK_SKEW_MS;
|
|
171
191
|
this._nonceCache = new LRUCache(nonceCapacity || DEFAULT_NONCE_CAPACITY);
|
|
172
192
|
this._metrics = _freshMetrics();
|
|
193
|
+
this._allowedWriters = _validateAllowedWriters(allowedWriters);
|
|
173
194
|
}
|
|
174
195
|
|
|
175
196
|
/** @returns {'enforce'|'log-only'} */
|
|
@@ -364,6 +385,51 @@ export default class SyncAuthService {
|
|
|
364
385
|
return { ok: true };
|
|
365
386
|
}
|
|
366
387
|
|
|
388
|
+
/**
|
|
389
|
+
* Validates that all writer IDs are in the allowed set.
|
|
390
|
+
* Call after verify() succeeds.
|
|
391
|
+
*
|
|
392
|
+
* This method is a pure validator — it always returns `{ ok: false }` for
|
|
393
|
+
* forbidden writers regardless of `this._mode`. Mode enforcement (enforce
|
|
394
|
+
* vs log-only) is the caller's responsibility, matching the same pattern
|
|
395
|
+
* used by `verify()` and `HttpSyncServer._checkAuth()`.
|
|
396
|
+
*
|
|
397
|
+
* @param {string[]} writerIds - Writer IDs from the sync request
|
|
398
|
+
* @returns {{ ok: true } | { ok: false, reason: string, status: number }}
|
|
399
|
+
*/
|
|
400
|
+
verifyWriters(writerIds) {
|
|
401
|
+
if (!this._allowedWriters) {
|
|
402
|
+
return { ok: true };
|
|
403
|
+
}
|
|
404
|
+
const forbidden = writerIds.filter(id => !/** @type {Set<string>} */ (this._allowedWriters).has(id));
|
|
405
|
+
if (forbidden.length > 0) {
|
|
406
|
+
this._metrics.forbiddenWriterRejects += 1;
|
|
407
|
+
this._logger.warn('sync auth: forbidden writers rejected', { forbidden });
|
|
408
|
+
return fail('FORBIDDEN_WRITER', 403);
|
|
409
|
+
}
|
|
410
|
+
return { ok: true };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Mode-aware convenience wrapper around `verifyWriters()`.
|
|
415
|
+
*
|
|
416
|
+
* In `enforce` mode, returns the failure result from `verifyWriters()`.
|
|
417
|
+
* In `log-only` mode, records a passthrough and returns `{ ok: true }`.
|
|
418
|
+
* Callers that want simple single-call authorization can use this instead
|
|
419
|
+
* of calling `verifyWriters()` + checking mode manually.
|
|
420
|
+
*
|
|
421
|
+
* @param {string[]} writerIds - Writer IDs from the sync request
|
|
422
|
+
* @returns {{ ok: true } | { ok: false, reason: string, status: number }}
|
|
423
|
+
*/
|
|
424
|
+
enforceWriters(writerIds) {
|
|
425
|
+
const result = this.verifyWriters(writerIds);
|
|
426
|
+
if (!result.ok && this._mode !== 'enforce') {
|
|
427
|
+
this._metrics.logOnlyPassthroughs += 1;
|
|
428
|
+
return { ok: true };
|
|
429
|
+
}
|
|
430
|
+
return result;
|
|
431
|
+
}
|
|
432
|
+
|
|
367
433
|
/**
|
|
368
434
|
* Records an auth failure and returns the result.
|
|
369
435
|
* @param {string} message
|
|
@@ -388,7 +454,7 @@ export default class SyncAuthService {
|
|
|
388
454
|
/**
|
|
389
455
|
* Returns a snapshot of auth metrics.
|
|
390
456
|
*
|
|
391
|
-
* @returns {{ authFailCount: number, replayRejectCount: number, nonceEvictions: number, clockSkewRejects: number, malformedRejects: number, logOnlyPassthroughs: number }}
|
|
457
|
+
* @returns {{ authFailCount: number, replayRejectCount: number, nonceEvictions: number, clockSkewRejects: number, malformedRejects: number, logOnlyPassthroughs: number, forbiddenWriterRejects: number }}
|
|
392
458
|
*/
|
|
393
459
|
getMetrics() {
|
|
394
460
|
return { ...this._metrics };
|
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
* WARP Message Codec — facade re-exporting all message encoding, decoding,
|
|
3
3
|
* and schema utilities.
|
|
4
4
|
*
|
|
5
|
-
* This module provides backward-compatible access to the
|
|
5
|
+
* This module provides backward-compatible access to the four types of
|
|
6
6
|
* WARP (Write-Ahead Reference Protocol) commit messages:
|
|
7
7
|
* - Patch: Contains graph mutations from a single writer
|
|
8
8
|
* - Checkpoint: Contains a snapshot of materialized graph state
|
|
9
9
|
* - Anchor: Marks a merge point in the WARP DAG
|
|
10
|
+
* - Audit: Records tamper-evident audit receipts for data commits
|
|
10
11
|
*
|
|
11
12
|
* Implementation is split across focused sub-modules:
|
|
12
13
|
* - {@link module:domain/services/PatchMessageCodec}
|
|
13
14
|
* - {@link module:domain/services/CheckpointMessageCodec}
|
|
14
15
|
* - {@link module:domain/services/AnchorMessageCodec}
|
|
16
|
+
* - {@link module:domain/services/AuditMessageCodec}
|
|
15
17
|
* - {@link module:domain/services/MessageSchemaDetector}
|
|
16
18
|
*
|
|
17
19
|
* @module domain/services/WarpMessageCodec
|
|
@@ -20,6 +22,7 @@
|
|
|
20
22
|
export { encodePatchMessage, decodePatchMessage } from './PatchMessageCodec.js';
|
|
21
23
|
export { encodeCheckpointMessage, decodeCheckpointMessage } from './CheckpointMessageCodec.js';
|
|
22
24
|
export { encodeAnchorMessage, decodeAnchorMessage } from './AnchorMessageCodec.js';
|
|
25
|
+
export { encodeAuditMessage, decodeAuditMessage } from './AuditMessageCodec.js';
|
|
23
26
|
export {
|
|
24
27
|
detectSchemaVersion,
|
|
25
28
|
detectMessageKind,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHA-256 hashing layer on top of canonical.js string payloads.
|
|
3
|
+
*
|
|
4
|
+
* Computes record IDs (content-addressed hex digests) and
|
|
5
|
+
* signature payloads (raw UTF-8 bytes) for trust records.
|
|
6
|
+
*
|
|
7
|
+
* @module domain/trust/TrustCanonical
|
|
8
|
+
* @see docs/specs/TRUST_V1_CRYPTO.md
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { recordIdPayload, signaturePayload } from './canonical.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Computes the record ID (SHA-256 hex digest) for a trust record.
|
|
16
|
+
*
|
|
17
|
+
* @param {Record<string, *>} record - Full trust record
|
|
18
|
+
* @returns {string} 64-character lowercase hex string
|
|
19
|
+
*/
|
|
20
|
+
export function computeRecordId(record) {
|
|
21
|
+
return createHash('sha256').update(recordIdPayload(record)).digest('hex');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Computes the signature payload as a Buffer (UTF-8 bytes).
|
|
26
|
+
*
|
|
27
|
+
* @param {Record<string, *>} record - Full trust record (signature will be stripped)
|
|
28
|
+
* @returns {Buffer} UTF-8 encoded bytes of the domain-separated canonical string
|
|
29
|
+
*/
|
|
30
|
+
export function computeSignaturePayload(record) {
|
|
31
|
+
return Buffer.from(signaturePayload(record), 'utf8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Verifies that a record's recordId matches its content.
|
|
36
|
+
*
|
|
37
|
+
* @param {Record<string, *>} record - Trust record with `recordId` field
|
|
38
|
+
* @returns {boolean} true if recordId matches computed value
|
|
39
|
+
*/
|
|
40
|
+
export function verifyRecordId(record) {
|
|
41
|
+
return record.recordId === computeRecordId(record);
|
|
42
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ed25519 cryptographic operations for trust records.
|
|
3
|
+
*
|
|
4
|
+
* Uses `node:crypto` directly — Ed25519 is trust-specific and does not
|
|
5
|
+
* belong on the general CryptoPort hash/hmac interface.
|
|
6
|
+
*
|
|
7
|
+
* @module domain/trust/TrustCrypto
|
|
8
|
+
* @see docs/specs/TRUST_V1_CRYPTO.md
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash, createPublicKey, verify } from 'node:crypto';
|
|
12
|
+
import TrustError from '../errors/TrustError.js';
|
|
13
|
+
|
|
14
|
+
/** Algorithms supported by this module. */
|
|
15
|
+
export const SUPPORTED_ALGORITHMS = new Set(['ed25519']);
|
|
16
|
+
|
|
17
|
+
const ED25519_PUBLIC_KEY_LENGTH = 32;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Decodes a base64-encoded Ed25519 public key and validates its length.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} base64 - Base64-encoded raw public key bytes
|
|
23
|
+
* @returns {Buffer} 32-byte raw key
|
|
24
|
+
* @throws {TrustError} E_TRUST_INVALID_KEY if base64 is malformed or wrong length
|
|
25
|
+
*/
|
|
26
|
+
function decodePublicKey(base64) {
|
|
27
|
+
/** @type {Buffer} */
|
|
28
|
+
let raw;
|
|
29
|
+
try {
|
|
30
|
+
raw = Buffer.from(base64, 'base64');
|
|
31
|
+
} catch {
|
|
32
|
+
throw new TrustError('Malformed base64 in public key', {
|
|
33
|
+
code: 'E_TRUST_INVALID_KEY',
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Buffer.from with 'base64' never throws on bad input — it silently
|
|
38
|
+
// produces an empty or truncated buffer. Validate that the round-trip
|
|
39
|
+
// matches to detect garbage input.
|
|
40
|
+
if (raw.toString('base64') !== base64) {
|
|
41
|
+
throw new TrustError('Malformed base64 in public key', {
|
|
42
|
+
code: 'E_TRUST_INVALID_KEY',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (raw.length !== ED25519_PUBLIC_KEY_LENGTH) {
|
|
47
|
+
throw new TrustError(
|
|
48
|
+
`Ed25519 public key must be ${ED25519_PUBLIC_KEY_LENGTH} bytes, got ${raw.length}`,
|
|
49
|
+
{ code: 'E_TRUST_INVALID_KEY' },
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return raw;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Verifies an Ed25519 signature against a payload.
|
|
58
|
+
*
|
|
59
|
+
* @param {Object} params
|
|
60
|
+
* @param {string} params.algorithm - Must be 'ed25519'
|
|
61
|
+
* @param {string} params.publicKeyBase64 - Base64-encoded 32-byte public key
|
|
62
|
+
* @param {string} params.signatureBase64 - Base64-encoded signature
|
|
63
|
+
* @param {Buffer} params.payload - Bytes to verify
|
|
64
|
+
* @returns {boolean} true if signature is valid
|
|
65
|
+
* @throws {TrustError} E_TRUST_UNSUPPORTED_ALGORITHM for non-ed25519
|
|
66
|
+
* @throws {TrustError} E_TRUST_INVALID_KEY for malformed public key
|
|
67
|
+
*/
|
|
68
|
+
export function verifySignature({
|
|
69
|
+
algorithm,
|
|
70
|
+
publicKeyBase64,
|
|
71
|
+
signatureBase64,
|
|
72
|
+
payload,
|
|
73
|
+
}) {
|
|
74
|
+
if (!SUPPORTED_ALGORITHMS.has(algorithm)) {
|
|
75
|
+
throw new TrustError(`Unsupported algorithm: ${algorithm}`, {
|
|
76
|
+
code: 'E_TRUST_UNSUPPORTED_ALGORITHM',
|
|
77
|
+
context: { algorithm },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const raw = decodePublicKey(publicKeyBase64);
|
|
82
|
+
|
|
83
|
+
const keyObject = createPublicKey({
|
|
84
|
+
key: Buffer.concat([
|
|
85
|
+
// DER prefix for Ed25519 public key (RFC 8410)
|
|
86
|
+
Buffer.from('302a300506032b6570032100', 'hex'),
|
|
87
|
+
raw,
|
|
88
|
+
]),
|
|
89
|
+
format: 'der',
|
|
90
|
+
type: 'spki',
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const sig = Buffer.from(signatureBase64, 'base64');
|
|
94
|
+
|
|
95
|
+
return verify(null, payload, keyObject, sig);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Computes the key fingerprint for an Ed25519 public key.
|
|
100
|
+
*
|
|
101
|
+
* Format: `"ed25519:" + sha256_hex(rawBytes)`
|
|
102
|
+
*
|
|
103
|
+
* @param {string} publicKeyBase64 - Base64-encoded 32-byte public key
|
|
104
|
+
* @returns {string} Fingerprint string, e.g. "ed25519:abcd1234..."
|
|
105
|
+
* @throws {TrustError} E_TRUST_INVALID_KEY for malformed key
|
|
106
|
+
*/
|
|
107
|
+
export function computeKeyFingerprint(publicKeyBase64) {
|
|
108
|
+
const raw = decodePublicKey(publicKeyBase64);
|
|
109
|
+
const hash = createHash('sha256').update(raw).digest('hex');
|
|
110
|
+
return `ed25519:${hash}`;
|
|
111
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trust V1 evaluator.
|
|
3
|
+
*
|
|
4
|
+
* Pure function that evaluates writer trust status against a built
|
|
5
|
+
* trust state and policy configuration. No I/O, no side effects.
|
|
6
|
+
*
|
|
7
|
+
* @module domain/trust/TrustEvaluator
|
|
8
|
+
* @see docs/specs/TRUST_V1_CRYPTO.md Section 12
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { TrustPolicySchema } from './schemas.js';
|
|
12
|
+
import { TRUST_REASON_CODES } from './reasonCodes.js';
|
|
13
|
+
import { deriveTrustVerdict } from './verdict.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {import('./TrustStateBuilder.js').TrustState} TrustState
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Evaluates trust status for a set of writers against the current trust state.
|
|
21
|
+
*
|
|
22
|
+
* For each writer (sorted deterministically), checks:
|
|
23
|
+
* 1. Whether any active binding exists for that writer
|
|
24
|
+
* 2. Whether the bound key is still active (not revoked)
|
|
25
|
+
*
|
|
26
|
+
* @param {string[]} writerIds - Writer IDs to evaluate
|
|
27
|
+
* @param {TrustState} trustState - Built trust state from TrustStateBuilder
|
|
28
|
+
* @param {Record<string, *>} policy - Trust policy configuration
|
|
29
|
+
* @returns {Record<string, *>} Frozen TrustAssessment object
|
|
30
|
+
*/
|
|
31
|
+
export function evaluateWriters(writerIds, trustState, policy) {
|
|
32
|
+
const policyResult = TrustPolicySchema.safeParse(policy);
|
|
33
|
+
if (!policyResult.success) {
|
|
34
|
+
return buildErrorAssessment(writerIds, TRUST_REASON_CODES.TRUST_POLICY_INVALID);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const sortedWriters = [...writerIds].sort();
|
|
38
|
+
const explanations = sortedWriters.map((writerId) =>
|
|
39
|
+
evaluateSingleWriter(writerId, trustState),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const untrustedWriters = explanations
|
|
43
|
+
.filter((e) => !e.trusted)
|
|
44
|
+
.map((e) => e.writerId);
|
|
45
|
+
|
|
46
|
+
const trust = {
|
|
47
|
+
status: /** @type {'configured'|'pinned'|'error'|'not_configured'} */ ('configured'),
|
|
48
|
+
source: 'ref',
|
|
49
|
+
sourceDetail: null,
|
|
50
|
+
evaluatedWriters: sortedWriters,
|
|
51
|
+
untrustedWriters,
|
|
52
|
+
explanations: explanations.map((e) => Object.freeze(e)),
|
|
53
|
+
evidenceSummary: buildEvidenceSummary(trustState),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const trustVerdict = deriveTrustVerdict(trust);
|
|
57
|
+
|
|
58
|
+
return Object.freeze({
|
|
59
|
+
trustSchemaVersion: 1,
|
|
60
|
+
mode: 'signed_evidence_v1',
|
|
61
|
+
trustVerdict,
|
|
62
|
+
trust: Object.freeze(trust),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Evaluates trust for a single writer.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} writerId
|
|
70
|
+
* @param {TrustState} trustState
|
|
71
|
+
* @returns {{writerId: string, trusted: boolean, reasonCode: string, reason: string}}
|
|
72
|
+
*/
|
|
73
|
+
function evaluateSingleWriter(writerId, trustState) {
|
|
74
|
+
// Check all bindings for this writer
|
|
75
|
+
const activeBindingKeys = [];
|
|
76
|
+
for (const [bindingKey, binding] of trustState.writerBindings) {
|
|
77
|
+
if (bindingKey.startsWith(`${writerId}\0`)) {
|
|
78
|
+
activeBindingKeys.push({ bindingKey, keyId: binding.keyId });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check revoked bindings too (for reason code accuracy)
|
|
83
|
+
let hasRevokedBinding = false;
|
|
84
|
+
for (const bindingKey of trustState.revokedBindings.keys()) {
|
|
85
|
+
if (bindingKey.startsWith(`${writerId}\0`)) {
|
|
86
|
+
hasRevokedBinding = true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (activeBindingKeys.length === 0) {
|
|
91
|
+
if (hasRevokedBinding) {
|
|
92
|
+
return {
|
|
93
|
+
writerId,
|
|
94
|
+
trusted: false,
|
|
95
|
+
reasonCode: TRUST_REASON_CODES.BINDING_REVOKED,
|
|
96
|
+
reason: `Writer '${writerId}' has no active bindings (all revoked)`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
writerId,
|
|
101
|
+
trusted: false,
|
|
102
|
+
reasonCode: TRUST_REASON_CODES.WRITER_HAS_NO_ACTIVE_BINDING,
|
|
103
|
+
reason: `Writer '${writerId}' has no active bindings`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Check if any active binding points to an active key
|
|
108
|
+
for (const { keyId } of activeBindingKeys) {
|
|
109
|
+
if (trustState.activeKeys.has(keyId)) {
|
|
110
|
+
return {
|
|
111
|
+
writerId,
|
|
112
|
+
trusted: true,
|
|
113
|
+
reasonCode: TRUST_REASON_CODES.WRITER_BOUND_TO_ACTIVE_KEY,
|
|
114
|
+
reason: `Writer '${writerId}' is bound to active key ${keyId}`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// All bindings point to revoked keys
|
|
120
|
+
return {
|
|
121
|
+
writerId,
|
|
122
|
+
trusted: false,
|
|
123
|
+
reasonCode: TRUST_REASON_CODES.WRITER_BOUND_KEY_REVOKED,
|
|
124
|
+
reason: `Writer '${writerId}' is bound only to revoked keys`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Builds an error assessment when policy validation fails.
|
|
130
|
+
*
|
|
131
|
+
* @param {string[]} writerIds
|
|
132
|
+
* @param {string} reasonCode
|
|
133
|
+
* @returns {Record<string, *>}
|
|
134
|
+
*/
|
|
135
|
+
function buildErrorAssessment(writerIds, reasonCode) {
|
|
136
|
+
const sortedWriters = [...writerIds].sort();
|
|
137
|
+
const trust = {
|
|
138
|
+
status: /** @type {'configured'|'pinned'|'error'|'not_configured'} */ ('error'),
|
|
139
|
+
source: 'none',
|
|
140
|
+
sourceDetail: null,
|
|
141
|
+
evaluatedWriters: sortedWriters,
|
|
142
|
+
untrustedWriters: sortedWriters,
|
|
143
|
+
explanations: sortedWriters.map((writerId) => Object.freeze({
|
|
144
|
+
writerId,
|
|
145
|
+
trusted: false,
|
|
146
|
+
reasonCode,
|
|
147
|
+
reason: `Policy validation failed: ${reasonCode}`,
|
|
148
|
+
})),
|
|
149
|
+
evidenceSummary: {
|
|
150
|
+
recordsScanned: 0,
|
|
151
|
+
activeKeys: 0,
|
|
152
|
+
revokedKeys: 0,
|
|
153
|
+
activeBindings: 0,
|
|
154
|
+
revokedBindings: 0,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
return Object.freeze({
|
|
159
|
+
trustSchemaVersion: 1,
|
|
160
|
+
mode: 'signed_evidence_v1',
|
|
161
|
+
trustVerdict: deriveTrustVerdict(trust),
|
|
162
|
+
trust: Object.freeze(trust),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Builds the evidence summary from trust state.
|
|
168
|
+
*
|
|
169
|
+
* @param {TrustState} trustState
|
|
170
|
+
* @returns {{recordsScanned: number, activeKeys: number, revokedKeys: number, activeBindings: number, revokedBindings: number}}
|
|
171
|
+
*/
|
|
172
|
+
function buildEvidenceSummary(trustState) {
|
|
173
|
+
return Object.freeze({
|
|
174
|
+
recordsScanned: trustState.recordsProcessed,
|
|
175
|
+
activeKeys: trustState.activeKeys.size,
|
|
176
|
+
revokedKeys: trustState.revokedKeys.size,
|
|
177
|
+
activeBindings: trustState.writerBindings.size,
|
|
178
|
+
revokedBindings: trustState.revokedBindings.size,
|
|
179
|
+
});
|
|
180
|
+
}
|