@xtr-dev/rondevu-server 0.4.0 → 0.5.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/src/config.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { generateSecretKey } from './crypto.ts';
2
-
3
1
  /**
4
2
  * Application configuration
5
3
  * Reads from environment variables with sensible defaults
@@ -10,7 +8,6 @@ export interface Config {
10
8
  storagePath: string;
11
9
  corsOrigins: string[];
12
10
  version: string;
13
- authSecret: string;
14
11
  offerDefaultTtl: number;
15
12
  offerMaxTtl: number;
16
13
  offerMinTtl: number;
@@ -22,15 +19,6 @@ export interface Config {
22
19
  * Loads configuration from environment variables
23
20
  */
24
21
  export function loadConfig(): Config {
25
- // Generate or load auth secret
26
- let authSecret = process.env.AUTH_SECRET;
27
- if (!authSecret) {
28
- authSecret = generateSecretKey();
29
- console.warn('WARNING: No AUTH_SECRET provided. Generated temporary secret:', authSecret);
30
- console.warn('All peer credentials will be invalidated on server restart.');
31
- console.warn('Set AUTH_SECRET environment variable to persist credentials across restarts.');
32
- }
33
-
34
22
  return {
35
23
  port: parseInt(process.env.PORT || '3000', 10),
36
24
  storageType: (process.env.STORAGE_TYPE || 'sqlite') as 'sqlite' | 'memory',
@@ -39,7 +27,6 @@ export function loadConfig(): Config {
39
27
  ? process.env.CORS_ORIGINS.split(',').map(o => o.trim())
40
28
  : ['*'],
41
29
  version: process.env.VERSION || 'unknown',
42
- authSecret,
43
30
  offerDefaultTtl: parseInt(process.env.OFFER_DEFAULT_TTL || '60000', 10),
44
31
  offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || '86400000', 10),
45
32
  offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || '60000', 10),
package/src/crypto.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Crypto utilities for stateless peer authentication
3
- * Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
2
+ * Crypto utilities for Ed25519-based authentication
4
3
  * Uses @noble/ed25519 for Ed25519 signature verification
4
+ * Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
5
5
  */
6
6
 
7
7
  import * as ed25519 from '@noble/ed25519';
8
+ import { Buffer } from 'node:buffer';
8
9
 
9
10
  // Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+)
10
11
  // Uses Web Crypto API (compatible with both Node.js and Cloudflare Workers)
@@ -12,10 +13,6 @@ ed25519.hashes.sha512Async = async (message: Uint8Array) => {
12
13
  return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource));
13
14
  };
14
15
 
15
- const ALGORITHM = 'AES-GCM';
16
- const IV_LENGTH = 12; // 96 bits for GCM
17
- const KEY_LENGTH = 32; // 256 bits
18
-
19
16
  // Username validation
20
17
  const USERNAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
21
18
  const USERNAME_MIN_LENGTH = 3;
@@ -25,144 +22,68 @@ const USERNAME_MAX_LENGTH = 32;
25
22
  const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;
26
23
 
27
24
  /**
28
- * Generates a random peer ID (16 bytes = 32 hex chars)
29
- */
30
- export function generatePeerId(): string {
31
- const bytes = crypto.getRandomValues(new Uint8Array(16));
32
- return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
33
- }
34
-
35
- /**
36
- * Generates a random secret key for encryption (32 bytes = 64 hex chars)
37
- */
38
- export function generateSecretKey(): string {
39
- const bytes = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
40
- return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
41
- }
42
-
43
- /**
44
- * Convert hex string to Uint8Array
25
+ * Generates an anonymous username for users who don't want to claim one
26
+ * Format: anon-{timestamp}-{random}
27
+ * This reduces collision probability to near-zero
45
28
  */
