@xtr-dev/rondevu-server 0.5.11 → 0.5.13

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/src/rpc.ts CHANGED
@@ -3,45 +3,25 @@ import { Storage } from './storage/types.ts';
3
3
  import { Config } from './config.ts';
4
4
  import {
5
5
  validateTags,
6
- validateUsername,
7
- verifySignature,
6
+ validatePublicKey,
7
+ verifyEd25519Signature,
8
8
  buildSignatureMessage,
9
9
  } from './crypto.ts';
10
10
 
11
11
  // Constants (non-configurable)
12
12
  const MAX_PAGE_SIZE = 100;
13
-
14
- // NOTE: MAX_SDP_SIZE, MAX_CANDIDATE_SIZE, MAX_CANDIDATE_DEPTH, and MAX_CANDIDATES_PER_REQUEST
15
- // are now configurable via environment variables (see config.ts)
16
-
17
- // ===== Rate Limiting =====
18
-
19
- // Rate limiting windows (these are fixed, limits come from config)
20
- // NOTE: Uses fixed-window rate limiting with full window reset on expiry
21
- // - Window starts on first request and expires after window duration
22
- // - When window expires, counter resets to 0 and new window starts
23
- // - This is simpler than sliding windows but may allow bursts at window boundaries
24
- const CREDENTIAL_RATE_WINDOW = 1000; // 1 second in milliseconds
25
13
  const REQUEST_RATE_WINDOW = 1000; // 1 second in milliseconds
26
14
 
27
15
  /**
28
16
  * Check JSON object depth to prevent stack overflow from deeply nested objects
29
- * CRITICAL: Checks depth BEFORE recursing to prevent stack overflow
30
- * @param obj Object to check
31
- * @param maxDepth Maximum allowed depth
32
- * @param currentDepth Current recursion depth
33
- * @returns Actual depth of the object (returns maxDepth + 1 if exceeded)
34
17
  */
