@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.
Files changed (71) hide show
  1. package/README.md +53 -32
  2. package/SECURITY.md +64 -0
  3. package/bin/cli/commands/check.js +168 -0
  4. package/bin/cli/commands/doctor/checks.js +422 -0
  5. package/bin/cli/commands/doctor/codes.js +46 -0
  6. package/bin/cli/commands/doctor/index.js +239 -0
  7. package/bin/cli/commands/doctor/types.js +89 -0
  8. package/bin/cli/commands/history.js +73 -0
  9. package/bin/cli/commands/info.js +139 -0
  10. package/bin/cli/commands/install-hooks.js +128 -0
  11. package/bin/cli/commands/materialize.js +99 -0
  12. package/bin/cli/commands/path.js +88 -0
  13. package/bin/cli/commands/query.js +194 -0
  14. package/bin/cli/commands/registry.js +28 -0
  15. package/bin/cli/commands/seek.js +592 -0
  16. package/bin/cli/commands/trust.js +154 -0
  17. package/bin/cli/commands/verify-audit.js +113 -0
  18. package/bin/cli/commands/view.js +45 -0
  19. package/bin/cli/infrastructure.js +336 -0
  20. package/bin/cli/schemas.js +177 -0
  21. package/bin/cli/shared.js +244 -0
  22. package/bin/cli/types.js +85 -0
  23. package/bin/presenters/index.js +214 -0
  24. package/bin/presenters/json.js +66 -0
  25. package/bin/presenters/text.js +543 -0
  26. package/bin/warp-graph.js +19 -2824
  27. package/index.d.ts +32 -2
  28. package/index.js +2 -0
  29. package/package.json +9 -7
  30. package/src/domain/WarpGraph.js +106 -3252
  31. package/src/domain/errors/QueryError.js +2 -2
  32. package/src/domain/errors/TrustError.js +29 -0
  33. package/src/domain/errors/index.js +1 -0
  34. package/src/domain/services/AuditMessageCodec.js +137 -0
  35. package/src/domain/services/AuditReceiptService.js +471 -0
  36. package/src/domain/services/AuditVerifierService.js +693 -0
  37. package/src/domain/services/HttpSyncServer.js +36 -22
  38. package/src/domain/services/MessageCodecInternal.js +3 -0
  39. package/src/domain/services/MessageSchemaDetector.js +2 -2
  40. package/src/domain/services/SyncAuthService.js +69 -3
  41. package/src/domain/services/WarpMessageCodec.js +4 -1
  42. package/src/domain/trust/TrustCanonical.js +42 -0
  43. package/src/domain/trust/TrustCrypto.js +111 -0
  44. package/src/domain/trust/TrustEvaluator.js +180 -0
  45. package/src/domain/trust/TrustRecordService.js +274 -0
  46. package/src/domain/trust/TrustStateBuilder.js +209 -0
  47. package/src/domain/trust/canonical.js +68 -0
  48. package/src/domain/trust/reasonCodes.js +64 -0
  49. package/src/domain/trust/schemas.js +160 -0
  50. package/src/domain/trust/verdict.js +42 -0
  51. package/src/domain/types/git-cas.d.ts +20 -0
  52. package/src/domain/utils/RefLayout.js +59 -0
  53. package/src/domain/warp/PatchSession.js +18 -0
  54. package/src/domain/warp/Writer.js +18 -3
  55. package/src/domain/warp/_internal.js +26 -0
  56. package/src/domain/warp/_wire.js +58 -0
  57. package/src/domain/warp/_wiredMethods.d.ts +100 -0
  58. package/src/domain/warp/checkpoint.methods.js +397 -0
  59. package/src/domain/warp/fork.methods.js +323 -0
  60. package/src/domain/warp/materialize.methods.js +188 -0
  61. package/src/domain/warp/materializeAdvanced.methods.js +339 -0
  62. package/src/domain/warp/patch.methods.js +529 -0
  63. package/src/domain/warp/provenance.methods.js +284 -0
  64. package/src/domain/warp/query.methods.js +279 -0
  65. package/src/domain/warp/subscribe.methods.js +272 -0
  66. package/src/domain/warp/sync.methods.js +549 -0
  67. package/src/infrastructure/adapters/GitGraphAdapter.js +67 -1
  68. package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
  69. package/src/ports/CommitPort.js +10 -0
  70. package/src/ports/RefPort.js +17 -0
  71. 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
- * Handles an incoming HTTP request through the port abstraction.
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
- * @param {{ method: string, url: string, headers: { [x: string]: string }, body: Buffer|undefined }} request
228
- * @returns {Promise<{ status: number, headers: Object, body: string }>}
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 {*} request
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 _checkAuth(request) {
241
+ async _authorize(request, parsed) {
240
242
  if (!this._auth) {
241
243
  return null;
242
244
  }
243
- const result = await this._auth.verify(request);
244
- if (!result.ok) {
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(result.status, result.reason);
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 three types of
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
+ }