46
- function hexToBytes(hex: string): Uint8Array {
47
- const bytes = new Uint8Array(hex.length / 2);
48
- for (let i = 0; i < hex.length; i += 2) {
49
- bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
50
- }
51
- return bytes;
29
+ export function generateAnonymousUsername(): string {
30
+ const timestamp = Date.now().toString(36);
31
+ const random = crypto.getRandomValues(new Uint8Array(3));
32
+ const hex = Array.from(random).map(b => b.toString(16).padStart(2, '0')).join('');
33
+ return `anon-${timestamp}-${hex}`;
52
34
  }
53
35
 
54
36
  /**
55
37
  * Convert Uint8Array to base64 string
38
+ * Uses Buffer for compatibility with Node.js-based clients
56
39
  */
57
40
  function bytesToBase64(bytes: Uint8Array): string {
58
- const binString = Array.from(bytes, (byte) =>
59
- String.fromCodePoint(byte)
60
- ).join('');
61
- return btoa(binString);
41
+ return Buffer.from(bytes).toString('base64');
62
42
  }
63
43
 
64
44
  /**
65
45
  * Convert base64 string to Uint8Array
46
+ * Uses Buffer for compatibility with Node.js-based clients
66
47
  */
67
48
  function base64ToBytes(base64: string): Uint8Array {
68
- const binString = atob(base64);
69
- return Uint8Array.from(binString, (char) => char.codePointAt(0)!);
49
+ return new Uint8Array(Buffer.from(base64, 'base64'));
70
50
  }
71
51
 
72
52
  /**
73
- * Encrypts a peer ID using the server secret key
74
- * Returns base64-encoded encrypted data (IV + ciphertext)
53
+ * Validates a generic auth message format
54
+ * Expected format: action:username:params:timestamp
55
+ * Validates that the message contains the expected username and has a valid timestamp
75
56
  */
76
- export async function encryptPeerId(peerId: string, secretKeyHex: string): Promise<string> {
77
- const keyBytes = hexToBytes(secretKeyHex);
57
+ export function validateAuthMessage(
58
+ expectedUsername: string,
59
+ message: string
60
+ ): { valid: boolean; error?: string } {
61
+ const parts = message.split(':');
78
62
 
79
- if (keyBytes.length !== KEY_LENGTH) {
80
- throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`);
63
+ if (parts.length < 3) {
64
+ return { valid: false, error: 'Invalid message format: must have at least action:username:timestamp' };
81
65
  }
82
66
 
83
- // Import key
84
- const key = await crypto.subtle.importKey(
85
- 'raw',
86
- keyBytes,
87
- { name: ALGORITHM, length: 256 },
88
- false,
89
- ['encrypt']
90
- );
91
-
92
- // Generate random IV
93
- const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
94
-
95
- // Encrypt peer ID
96
- const encoder = new TextEncoder();
97
- const data = encoder.encode(peerId);
98
-
99
- const encrypted = await crypto.subtle.encrypt(
100
- { name: ALGORITHM, iv },
101
- key,
102
- data
103
- );
104
-
105
- // Combine IV + ciphertext and encode as base64
106
- const combined = new Uint8Array(iv.length + encrypted.byteLength);
107
- combined.set(iv, 0);
108
- combined.set(new Uint8Array(encrypted), iv.length);
109
-
110
- return bytesToBase64(combined);
111
- }
67
+ // Extract username (second part) and timestamp (last part)
68
+ const messageUsername = parts[1];
69
+ const timestamp = parseInt(parts[parts.length - 1], 10);
112
70
 
113
- /**
114
- * Decrypts an encrypted peer ID secret
115
- * Returns the plaintext peer ID or throws if decryption fails
116
- */
117
- export async function decryptPeerId(encryptedSecret: string, secretKeyHex: string): Promise<string> {
118
- try {
119
- const keyBytes = hexToBytes(secretKeyHex);
120
-
121
- if (keyBytes.length !== KEY_LENGTH) {
122
- throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`);
123
- }
71
+ // Validate username matches
72
+ if (messageUsername !== expectedUsername) {
73
+ return { valid: false, error: 'Username in message does not match authenticated username' };
74
+ }
124
75
 
125
- // Decode base64
126
- const combined = base64ToBytes(encryptedSecret);
127
-
128
- // Extract IV and ciphertext
129
- const iv = combined.slice(0, IV_LENGTH);
130
- const ciphertext = combined.slice(IV_LENGTH);
131
-
132
- // Import key
133
- const key = await crypto.subtle.importKey(
134
- 'raw',
135
- keyBytes,
136
- { name: ALGORITHM, length: 256 },
137
- false,
138
- ['decrypt']
139
- );
140
-
141
- // Decrypt
142
- const decrypted = await crypto.subtle.decrypt(
143
- { name: ALGORITHM, iv },
144
- key,
145
- ciphertext
146
- );
147
-
148
- const decoder = new TextDecoder();
149
- return decoder.decode(decrypted);
150
- } catch (err) {
151
- throw new Error('Failed to decrypt peer ID: invalid secret or secret key');
76
+ // Validate timestamp
77
+ if (isNaN(timestamp)) {
78
+ return { valid: false, error: 'Invalid timestamp in message' };
152
79
  }
153
- }
154
80
 
155
- /**
156
- * Validates that a peer ID and secret match
157
- * Returns true if valid, false otherwise
158
- */
159
- export async function validateCredentials(peerId: string, encryptedSecret: string, secretKey: string): Promise<boolean> {
160
- try {
161
- const decryptedPeerId = await decryptPeerId(encryptedSecret, secretKey);
162
- return decryptedPeerId === peerId;
163
- } catch {
164
- return false;
81
+ const timestampCheck = validateTimestamp(timestamp);
82
+ if (!timestampCheck.valid) {
83
+ return timestampCheck;
165
84
  }
85
+
86
+ return { valid: true };
166
87
  }
167
88
 
168
89
  // ===== Username and Ed25519 Signature Utilities =====
@@ -192,31 +113,32 @@ export function validateUsername(username: string): { valid: boolean; error?: st
192
113
  }
193
114
 
194
115
  /**
195
- * Validates service FQN format (service-name@version)
196
- * Service name: reverse domain notation (com.example.service)
116
+ * Validates service FQN format (service:version@username or service:version)
117
+ * Service name: lowercase alphanumeric with dots/dashes (e.g., chat, file-share, com.example.chat)
197
118
  * Version: semantic versioning (1.0.0, 2.1.3-beta, etc.)
119
+ * Username: optional, lowercase alphanumeric with dashes
198
120
  */
199
121
  export function validateServiceFqn(fqn: string): { valid: boolean; error?: string } {
200
122
  if (typeof fqn !== 'string') {
201
123
  return { valid: false, error: 'Service FQN must be a string' };
202
124
  }
203
125
 
204
- // Split into service name and version
205
- const parts = fqn.split('@');
206
- if (parts.length !== 2) {
207
- return { valid: false, error: 'Service FQN must be in format: service-name@version' };
126
+ // Parse the FQN
127
+ const parsed = parseServiceFqn(fqn);
128
+ if (!parsed) {
129
+ return { valid: false, error: 'Service FQN must be in format: service:version[@username]' };
208
130
  }
209
131
 
210
- const [serviceName, version] = parts;
132
+ const { serviceName, version, username } = parsed;
211
133
 
212
- // Validate service name (reverse domain notation)
213
- const serviceNameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
134
+ // Validate service name (alphanumeric with dots/dashes)
135
+ const serviceNameRegex = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
214
136
  if (!serviceNameRegex.test(serviceName)) {
215
- return { valid: false, error: 'Service name must be reverse domain notation (e.g., com.example.service)' };
137
+ return { valid: false, error: 'Service name must be lowercase alphanumeric with optional dots/dashes' };
216
138
  }
217
139
 
218
- if (serviceName.length < 3 || serviceName.length > 128) {
219
- return { valid: false, error: 'Service name must be 3-128 characters' };
140
+ if (serviceName.length < 1 || serviceName.length > 128) {
141
+ return { valid: false, error: 'Service name must be 1-128 characters' };
220
142
  }
221
143
 
222
144
  // Validate version (semantic versioning)
@@ -225,6 +147,14 @@ export function validateServiceFqn(fqn: string): { valid: boolean; error?: strin
225
147
  return { valid: false, error: 'Version must be semantic versioning (e.g., 1.0.0, 2.1.3-beta)' };
226
148
  }
227
149
 
150
+ // Validate username if present
151
+ if (username) {
152
+ const usernameCheck = validateUsername(username);
153
+ if (!usernameCheck.valid) {
154
+ return usernameCheck;
155
+ }
156
+ }
157
+
228
158
  return { valid: true };
229
159
  }
230
160
 
@@ -270,15 +200,41 @@ export function isVersionCompatible(requested: string, available: string): boole
270
200
  }
271
201
 
272
202
  /**
273
- * Parse service FQN into service name and version
203
+ * Parse service FQN into components
204
+ * Formats supported:
205
+ * - service:version@username (e.g., "chat:1.0.0@alice")
206
+ * - service:version (e.g., "chat:1.0.0") for discovery
274
207
  */
275
- export function parseServiceFqn(fqn: string): { serviceName: string; version: string } | null {
276
- const parts = fqn.split('@');
277
- if (parts.length !== 2) return null;
208
+ export function parseServiceFqn(fqn: string): { serviceName: string; version: string; username: string | null } | null {
209
+ if (!fqn || typeof fqn !== 'string') return null;
210
+
211
+ // Check if username is present
212
+ const atIndex = fqn.lastIndexOf('@');
213
+ let serviceVersion: string;
214
+ let username: string | null = null;
215
+
216
+ if (atIndex > 0) {
217
+ // Format: service:version@username
218
+ serviceVersion = fqn.substring(0, atIndex);
219
+ username = fqn.substring(atIndex + 1);
220
+ } else {
221
+ // Format: service:version (no username)
222
+ serviceVersion = fqn;
223
+ }
224
+
225
+ // Split service:version
226
+ const colonIndex = serviceVersion.indexOf(':');
227
+ if (colonIndex <= 0) return null; // No colon or colon at start
228
+
229
+ const serviceName = serviceVersion.substring(0, colonIndex);
230
+ const version = serviceVersion.substring(colonIndex + 1);
231
+
232
+ if (!serviceName || !version) return null;
278
233
 
279
234
  return {
280
- serviceName: parts[0],
281
- version: parts[1],
235
+ serviceName,
236
+ version,
237
+ username,
282
238
  };
283
239
  }
284
240
 
@@ -390,16 +346,24 @@ export async function validateServicePublish(
390
346
  }
391
347
 
392
348
  // Parse message format: "publish:{username}:{serviceFqn}:{timestamp}"
349
+ // Note: serviceFqn can contain colons (e.g., "chat:2.0.0@user"), so we need careful parsing
393
350
  const parts = message.split(':');
394
- if (parts.length !== 4 || parts[0] !== 'publish' || parts[1] !== username || parts[2] !== serviceFqn) {
351
+ if (parts.length < 4 || parts[0] !== 'publish' || parts[1] !== username) {
395
352
  return { valid: false, error: 'Invalid message format (expected: publish:{username}:{serviceFqn}:{timestamp})' };
396
353
  }
397
354
 
398
- const timestamp = parseInt(parts[3], 10);
355
+ // The timestamp is the last part
356
+ const timestamp = parseInt(parts[parts.length - 1], 10);
399
357
  if (isNaN(timestamp)) {
400
358
  return { valid: false, error: 'Invalid timestamp in message' };
401
359
  }
402
360
 
361
+ // The serviceFqn is everything between username and timestamp
362
+ const extractedServiceFqn = parts.slice(2, parts.length - 1).join(':');
363
+ if (extractedServiceFqn !== serviceFqn) {
364
+ return { valid: false, error: `Service FQN mismatch (expected: ${serviceFqn}, got: ${extractedServiceFqn})` };
365
+ }
366
+
403
367
  // Validate timestamp
404
368
  const timestampCheck = validateTimestamp(timestamp);
405
369
  if (!timestampCheck.valid) {