35
18
  function getJsonDepth(obj: any, maxDepth: number, currentDepth = 0): number {
36
- // Check for primitives/null first
37
19
  if (obj === null || typeof obj !== 'object') {
38
20
  return currentDepth;
39
21
  }
40
22
 
41
- // CRITICAL: Check depth BEFORE recursing to prevent stack overflow
42
- // If we're already at max depth, don't recurse further
43
23
  if (currentDepth >= maxDepth) {
44
- return currentDepth + 1; // Indicate exceeded
24
+ return currentDepth + 1;
45
25
  }
46
26
 
47
27
  let maxChildDepth = currentDepth;
@@ -50,7 +30,6 @@ function getJsonDepth(obj: any, maxDepth: number, currentDepth = 0): number {
50
30
  const childDepth = getJsonDepth(obj[key], maxDepth, currentDepth + 1);
51
31
  maxChildDepth = Math.max(maxChildDepth, childDepth);
52
32
 
53
- // Early exit if exceeded
54
33
  if (maxChildDepth > maxDepth) {
55
34
  return maxChildDepth;
56
35
  }
@@ -62,7 +41,6 @@ function getJsonDepth(obj: any, maxDepth: number, currentDepth = 0): number {
62
41
 
63
42
  /**
64
43
  * Validate parameter is a non-empty string
65
- * Prevents type coercion issues and injection attacks
66
44
  */
67
45
  function validateStringParam(value: any, paramName: string): void {
68
46
  if (typeof value !== 'string' || value.length === 0) {
@@ -79,7 +57,7 @@ export const ErrorCodes = {
79
57
  INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
80
58
 
81
59
  // Validation errors
82
- INVALID_NAME: 'INVALID_NAME',
60
+ INVALID_PUBLIC_KEY: 'INVALID_PUBLIC_KEY',
83
61
  INVALID_TAG: 'INVALID_TAG',
84
62
  INVALID_SDP: 'INVALID_SDP',
85
63
  INVALID_PARAMS: 'INVALID_PARAMS',
@@ -144,11 +122,6 @@ export interface RpcResponse {
144
122
  /**
145
123
  * RPC Method Parameter Interfaces
146
124
  */
147
- export interface GenerateCredentialsParams {
148
- name?: string; // Optional: claim specific username (4-32 chars, alphanumeric + dashes + periods)
149
- expiresAt?: number;
150
- }
151
-
152
125
  export interface DiscoverParams {
153
126
  tags: string[];
154
127
  limit?: number;
@@ -168,6 +141,7 @@ export interface DeleteOfferParams {
168
141
  export interface AnswerOfferParams {
169
142
  offerId: string;
170
143
  sdp: string;
144
+ matchedTags?: string[];
171
145
  }
172
146
 
173
147
  export interface GetOfferAnswerParams {
@@ -190,11 +164,10 @@ export interface GetIceCandidatesParams {
190
164
 
191
165
  /**
192
166
  * RPC method handler
193
- * Generic type parameter allows individual handlers to specify their param types
194
167
  */
195
168
  type RpcHandler<TParams = any> = (
196
169
  params: TParams,
197
- name: string,
170
+ publicKey: string,
198
171
  timestamp: number,
199
172
  signature: string,
200
173
  storage: Storage,
@@ -204,28 +177,25 @@ type RpcHandler<TParams = any> = (
204
177
 
205
178
  /**
206
179
  * Validate timestamp for replay attack prevention
207
- * Throws RpcError if timestamp is invalid
208
180
  */
209
181
  function validateTimestamp(timestamp: number, config: Config): void {
210
182
  const now = Date.now();
211
183
 
212
- // Check if timestamp is too old (replay attack)
213
184
  if (now - timestamp > config.timestampMaxAge) {
214
185
  throw new RpcError(ErrorCodes.INVALID_PARAMS, 'Timestamp too old');
215
186
  }
216
187
 
217
- // Check if timestamp is too far in future (clock skew)
218
188
  if (timestamp - now > config.timestampMaxFuture) {
219
189
  throw new RpcError(ErrorCodes.INVALID_PARAMS, 'Timestamp too far in future');
220
190
  }
221
191
  }
222
192
 
223
193
  /**
224
- * Verify request signature for authentication
225
- * Throws RpcError on authentication failure
194
+ * Verify request signature using Ed25519
195
+ * Stateless verification - no identity registration required
226
196
  */
227
197
  async function verifyRequestSignature(
228
- name: string,
198
+ publicKey: string,
229
199
  timestamp: number,
230
200
  nonce: string,
231
201
  signature: string,
@@ -237,143 +207,40 @@ async function verifyRequestSignature(
237
207
  // Validate timestamp first
238
208
  validateTimestamp(timestamp, config);
239
209
 
240
- // Get credential to retrieve secret
241
- const credential = await storage.getCredential(name);
242
- if (!credential) {
243
- throw new RpcError(ErrorCodes.INVALID_CREDENTIALS, 'Invalid credentials');
210
+ // Validate public key format
211
+ const pkValidation = validatePublicKey(publicKey);
212
+ if (!pkValidation.valid) {
213
+ throw new RpcError(ErrorCodes.INVALID_PUBLIC_KEY, pkValidation.error || 'Invalid public key');
244
214
  }
245
215
 
246
- // Build message and verify signature (includes nonce to prevent signature reuse)
216
+ // Build message and verify Ed25519 signature
247
217
  const message = buildSignatureMessage(timestamp, nonce, method, params);
248
- const isValid = await verifySignature(credential.secret, message, signature);
218
+ const isValid = await verifyEd25519Signature(publicKey, message, signature);
249
219
 
250
220
  if (!isValid) {
251
221
  throw new RpcError(ErrorCodes.INVALID_CREDENTIALS, 'Invalid signature');
252
222
  }
253
223
 
254
224
  // Check nonce uniqueness AFTER successful signature verification
255
- // This prevents DoS where invalid signatures burn nonces
256
- // Only valid authenticated requests can mark nonces as used
257
- const nonceKey = `nonce:${name}:${nonce}`;
225
+ const nonceKey = `nonce:${publicKey}:${nonce}`;
258
226
  const nonceExpiresAt = timestamp + config.timestampMaxAge;
259
227
  const nonceIsNew = await storage.checkAndMarkNonce(nonceKey, nonceExpiresAt);
260
228
 
261
229
  if (!nonceIsNew) {
262
230
  throw new RpcError(ErrorCodes.INVALID_CREDENTIALS, 'Nonce already used (replay attack detected)');
263
231
  }
264
-
265
- // Update last used timestamp
266
- const now = Date.now();
267
- const credentialExpiresAt = now + (365 * 24 * 60 * 60 * 1000); // 1 year
268
- await storage.updateCredentialUsage(name, now, credentialExpiresAt);
269
232
  }
270
233
 
271
234
  /**
272
235
  * RPC Method Handlers
273
236
  */
274
-
275
237
  const handlers: Record<string, RpcHandler> = {
276
238
  /**
277
- * Generate new credentials (name + secret pair)
278
- * No authentication required - this is how users get started
279
- * SECURITY: Rate limited per IP to prevent abuse (database-backed for multi-instance support)
280
- */
281
- async generateCredentials(params: GenerateCredentialsParams, name, timestamp, signature, storage, config, request: RpcRequest & { clientIp?: string }) {
282
- // Check total credentials limit
283
- const credentialCount = await storage.getCredentialCount();
284
- if (credentialCount >= config.maxTotalCredentials) {
285
- throw new RpcError(
286
- ErrorCodes.STORAGE_FULL,
287
- `Server credential limit reached (${config.maxTotalCredentials}). Try again later.`
288
- );
289
- }
290
-
291
- // Rate limiting check (IP-based, stored in database)
292
- // SECURITY: Use stricter global rate limit for requests without identifiable IP
293
- let rateLimitKey: string;
294
- let rateLimit: number;
295
-
296
- if (!request.clientIp) {
297
- // Warn about missing IP (suggests proxy misconfiguration)
298
- console.warn('⚠️ WARNING: Unable to determine client IP for credential generation. Using global rate limit.');
299
- // Use global rate limit with much stricter limit (prevents DoS while allowing basic function)
300
- rateLimitKey = 'cred_gen:global_unknown';
301
- rateLimit = 2; // Only 2 credentials per second globally for all unknown IPs combined
302
- } else {
303
- rateLimitKey = `cred_gen:${request.clientIp}`;
304
- rateLimit = config.credentialsPerIpPerSecond;
305
- }
306
-
307
- const allowed = await storage.checkRateLimit(
308
- rateLimitKey,
309
- rateLimit,
310
- CREDENTIAL_RATE_WINDOW
311
- );
312
-
313
- if (!allowed) {
314
- throw new RpcError(
315
- ErrorCodes.RATE_LIMIT_EXCEEDED,
316
- `Rate limit exceeded. Maximum ${rateLimit} credentials per second${request.clientIp ? ' per IP' : ' (global limit for unidentified IPs)'}.`
317
- );
318
- }
319
-
320
- // Validate username if provided
321
- if (params.name !== undefined) {
322
- if (typeof params.name !== 'string') {
323
- throw new RpcError(ErrorCodes.INVALID_PARAMS, 'name must be a string');
324
- }
325
- const usernameValidation = validateUsername(params.name);
326
- if (!usernameValidation.valid) {
327
- throw new RpcError(ErrorCodes.INVALID_PARAMS, usernameValidation.error || 'Invalid username');
328
- }
329
- }
330
-
331
- // Validate expiresAt if provided
332
- if (params.expiresAt !== undefined) {
333
- if (typeof params.expiresAt !== 'number' || isNaN(params.expiresAt) || !Number.isFinite(params.expiresAt)) {
334
- throw new RpcError(ErrorCodes.INVALID_PARAMS, 'expiresAt must be a valid timestamp');
335
- }
336
- // Prevent setting expiry in the past (with 1 minute tolerance for clock skew)
337
- const now = Date.now();
338
- if (params.expiresAt < now - 60000) {
339
- throw new RpcError(ErrorCodes.INVALID_PARAMS, 'expiresAt cannot be in the past');
340
- }
341
- // Prevent unreasonably far future expiry (max 10 years)
342
- const maxFuture = now + (10 * 365 * 24 * 60 * 60 * 1000);
343
- if (params.expiresAt > maxFuture) {
344
- throw new RpcError(ErrorCodes.INVALID_PARAMS, 'expiresAt cannot be more than 10 years in the future');
345
- }
346
- }
347
-
348
- try {
349
- const credential = await storage.generateCredentials({
350
- name: params.name,
351
- expiresAt: params.expiresAt,
352
- });
353
-
354
- return {
355
- name: credential.name,
356
- secret: credential.secret,
357
- createdAt: credential.createdAt,
358
- expiresAt: credential.expiresAt,
359
- };
360
- } catch (error: any) {
361
- if (error.message === 'Username already taken') {
362
- throw new RpcError(ErrorCodes.INVALID_PARAMS, 'Username already taken');
363
- }
364
- throw error;
365
- }
366
- },
367
-
368
- /**
369
- * Discover offers by tags - Supports 2 modes:
370
- * 1. Paginated discovery: tags array with limit/offset
371
- * 2. Random discovery: tags array without limit (returns single random offer)
239
+ * Discover offers by tags
372
240
  */
373
- async discover(params: DiscoverParams, name, timestamp, signature, storage, config, request: RpcRequest) {
241
+ async discover(params: DiscoverParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
374
242
  const { tags, limit, offset } = params;
375
243
 
376
- // Validate tags
377
244
  const tagsValidation = validateTags(tags);
378
245
  if (!tagsValidation.valid) {
379
246
  throw new RpcError(ErrorCodes.INVALID_TAG, tagsValidation.error || 'Invalid tags');
@@ -381,7 +248,6 @@ const handlers: Record<string, RpcHandler> = {
381
248
 
382
249
  // Mode 1: Paginated discovery
383
250
  if (limit !== undefined) {
384
- // Validate numeric parameters
385
251
  if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 0) {
386
252
  throw new RpcError(ErrorCodes.INVALID_PARAMS, 'limit must be a non-negative integer');
387
253
  }
@@ -392,12 +258,11 @@ const handlers: Record<string, RpcHandler> = {
392
258
  const pageLimit = Math.min(Math.max(1, limit), MAX_PAGE_SIZE);
393
259
  const pageOffset = Math.max(0, offset || 0);
394
260
 
395
- // Exclude self if authenticated
396
- const excludeUsername = name || null;
261
+ const excludePublicKey = publicKey || null;
397
262
 
398
263
  const offers = await storage.discoverOffers(
399
264
  tags,
400
- excludeUsername,
265
+ excludePublicKey,
401
266
  pageLimit,
402
267
  pageOffset
403
268
  );
@@ -405,7 +270,7 @@ const handlers: Record<string, RpcHandler> = {
405
270
  return {
406
271
  offers: offers.map(offer => ({
407
272
  offerId: offer.id,
408
- username: offer.username,
273
+ publicKey: offer.publicKey,
409
274
  tags: offer.tags,
410
275
  sdp: offer.sdp,
411
276
  createdAt: offer.createdAt,
@@ -417,11 +282,9 @@ const handlers: Record<string, RpcHandler> = {
417
282
  };
418
283
  }
419
284
 
420
- // Mode 2: Random discovery (no limit provided)
421
- // Exclude self if authenticated
422
- const excludeUsername = name || null;
423
-
424
- const offer = await storage.getRandomOffer(tags, excludeUsername);
285
+ // Mode 2: Random discovery
286
+ const excludePublicKey = publicKey || null;
287
+ const offer = await storage.getRandomOffer(tags, excludePublicKey);
425
288
 
426
289
  if (!offer) {
427
290
  throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, 'No offers found matching tags');
@@ -429,7 +292,7 @@ const handlers: Record<string, RpcHandler> = {
429
292
 
430
293
  return {
431
294
  offerId: offer.id,
432
- username: offer.username,
295
+ publicKey: offer.publicKey,
433
296
  tags: offer.tags,
434
297
  sdp: offer.sdp,
435
298
  createdAt: offer.createdAt,
@@ -440,20 +303,18 @@ const handlers: Record<string, RpcHandler> = {
440
303
  /**
441
304
  * Publish offers with tags
442
305
  */
443
- async publishOffer(params: PublishOfferParams, name, timestamp, signature, storage, config, request: RpcRequest) {
306
+ async publishOffer(params: PublishOfferParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
444
307
  const { tags, offers, ttl } = params;
445
308
 
446
- if (!name) {
447
- throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Name required for offer publishing');
309
+ if (!publicKey) {
310
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Authentication required for offer publishing');
448
311
  }
449
312
 
450
- // Validate tags
451
313
  const tagsValidation = validateTags(tags);
452
314
  if (!tagsValidation.valid) {
453
315
  throw new RpcError(ErrorCodes.INVALID_TAG, tagsValidation.error || 'Invalid tags');
454
316
  }
455
317
 
456
- // Validate offers
457
318
  if (!offers || !Array.isArray(offers) || offers.length === 0) {
458
319
  throw new RpcError(ErrorCodes.MISSING_PARAMS, 'Must provide at least one offer');
459
320
  }
@@ -465,8 +326,7 @@ const handlers: Record<string, RpcHandler> = {
465
326
  );
466
327
  }
467
328
 
468
- // Check per-user offer limit
469
- const userOfferCount = await storage.getOfferCountByUsername(name);
329
+ const userOfferCount = await storage.getOfferCountByPublicKey(publicKey);
470
330
  if (userOfferCount + offers.length > config.maxOffersPerUser) {
471
331
  throw new RpcError(
472
332
  ErrorCodes.TOO_MANY_OFFERS_PER_USER,
@@ -474,7 +334,6 @@ const handlers: Record<string, RpcHandler> = {
474
334
  );
475
335
  }
476
336
 
477
- // Check total offers limit
478
337
  const totalOfferCount = await storage.getOfferCount();
479
338
  if (totalOfferCount + offers.length > config.maxTotalOffers) {
480
339
  throw new RpcError(
@@ -483,7 +342,6 @@ const handlers: Record<string, RpcHandler> = {
483
342
  );
484
343
  }
485
344
 
486
- // Validate each offer has valid SDP
487
345
  offers.forEach((offer, index) => {
488
346
  if (!offer || typeof offer !== 'object') {
489
347
  throw new RpcError(ErrorCodes.INVALID_PARAMS, `Invalid offer at index ${index}: must be an object`);
@@ -499,27 +357,21 @@ const handlers: Record<string, RpcHandler> = {
499
357
  }
500
358
  });
501
359
 
502
- // Validate TTL if provided
503
360
  if (ttl !== undefined) {
504
361
  if (typeof ttl !== 'number' || isNaN(ttl) || ttl < 0) {
505
362
  throw new RpcError(ErrorCodes.INVALID_PARAMS, 'TTL must be a non-negative number');
506
363
  }
507
364
  }
508
365
 
509
- // Create offers with tags
510
366
  const now = Date.now();
511
367
  const offerTtl =
512
368
  ttl !== undefined
513
- ? Math.min(
514
- Math.max(ttl, config.offerMinTtl),
515
- config.offerMaxTtl
516
- )
369
+ ? Math.min(Math.max(ttl, config.offerMinTtl), config.offerMaxTtl)
517
370
  : config.offerDefaultTtl;
518
371
  const expiresAt = now + offerTtl;
519
372
 
520
- // Prepare offer requests with tags
521
373
  const offerRequests = offers.map(offer => ({
522
- username: name,
374
+ publicKey,
523
375
  tags,
524
376
  sdp: offer.sdp,
525
377
  expiresAt,
@@ -528,7 +380,7 @@ const handlers: Record<string, RpcHandler> = {
528
380
  const createdOffers = await storage.createOffers(offerRequests);
529
381
 
530
382
  return {
531
- username: name,
383
+ publicKey,
532
384
  tags,
533
385
  offers: createdOffers.map(offer => ({
534
386
  offerId: offer.id,
@@ -544,18 +396,18 @@ const handlers: Record<string, RpcHandler> = {
544
396
  /**
545
397
  * Delete an offer by ID
546
398
  */
547
- async deleteOffer(params: DeleteOfferParams, name, timestamp, signature, storage, config, request: RpcRequest) {
399
+ async deleteOffer(params: DeleteOfferParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
548
400
  const { offerId } = params;
549
401
 
550
- if (!name) {
551
- throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Name required');
402
+ if (!publicKey) {
403
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Authentication required');
552
404
  }
553
405
 
554
406
  validateStringParam(offerId, 'offerId');
555
407
 
556
- const deleted = await storage.deleteOffer(offerId, name);
408
+ const deleted = await storage.deleteOffer(offerId, publicKey);
557
409
  if (!deleted) {
558
- throw new RpcError(ErrorCodes.NOT_AUTHORIZED, 'Offer not found or not owned by this name');
410
+ throw new RpcError(ErrorCodes.NOT_AUTHORIZED, 'Offer not found or not owned by this identity');
559
411
  }
560
412
 
561
413
  return { success: true };
@@ -564,14 +416,13 @@ const handlers: Record<string, RpcHandler> = {
564
416
  /**
565
417
  * Answer an offer
566
418
  */
567
- async answerOffer(params: AnswerOfferParams, name, timestamp, signature, storage, config, request: RpcRequest) {
568
- const { offerId, sdp } = params;
419
+ async answerOffer(params: AnswerOfferParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
420
+ const { offerId, sdp, matchedTags } = params;
569
421
 
570
- // Validate input parameters
571
422
  validateStringParam(offerId, 'offerId');
572
423
 
573
- if (!name) {
574
- throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Name required');
424
+ if (!publicKey) {
425
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Authentication required');
575
426
  }
576
427
 
577
428
  if (!sdp || typeof sdp !== 'string' || sdp.length === 0) {
@@ -582,16 +433,28 @@ const handlers: Record<string, RpcHandler> = {
582
433
  throw new RpcError(ErrorCodes.SDP_TOO_LARGE, `SDP too large (max ${config.maxSdpSize} bytes)`);
583
434
  }
584
435
 
436
+ if (matchedTags !== undefined && !Array.isArray(matchedTags)) {
437
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, 'matchedTags must be an array');
438
+ }
439
+
585
440
  const offer = await storage.getOfferById(offerId);
586
441
  if (!offer) {
587
442
  throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, 'Offer not found');
588
443
  }
589
444
 
590
- if (offer.answererUsername) {
445
+ if (offer.answererPublicKey) {
591
446
  throw new RpcError(ErrorCodes.OFFER_ALREADY_ANSWERED, 'Offer already answered');
592
447
  }
593
448
 
594
- await storage.answerOffer(offerId, name, sdp);
449
+ if (matchedTags && matchedTags.length > 0) {
450
+ const offerTagSet = new Set(offer.tags);
451
+ const invalidTags = matchedTags.filter(tag => !offerTagSet.has(tag));
452
+ if (invalidTags.length > 0) {
453
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, `matchedTags contains tags not on offer: ${invalidTags.join(', ')}`);
454
+ }
455
+ }
456
+
457
+ await storage.answerOffer(offerId, publicKey, sdp, matchedTags);
595
458
 
596
459
  return { success: true, offerId };
597
460
  },
@@ -599,14 +462,13 @@ const handlers: Record<string, RpcHandler> = {
599
462
  /**
600
463
  * Get answer for an offer
601
464
  */
602
- async getOfferAnswer(params: GetOfferAnswerParams, name, timestamp, signature, storage, config, request: RpcRequest) {
465
+ async getOfferAnswer(params: GetOfferAnswerParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
603
466
  const { offerId } = params;
604
467
 
605
- // Validate input parameters
606
468
  validateStringParam(offerId, 'offerId');
607
469
 
608
- if (!name) {
609
- throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Name required');
470
+ if (!publicKey) {
471
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Authentication required');
610
472
  }
611
473
 
612
474
  const offer = await storage.getOfferById(offerId);
@@ -614,18 +476,18 @@ const handlers: Record<string, RpcHandler> = {
614
476
  throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, 'Offer not found');
615
477
  }
616
478
 
617
- if (offer.username !== name) {
479
+ if (offer.publicKey !== publicKey) {
618
480
  throw new RpcError(ErrorCodes.NOT_AUTHORIZED, 'Not authorized to access this offer');
619
481
  }
620
482
 
621
- if (!offer.answererUsername || !offer.answerSdp) {
483
+ if (!offer.answererPublicKey || !offer.answerSdp) {
622
484
  throw new RpcError(ErrorCodes.OFFER_NOT_ANSWERED, 'Offer not yet answered');
623
485
  }
624
486
 
625
487
  return {
626
488
  sdp: offer.answerSdp,
627
489
  offerId: offer.id,
628
- answererId: offer.answererUsername,
490
+ answererPublicKey: offer.answererPublicKey,
629
491
  answeredAt: offer.answeredAt,
630
492
  };
631
493
  },
@@ -633,49 +495,38 @@ const handlers: Record<string, RpcHandler> = {
633
495
  /**
634
496
  * Combined polling for answers and ICE candidates
635
497
  */
636
- async poll(params: PollParams, name, timestamp, signature, storage, config, request: RpcRequest) {
498
+ async poll(params: PollParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
637
499
  const { since } = params;
638
500
 
639
- if (!name) {
640
- throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Name required');
501
+ if (!publicKey) {
502
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Authentication required');
641
503
  }
642
504
 
643
- // Validate since parameter
644
505
  if (since !== undefined && (typeof since !== 'number' || since < 0 || !Number.isFinite(since))) {
645
506
  throw new RpcError(ErrorCodes.INVALID_PARAMS, 'Invalid since parameter: must be a non-negative number');
646
507
  }
647
508
  const sinceTimestamp = since !== undefined ? since : 0;
648
509
 
649
- // Get all answered offers (where user is the offerer)
650
- const answeredOffers = await storage.getAnsweredOffers(name);
510
+ const answeredOffers = await storage.getAnsweredOffers(publicKey);
651
511
  const filteredAnswers = answeredOffers.filter(
652
512
  (offer) => offer.answeredAt && offer.answeredAt > sinceTimestamp
653
513
  );
654
514
 
655
- // Get all user's offers (where user is offerer)
656
- const ownedOffers = await storage.getOffersByUsername(name);
657
-
658
- // Get all offers the user has answered (where user is answerer)
659
- const answeredByUser = await storage.getOffersAnsweredBy(name);
515
+ const ownedOffers = await storage.getOffersByPublicKey(publicKey);
516
+ const answeredByUser = await storage.getOffersAnsweredBy(publicKey);
660
517
 
661
- // Combine offer IDs from both sources for ICE candidate fetching
662
- // The storage method handles filtering by role automatically
663
518
  const allOfferIds = [
664
519
  ...ownedOffers.map(offer => offer.id),
665
520
  ...answeredByUser.map(offer => offer.id),
666
521
  ];
667
- // Remove duplicates (shouldn't happen, but defensive)
668
522
  const offerIds = [...new Set(allOfferIds)];
669
523
 
670
- // Batch fetch ICE candidates for all offers using JOIN to avoid N+1 query problem
671
- // Server filters by role - offerers get answerer candidates, answerers get offerer candidates
672
524
  const iceCandidatesMap = await storage.getIceCandidatesForMultipleOffers(
673
525
  offerIds,
674
- name,
526
+ publicKey,
675
527
  sinceTimestamp
676
528
  );
677
529
 
678
- // Convert Map to Record for response
679
530
  const iceCandidatesByOffer: Record<string, any[]> = {};
680
531
  for (const [offerId, candidates] of iceCandidatesMap.entries()) {
681
532
  iceCandidatesByOffer[offerId] = candidates;
@@ -684,7 +535,7 @@ const handlers: Record<string, RpcHandler> = {
684
535
  return {
685
536
  answers: filteredAnswers.map((offer) => ({
686
537
  offerId: offer.id,
687
- answererId: offer.answererUsername,
538
+ answererPublicKey: offer.answererPublicKey,
688
539
  sdp: offer.answerSdp,
689
540
  answeredAt: offer.answeredAt,
690
541
  })),
@@ -695,14 +546,13 @@ const handlers: Record<string, RpcHandler> = {
695
546
  /**
696
547
  * Add ICE candidates
697
548
  */
698
- async addIceCandidates(params: AddIceCandidatesParams, name, timestamp, signature, storage, config, request: RpcRequest) {
549
+ async addIceCandidates(params: AddIceCandidatesParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
699
550
  const { offerId, candidates } = params;
700
551
 
701
- // Validate input parameters
702
552
  validateStringParam(offerId, 'offerId');
703
553
 
704
- if (!name) {
705
- throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Name required');
554
+ if (!publicKey) {
555
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Authentication required');
706
556
  }
707
557
 
708
558
  if (!Array.isArray(candidates) || candidates.length === 0) {
@@ -716,13 +566,11 @@ const handlers: Record<string, RpcHandler> = {
716
566
  );
717
567
  }
718
568
 
719
- // Validate each candidate is an object (don't enforce structure per CLAUDE.md)
720
569
  candidates.forEach((candidate, index) => {
721
570
  if (!candidate || typeof candidate !== 'object') {
722
571
  throw new RpcError(ErrorCodes.INVALID_PARAMS, `Invalid candidate at index ${index}: must be an object`);
723
572
  }
724
573
 
725
- // Check JSON depth to prevent stack overflow from deeply nested objects
726
574
  const depth = getJsonDepth(candidate, config.maxCandidateDepth + 1);
727
575
  if (depth > config.maxCandidateDepth) {
728
576
  throw new RpcError(
@@ -731,7 +579,6 @@ const handlers: Record<string, RpcHandler> = {
731
579
  );
732
580
  }
733
581
 
734
- // Ensure candidate is serializable and check size (will be stored as JSON)
735
582
  let candidateJson: string;
736
583
  try {
737
584
  candidateJson = JSON.stringify(candidate);
@@ -739,7 +586,6 @@ const handlers: Record<string, RpcHandler> = {
739
586
  throw new RpcError(ErrorCodes.INVALID_PARAMS, `Candidate at index ${index} is not serializable`);
740
587
  }
741
588
 
742
- // Validate candidate size to prevent abuse
743
589
  if (candidateJson.length > config.maxCandidateSize) {
744
590
  throw new RpcError(
745
591
  ErrorCodes.INVALID_PARAMS,
@@ -753,7 +599,6 @@ const handlers: Record<string, RpcHandler> = {
753
599
  throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, 'Offer not found');
754
600
  }
755
601
 
756
- // Check ICE candidates limit per offer
757
602
  const currentCandidateCount = await storage.getIceCandidateCount(offerId);
758
603
  if (currentCandidateCount + candidates.length > config.maxIceCandidatesPerOffer) {
759
604
  throw new RpcError(
@@ -762,10 +607,10 @@ const handlers: Record<string, RpcHandler> = {
762
607
  );
763
608
  }
764
609
 
765
- const role = offer.username === name ? 'offerer' : 'answerer';
610
+ const role = offer.publicKey === publicKey ? 'offerer' : 'answerer';
766
611
  const count = await storage.addIceCandidates(
767
612
  offerId,
768
- name,
613
+ publicKey,
769
614
  role,
770
615
  candidates
771
616
  );
@@ -776,17 +621,15 @@ const handlers: Record<string, RpcHandler> = {
776
621
  /**
777
622
  * Get ICE candidates
778
623
  */
779
- async getIceCandidates(params: GetIceCandidatesParams, name, timestamp, signature, storage, config, request: RpcRequest) {
624
+ async getIceCandidates(params: GetIceCandidatesParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
780
625
  const { offerId, since } = params;
781
626
 
782
- // Validate input parameters
783
627
  validateStringParam(offerId, 'offerId');
784
628
 
785
- if (!name) {
786
- throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Name required');
629
+ if (!publicKey) {
630
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Authentication required');
787
631
  }
788
632
 
789
- // Validate since parameter
790
633
  if (since !== undefined && (typeof since !== 'number' || since < 0 || !Number.isFinite(since))) {
791
634
  throw new RpcError(ErrorCodes.INVALID_PARAMS, 'Invalid since parameter: must be a non-negative number');
792
635
  }
@@ -797,10 +640,8 @@ const handlers: Record<string, RpcHandler> = {
797
640
  throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, 'Offer not found');
798
641
  }
799
642
 
800
- // Validate that user is authorized to access this offer's candidates
801
- // Only the offerer and answerer can access ICE candidates
802
- const isOfferer = offer.username === name;
803
- const isAnswerer = offer.answererUsername === name;
643
+ const isOfferer = offer.publicKey === publicKey;
644
+ const isAnswerer = offer.answererPublicKey === publicKey;
804
645
 
805
646
  if (!isOfferer && !isAnswerer) {
806
647
  throw new RpcError(ErrorCodes.NOT_AUTHORIZED, 'Not authorized to access ICE candidates for this offer');
@@ -825,7 +666,7 @@ const handlers: Record<string, RpcHandler> = {
825
666
  };
826
667
 
827
668
  // Methods that don't require authentication
828
- const UNAUTHENTICATED_METHODS = new Set(['generateCredentials', 'discover']);
669
+ const UNAUTHENTICATED_METHODS = new Set(['discover']);
829
670
 
830
671
  /**
831
672
  * Handle RPC batch request with header-based authentication
@@ -838,13 +679,11 @@ export async function handleRpc(
838
679
  ): Promise<RpcResponse[]> {
839
680
  const responses: RpcResponse[] = [];
840
681
 
841
- // Extract client IP for rate limiting
842
- // Try multiple headers for proxy compatibility
843
682
  const clientIp =
844
- ctx.req.header('cf-connecting-ip') || // Cloudflare
845
- ctx.req.header('x-real-ip') || // Nginx
683
+ ctx.req.header('cf-connecting-ip') ||
684
+ ctx.req.header('x-real-ip') ||
846
685
  ctx.req.header('x-forwarded-for')?.split(',')[0].trim() ||
847
- undefined; // Don't use fallback - let handlers decide how to handle missing IP
686
+ undefined;
848
687
 
849
688
  // General request rate limiting (per IP per second)
850
689
  if (clientIp) {
@@ -856,7 +695,6 @@ export async function handleRpc(
856
695
  );
857
696
 
858
697
  if (!allowed) {
859
- // Return error for all requests in the batch
860
698
  return requests.map(() => ({
861
699
  success: false,
862
700
  error: `Rate limit exceeded. Maximum ${config.requestsPerIpPerSecond} requests per second per IP.`,
@@ -865,21 +703,16 @@ export async function handleRpc(
865
703
  }
866
704
  }
867
705
 
868
- // Read auth headers (same for all requests in batch)
869
- const name = ctx.req.header('X-Name');
706
+ // Read auth headers (X-PublicKey instead of X-Name)
707
+ const publicKey = ctx.req.header('X-PublicKey');
870
708
  const timestampHeader = ctx.req.header('X-Timestamp');
871
709
  const nonce = ctx.req.header('X-Nonce');
872
710
  const signature = ctx.req.header('X-Signature');
873
711
 
874
- // Parse timestamp if present
875
712
  const timestamp = timestampHeader ? parseInt(timestampHeader, 10) : 0;
876
713
 
877
- // CRITICAL: Pre-calculate total operations BEFORE processing any requests
878
- // This prevents DoS where first N requests complete before limit triggers
879
- // Example attack prevented: 100 publishOffer × 100 offers = 10,000 operations
714
+ // Pre-calculate total operations
880
715
  let totalOperations = 0;
881
-
882
- // Count all operations across all requests first
883
716
  for (const request of requests) {
884
717
  const { method, params } = request;
885
718
  if (method === 'publishOffer' && params?.offers && Array.isArray(params.offers)) {
@@ -887,13 +720,10 @@ export async function handleRpc(
887
720
  } else if (method === 'addIceCandidates' && params?.candidates && Array.isArray(params.candidates)) {
888
721
  totalOperations += params.candidates.length;
889
722
  } else {
890
- totalOperations += 1; // Single operation
723
+ totalOperations += 1;
891
724
  }
892
725
  }
893
726
 
894
- // Reject entire batch if total operations exceed limit
895
- // This happens BEFORE processing any requests
896
- // Return error for EACH request to maintain response array alignment
897
727
  if (totalOperations > config.maxTotalOperations) {
898
728
  return requests.map(() => ({
899
729
  success: false,
@@ -907,7 +737,6 @@ export async function handleRpc(
907
737
  try {
908
738
  const { method, params } = request;
909
739
 
910
- // Validate request
911
740
  if (!method || typeof method !== 'string') {
912
741
  responses.push({
913
742
  success: false,
@@ -917,7 +746,6 @@ export async function handleRpc(
917
746
  continue;
918
747
  }
919
748
 
920
- // Get handler
921
749
  const handler = handlers[method];
922
750
  if (!handler) {
923
751
  responses.push({
@@ -928,14 +756,13 @@ export async function handleRpc(
928
756
  continue;
929
757
  }
930
758
 
931
- // Validate auth headers only for methods that require authentication
932
759
  const requiresAuth = !UNAUTHENTICATED_METHODS.has(method);
933
760
 
934
761
  if (requiresAuth) {
935
- if (!name || typeof name !== 'string') {
762
+ if (!publicKey || typeof publicKey !== 'string') {
936
763
  responses.push({
937
764
  success: false,
938
- error: 'Missing or invalid X-Name header',
765
+ error: 'Missing or invalid X-PublicKey header',
939
766
  errorCode: ErrorCodes.AUTH_REQUIRED,
940
767
  });
941
768
  continue;
@@ -968,9 +795,9 @@ export async function handleRpc(
968
795
  continue;
969
796
  }
970
797
 
971
- // Verify signature (validates timestamp, nonce, and signature)
798
+ // Verify Ed25519 signature
972
799
  await verifyRequestSignature(
973
- name,
800
+ publicKey,
974
801
  timestamp,
975
802
  nonce,
976
803
  signature,
@@ -980,10 +807,9 @@ export async function handleRpc(
980
807
  config
981
808
  );
982
809
 
983
- // Execute handler with auth
984
810
  const result = await handler(
985
811
  params || {},
986
- name,
812
+ publicKey,
987
813
  timestamp,
988
814
  signature,
989
815
  storage,
@@ -999,9 +825,9 @@ export async function handleRpc(
999
825
  // Execute handler without strict auth requirement
1000
826
  const result = await handler(
1001
827
  params || {},
1002
- name || '',
1003
- 0, // timestamp
1004
- '', // signature
828
+ publicKey || '',
829
+ 0,
830
+ '',
1005
831
  storage,
1006
832
  config,
1007
833
  { ...request, clientIp }
@@ -1020,8 +846,6 @@ export async function handleRpc(
1020
846
  errorCode: err.errorCode,
1021
847
  });
1022
848
  } else {
1023
- // Generic error - don't leak internal details
1024
- // Log the actual error for debugging
1025
849
  console.error('Unexpected RPC error:', err);
1026
850
  responses.push({
1027
851
  success: false,