@git-stunts/git-warp 10.4.2 → 10.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,396 @@
1
+ /**
2
+ * HMAC-SHA256 request signing and verification for the sync protocol.
3
+ *
4
+ * Provides:
5
+ * - Canonical payload construction
6
+ * - Request signing (client side)
7
+ * - Request verification with replay protection (server side)
8
+ *
9
+ * @module domain/services/SyncAuthService
10
+ */
11
+
12
+ import LRUCache from '../utils/LRUCache.js';
13
+ import defaultCrypto from '../utils/defaultCrypto.js';
14
+ import nullLogger from '../utils/nullLogger.js';
15
+
16
+ const SIG_VERSION = '1';
17
+ const SIG_PREFIX = 'warp-v1';
18
+ const HMAC_ALGO = 'sha256';
19
+ const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;
20
+ const DEFAULT_NONCE_CAPACITY = 100_000;
21
+ const NONCE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
22
+ const SIG_HEX_LENGTH = 64;
23
+ const HEX_PATTERN = /^[0-9a-f]+$/;
24
+ const MAX_TIMESTAMP_DIGITS = 16;
25
+
26
+ /**
27
+ * Canonicalizes a URL path for signature computation.
28
+ *
29
+ * @param {string} url - URL or path to canonicalize
30
+ * @returns {string} Canonical path (pathname + search, no fragment)
31
+ */
32
+ export function canonicalizePath(url) {
33
+ const parsed = new URL(url, 'http://localhost');
34
+ return parsed.pathname + (parsed.search || '');
35
+ }
36
+
37
+ /**
38
+ * Builds the canonical string that gets signed.
39
+ *
40
+ * @param {Object} params
41
+ * @param {string} params.keyId - Key identifier
42
+ * @param {string} params.method - HTTP method (uppercased by caller)
43
+ * @param {string} params.path - Canonical path
44
+ * @param {string} params.timestamp - Epoch milliseconds as string
45
+ * @param {string} params.nonce - UUIDv4 nonce
46
+ * @param {string} params.contentType - Content-Type header value
47
+ * @param {string} params.bodySha256 - Hex SHA-256 of request body
48
+ * @returns {string} Pipe-delimited canonical payload
49
+ */
50
+ export function buildCanonicalPayload({ keyId, method, path, timestamp, nonce, contentType, bodySha256 }) {
51
+ return `${SIG_PREFIX}|${keyId}|${method}|${path}|${timestamp}|${nonce}|${contentType}|${bodySha256}`;
52
+ }
53
+
54
+ /**
55
+ * Signs an outgoing sync request.
56
+ *
57
+ * @param {Object} params
58
+ * @param {string} params.method - HTTP method
59
+ * @param {string} params.path - Canonical path
60
+ * @param {string} params.contentType - Content-Type header value
61
+ * @param {Buffer|Uint8Array} params.body - Raw request body
62
+ * @param {string} params.secret - Shared secret
63
+ * @param {string} params.keyId - Key identifier
64
+ * @param {Object} deps
65
+ * @param {import('../../ports/CryptoPort.js').default} [deps.crypto] - Crypto port
66
+ * @returns {Promise<Record<string, string>>} Auth headers
67
+ */
68
+ export async function signSyncRequest({ method, path, contentType, body, secret, keyId }, { crypto } = {}) {
69
+ const c = crypto || defaultCrypto;
70
+ const timestamp = String(Date.now());
71
+ const nonce = globalThis.crypto.randomUUID();
72
+
73
+ const bodySha256 = await c.hash('sha256', body);
74
+ const canonical = buildCanonicalPayload({
75
+ keyId,
76
+ method: method.toUpperCase(),
77
+ path,
78
+ timestamp,
79
+ nonce,
80
+ contentType,
81
+ bodySha256,
82
+ });
83
+
84
+ const hmacBuf = await c.hmac(HMAC_ALGO, secret, canonical);
85
+ const signature = Buffer.from(hmacBuf).toString('hex');
86
+
87
+ return {
88
+ 'x-warp-sig-version': SIG_VERSION,
89
+ 'x-warp-key-id': keyId,
90
+ 'x-warp-timestamp': timestamp,
91
+ 'x-warp-nonce': nonce,
92
+ 'x-warp-signature': signature,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * @param {string} reason
98
+ * @param {number} status
99
+ * @returns {{ ok: false, reason: string, status: number }}
100
+ */
101
+ function fail(reason, status) {
102
+ return { ok: false, reason, status };
103
+ }
104
+
105
+ /**
106
+ * @returns {{ authFailCount: number, replayRejectCount: number, nonceEvictions: number, clockSkewRejects: number, malformedRejects: number, logOnlyPassthroughs: number }}
107
+ */
108
+ function _freshMetrics() {
109
+ return {
110
+ authFailCount: 0,
111
+ replayRejectCount: 0,
112
+ nonceEvictions: 0,
113
+ clockSkewRejects: 0,
114
+ malformedRejects: 0,
115
+ logOnlyPassthroughs: 0,
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Validates format of individual auth header values.
121
+ *
122
+ * @param {string} timestamp
123
+ * @param {string} nonce
124
+ * @param {string} signature
125
+ * @returns {{ ok: false, reason: string, status: number } | { ok: true }}
126
+ */
127
+ function _checkHeaderFormats(timestamp, nonce, signature) {
128
+ if (!/^\d+$/.test(timestamp) || timestamp.length > MAX_TIMESTAMP_DIGITS) {
129
+ return fail('MALFORMED_TIMESTAMP', 400);
130
+ }
131
+
132
+ if (!NONCE_PATTERN.test(nonce)) {
133
+ return fail('MALFORMED_NONCE', 400);
134
+ }
135
+
136
+ if (signature.length !== SIG_HEX_LENGTH || !HEX_PATTERN.test(signature)) {
137
+ return fail('MALFORMED_SIGNATURE', 400);
138
+ }
139
+
140
+ return { ok: true };
141
+ }
142
+
143
+ /**
144
+ * @param {Record<string, string>|undefined} keys
145
+ */
146
+ function _validateKeys(keys) {
147
+ if (!keys || typeof keys !== 'object' || Object.keys(keys).length === 0) {
148
+ throw new Error('SyncAuthService requires a non-empty keys map');
149
+ }
150
+ }
151
+
152
+ export default class SyncAuthService {
153
+ /**
154
+ * @param {Object} options
155
+ * @param {Record<string, string>} options.keys - Key-id to secret mapping
156
+ * @param {'enforce'|'log-only'} [options.mode='enforce'] - Auth enforcement mode
157
+ * @param {number} [options.nonceCapacity] - Nonce LRU capacity
158
+ * @param {number} [options.maxClockSkewMs] - Max clock skew tolerance
159
+ * @param {import('../../ports/CryptoPort.js').default} [options.crypto] - Crypto port
160
+ * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger port
161
+ * @param {() => number} [options.wallClockMs] - Wall clock function
162
+ */
163
+ constructor({ keys, mode = 'enforce', nonceCapacity, maxClockSkewMs, crypto, logger, wallClockMs } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
164
+ _validateKeys(keys);
165
+ this._keys = keys;
166
+ this._mode = mode;
167
+ this._crypto = crypto || defaultCrypto;
168
+ this._logger = logger || nullLogger;
169
+ this._wallClockMs = wallClockMs || (() => Date.now());
170
+ this._maxClockSkewMs = typeof maxClockSkewMs === 'number' ? maxClockSkewMs : MAX_CLOCK_SKEW_MS;
171
+ this._nonceCache = new LRUCache(nonceCapacity || DEFAULT_NONCE_CAPACITY);
172
+ this._metrics = _freshMetrics();
173
+ }
174
+
175
+ /** @returns {'enforce'|'log-only'} */
176
+ get mode() {
177
+ return this._mode;
178
+ }
179
+
180
+ /**
181
+ * Validates auth header presence and format.
182
+ *
183
+ * @param {Record<string, string>} headers
184
+ * @returns {{ ok: false, reason: string, status: number } | { ok: true, sigVersion: string, signature: string, timestamp: string, nonce: string, keyId: string }}
185
+ * @private
186
+ */
187
+ _validateHeaders(headers) {
188
+ const sigVersion = headers['x-warp-sig-version'];
189
+ if (sigVersion !== SIG_VERSION) {
190
+ return fail('INVALID_VERSION', 400);
191
+ }
192
+
193
+ const keyId = headers['x-warp-key-id'];
194
+ const signature = headers['x-warp-signature'];
195
+ const timestamp = headers['x-warp-timestamp'];
196
+ const nonce = headers['x-warp-nonce'];
197
+
198
+ if (!keyId || !signature || !timestamp || !nonce) {
199
+ return fail('MISSING_AUTH', 401);
200
+ }
201
+
202
+ const formatCheck = _checkHeaderFormats(timestamp, nonce, signature);
203
+ if (!formatCheck.ok) {
204
+ return formatCheck;
205
+ }
206
+
207
+ return { ok: true, sigVersion, signature, timestamp, nonce, keyId };
208
+ }
209
+
210
+ /**
211
+ * Checks that the timestamp is within the allowed clock skew.
212
+ *
213
+ * @param {string} timestamp - Epoch ms as string
214
+ * @returns {{ ok: false, reason: string, status: number } | { ok: true }}
215
+ * @private
216
+ */
217
+ _validateFreshness(timestamp) {
218
+ const ts = Number(timestamp);
219
+ const now = this._wallClockMs();
220
+ if (Math.abs(now - ts) > this._maxClockSkewMs) {
221
+ this._metrics.clockSkewRejects += 1;
222
+ return fail('EXPIRED', 403);
223
+ }
224
+ return { ok: true };
225
+ }
226
+
227
+ /**
228
+ * Atomically reserves a nonce. Returns replay failure if already seen.
229
+ *
230
+ * @param {string} nonce
231
+ * @returns {{ ok: false, reason: string, status: number } | { ok: true }}
232
+ * @private
233
+ */
234
+ _reserveNonce(nonce) {
235
+ if (this._nonceCache.has(nonce)) {
236
+ this._metrics.replayRejectCount += 1;
237
+ return fail('REPLAY', 403);
238
+ }
239
+
240
+ const sizeBefore = this._nonceCache.size;
241
+ this._nonceCache.set(nonce, true);
242
+ if (this._nonceCache.size <= sizeBefore && sizeBefore >= this._nonceCache.maxSize) {
243
+ this._metrics.nonceEvictions += 1;
244
+ }
245
+
246
+ return { ok: true };
247
+ }
248
+
249
+ /**
250
+ * Resolves the shared secret for a key-id.
251
+ *
252
+ * @param {string} keyId
253
+ * @returns {{ ok: false, reason: string, status: number } | { ok: true, secret: string }}
254
+ * @private
255
+ */
256
+ _resolveKey(keyId) {
257
+ const secret = this._keys[keyId];
258
+ if (!secret) {
259
+ return fail('UNKNOWN_KEY_ID', 401);
260
+ }
261
+ return { ok: true, secret };
262
+ }
263
+
264
+ /**
265
+ * Verifies the HMAC signature against the canonical payload.
266
+ *
267
+ * @param {Object} params
268
+ * @param {{ method: string, url: string, headers: Record<string, string>, body?: Buffer|Uint8Array }} params.request
269
+ * @param {string} params.secret
270
+ * @param {string} params.keyId
271
+ * @param {string} params.timestamp
272
+ * @param {string} params.nonce
273
+ * @returns {Promise<{ ok: false, reason: string, status: number } | { ok: true }>}
274
+ * @private
275
+ */
276
+ async _verifySignature({ request, secret, keyId, timestamp, nonce }) {
277
+ const body = request.body || new Uint8Array(0);
278
+ const bodySha256 = await this._crypto.hash('sha256', body);
279
+ const contentType = request.headers['content-type'] || '';
280
+ const path = canonicalizePath(request.url || '/');
281
+
282
+ const canonical = buildCanonicalPayload({
283
+ keyId,
284
+ method: (request.method || 'POST').toUpperCase(),
285
+ path,
286
+ timestamp,
287
+ nonce,
288
+ contentType,
289
+ bodySha256,
290
+ });
291
+
292
+ const expectedBuf = await this._crypto.hmac(HMAC_ALGO, secret, canonical);
293
+ const receivedHex = request.headers['x-warp-signature'];
294
+
295
+ let receivedBuf;
296
+ try {
297
+ receivedBuf = Buffer.from(receivedHex, 'hex');
298
+ } catch {
299
+ return fail('INVALID_SIGNATURE', 401);
300
+ }
301
+
302
+ if (receivedBuf.length !== expectedBuf.length) {
303
+ return fail('INVALID_SIGNATURE', 401);
304
+ }
305
+
306
+ let equal;
307
+ try {
308
+ equal = this._crypto.timingSafeEqual(
309
+ Buffer.from(expectedBuf),
310
+ receivedBuf,
311
+ );
312
+ } catch {
313
+ return fail('INVALID_SIGNATURE', 401);
314
+ }
315
+
316
+ if (!equal) {
317
+ return fail('INVALID_SIGNATURE', 401);
318
+ }
319
+
320
+ return { ok: true };
321
+ }
322
+
323
+ /**
324
+ * Verifies an incoming sync request.
325
+ *
326
+ * @param {{ method: string, url: string, headers: Record<string, string>, body?: Buffer|Uint8Array }} request
327
+ * @returns {Promise<{ ok: true } | { ok: false, reason: string, status: number }>}
328
+ */
329
+ async verify(request) {
330
+ const headers = request.headers || {};
331
+
332
+ const headerResult = this._validateHeaders(headers);
333
+ if (!headerResult.ok) {
334
+ this._metrics.malformedRejects += 1;
335
+ return this._fail('header validation failed', { reason: headerResult.reason }, headerResult);
336
+ }
337
+
338
+ const { timestamp, nonce, keyId } = headerResult;
339
+
340
+ const freshnessResult = this._validateFreshness(timestamp);
341
+ if (!freshnessResult.ok) {
342
+ return this._fail('clock skew rejected', { keyId, timestamp }, freshnessResult);
343
+ }
344
+
345
+ const keyResult = this._resolveKey(keyId);
346
+ if (!keyResult.ok) {
347
+ return this._fail('unknown key-id', { keyId }, keyResult);
348
+ }
349
+
350
+ const sigResult = await this._verifySignature({
351
+ request, secret: keyResult.secret, keyId, timestamp, nonce,
352
+ });
353
+ if (!sigResult.ok) {
354
+ return this._fail('signature mismatch', { keyId }, sigResult);
355
+ }
356
+
357
+ // Reserve nonce only after signature verification succeeds to avoid
358
+ // consuming nonces for requests with invalid signatures.
359
+ const nonceResult = this._reserveNonce(nonce);
360
+ if (!nonceResult.ok) {
361
+ return this._fail('replay detected', { keyId, nonce }, nonceResult);
362
+ }
363
+
364
+ return { ok: true };
365
+ }
366
+
367
+ /**
368
+ * Records an auth failure and returns the result.
369
+ * @param {string} message
370
+ * @param {Record<string, *>} context
371
+ * @param {{ ok: false, reason: string, status: number }} result
372
+ * @returns {{ ok: false, reason: string, status: number }}
373
+ * @private
374
+ */
375
+ _fail(message, context, result) {
376
+ this._metrics.authFailCount += 1;
377
+ this._logger.warn(`sync auth: ${message}`, context);
378
+ return result;
379
+ }
380
+
381
+ /**
382
+ * Increments the log-only passthrough counter.
383
+ */
384
+ recordLogOnlyPassthrough() {
385
+ this._metrics.logOnlyPassthroughs += 1;
386
+ }
387
+
388
+ /**
389
+ * Returns a snapshot of auth metrics.
390
+ *
391
+ * @returns {{ authFailCount: number, replayRejectCount: number, nonceEvictions: number, clockSkewRejects: number, malformedRejects: number, logOnlyPassthroughs: number }}
392
+ */
393
+ getMetrics() {
394
+ return { ...this._metrics };
395
+ }
396
+ }
@@ -45,6 +45,7 @@
45
45
 
46
46
  import { retry } from '@git-stunts/alfred';
47
47
  import GraphPersistencePort from '../../ports/GraphPersistencePort.js';
48
+ import { validateOid, validateRef, validateLimit, validateConfigKey } from './adapterValidation.js';
48
49
 
49
50
  /**
50
51
  * Transient Git errors that are safe to retry automatically.
@@ -374,30 +375,13 @@ export default class GitGraphAdapter extends GraphPersistencePort {
374
375
 
375
376
  /**
376
377
  * Validates that a ref is safe to use in git commands.
377
- * Prevents command injection via malicious ref names.
378
+ * Delegates to shared validation in adapterValidation.js.
378
379
  * @param {string} ref - The ref to validate
379
380
  * @throws {Error} If ref contains invalid characters, is too long, or starts with -/--
380
381
  * @private
381
382
  */
382
383
  _validateRef(ref) {
383
- if (!ref || typeof ref !== 'string') {
384
- throw new Error('Ref must be a non-empty string');
385
- }
386
- // Prevent buffer overflow attacks with extremely long refs
387
- if (ref.length > 1024) {
388
- throw new Error(`Ref too long: ${ref.length} chars. Maximum is 1024`);
389
- }
390
- // Prevent git option injection (must check before pattern matching)
391
- if (ref.startsWith('-') || ref.startsWith('--')) {
392
- throw new Error(`Invalid ref: ${ref}. Refs cannot start with - or --. See https://github.com/git-stunts/git-warp#security`);
393
- }
394
- // Allow alphanumeric, ., /, -, _ in names
395
- // Allow ancestry operators: ^ or ~ optionally followed by digits
396
- // Allow range operators: .. between names
397
- const validRefPattern = /^[a-zA-Z0-9._/-]+((~\d*|\^\d*|\.\.[a-zA-Z0-9._/-]+)*)$/;
398
- if (!validRefPattern.test(ref)) {
399
- throw new Error(`Invalid ref format: ${ref}. Only alphanumeric characters, ., /, -, _, ^, ~, and range operators are allowed. See https://github.com/git-stunts/git-warp#ref-validation`);
400
- }
384
+ validateRef(ref);
401
385
  }
402
386
 
403
387
  /**
@@ -546,42 +530,24 @@ export default class GitGraphAdapter extends GraphPersistencePort {
546
530
 
547
531
  /**
548
532
  * Validates that an OID is safe to use in git commands.
533
+ * Delegates to shared validation in adapterValidation.js.
549
534
  * @param {string} oid - The OID to validate
550
535
  * @throws {Error} If OID is invalid
551
536
  * @private
552
537
  */
553
538
  _validateOid(oid) {
554
- if (!oid || typeof oid !== 'string') {
555
- throw new Error('OID must be a non-empty string');
556
- }
557
- if (oid.length > 64) {
558
- throw new Error(`OID too long: ${oid.length} chars. Maximum is 64`);
559
- }
560
- const validOidPattern = /^[0-9a-fA-F]{4,64}$/;
561
- if (!validOidPattern.test(oid)) {
562
- throw new Error(`Invalid OID format: ${oid}`);
563
- }
539
+ validateOid(oid);
564
540
  }
565
541
 
566
542
  /**
567
543
  * Validates that a limit is a safe positive integer.
544
+ * Delegates to shared validation in adapterValidation.js.
568
545
  * @param {number} limit - The limit to validate
569
546
  * @throws {Error} If limit is invalid
570
547
  * @private
571
548
  */
572
549
  _validateLimit(limit) {
573
- if (typeof limit !== 'number' || !Number.isFinite(limit)) {
574
- throw new Error('Limit must be a finite number');
575
- }
576
- if (!Number.isInteger(limit)) {
577
- throw new Error('Limit must be an integer');
578
- }
579
- if (limit <= 0) {
580
- throw new Error('Limit must be a positive integer');
581
- }
582
- if (limit > 10_000_000) {
583
- throw new Error(`Limit too large: ${limit}. Maximum is 10,000,000`);
584
- }
550
+ validateLimit(limit);
585
551
  }
586
552
 
587
553
  /**
@@ -721,26 +687,13 @@ export default class GitGraphAdapter extends GraphPersistencePort {
721
687
 
722
688
  /**
723
689
  * Validates that a config key is safe to use in git commands.
690
+ * Delegates to shared validation in adapterValidation.js.
724
691
  * @param {string} key - The config key to validate
725
692
  * @throws {Error} If key is invalid
726
693
  * @private
727
694
  */
728
695
  _validateConfigKey(key) {
729
- if (!key || typeof key !== 'string') {
730
- throw new Error('Config key must be a non-empty string');
731
- }
732
- if (key.length > 256) {
733
- throw new Error(`Config key too long: ${key.length} chars. Maximum is 256`);
734
- }
735
- // Prevent git option injection
736
- if (key.startsWith('-')) {
737
- throw new Error(`Invalid config key: ${key}. Keys cannot start with -`);
738
- }
739
- // Allow section.subsection.key format
740
- const validKeyPattern = /^[a-zA-Z][a-zA-Z0-9._-]*$/;
741
- if (!validKeyPattern.test(key)) {
742
- throw new Error(`Invalid config key format: ${key}`);
743
- }
696
+ validateConfigKey(key);
744
697
  }
745
698
 
746
699
  /**