@xtr-dev/rondevu-server 0.3.0 → 0.5.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.
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,7 +1,7 @@
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';
@@ -12,10 +12,6 @@ ed25519.hashes.sha512Async = async (message: Uint8Array) => {
12
12
  return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource));
13
13
  };
14
14
 
15
- const ALGORITHM = 'AES-GCM';
16
- const IV_LENGTH = 12; // 96 bits for GCM
17
- const KEY_LENGTH = 32; // 256 bits
18
-
19
15
  // Username validation
20
16
  const USERNAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
21
17
  const USERNAME_MIN_LENGTH = 3;
@@ -25,30 +21,15 @@ const USERNAME_MAX_LENGTH = 32;
25
21
  const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;
26
22
 
27
23
  /**
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
24
+ * Generates an anonymous username for users who don't want to claim one
25
+ * Format: anon-{timestamp}-{random}
26
+ * This reduces collision probability to near-zero
45
27
  */
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;
28
+ export function generateAnonymousUsername(): string {
29
+ const timestamp = Date.now().toString(36);
30
+ const random = crypto.getRandomValues(new Uint8Array(3));
31
+ const hex = Array.from(random).map(b => b.toString(16).padStart(2, '0')).join('');
32
+ return `anon-${timestamp}-${hex}`;
52
33
  }
53
34
 
54
35
  /**
@@ -70,99 +51,40 @@ function base64ToBytes(base64: string): Uint8Array {
70
51
  }
71
52
 
72
53
  /**
73
- * Encrypts a peer ID using the server secret key
74
- * Returns base64-encoded encrypted data (IV + ciphertext)
54
+ * Validates a generic auth message format
55
+ * Expected format: action:username:params:timestamp
56
+ * Validates that the message contains the expected username and has a valid timestamp
75
57
  */
76
- export async function encryptPeerId(peerId: string, secretKeyHex: string): Promise<string> {
77
- const keyBytes = hexToBytes(secretKeyHex);
58
+ export function validateAuthMessage(
59
+ expectedUsername: string,
60
+ message: string
61
+ ): { valid: boolean; error?: string } {
62
+ const parts = message.split(':');
78
63
 
79
- if (keyBytes.length !== KEY_LENGTH) {
80
- throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`);
64
+ if (parts.length < 3) {
65
+ return { valid: false, error: 'Invalid message format: must have at least action:username:timestamp' };
81
66
  }
82
67
 
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
- }
68
+ // Extract username (second part) and timestamp (last part)
69
+ const messageUsername = parts[1];
70
+ const timestamp = parseInt(parts[parts.length - 1], 10);
112
71
 
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
- }
72
+ // Validate username matches
73
+ if (messageUsername !== expectedUsername) {
74
+ return { valid: false, error: 'Username in message does not match authenticated username' };
75
+ }
124
76
 
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');
77
+ // Validate timestamp
78
+ if (isNaN(timestamp)) {
79
+ return { valid: false, error: 'Invalid timestamp in message' };
152
80
  }
153
- }
154
81
 
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;
82
+ const timestampCheck = validateTimestamp(timestamp);
83
+ if (!timestampCheck.valid) {
84
+ return timestampCheck;
165
85
  }
86
+
87
+ return { valid: true };
166
88
  }
167
89
 
168
90
  // ===== Username and Ed25519 Signature Utilities =====
@@ -192,31 +114,32 @@ export function validateUsername(username: string): { valid: boolean; error?: st
192
114
  }
193
115
 
194
116
  /**
195
- * Validates service FQN format (service-name@version)
196
- * Service name: reverse domain notation (com.example.service)
117
+ * Validates service FQN format (service:version@username or service:version)
118
+ * Service name: lowercase alphanumeric with dots/dashes (e.g., chat, file-share, com.example.chat)
197
119
  * Version: semantic versioning (1.0.0, 2.1.3-beta, etc.)
120
+ * Username: optional, lowercase alphanumeric with dashes
198
121
  */
199
122
  export function validateServiceFqn(fqn: string): { valid: boolean; error?: string } {
200
123
  if (typeof fqn !== 'string') {
201
124
  return { valid: false, error: 'Service FQN must be a string' };
202
125
  }
203
126
 
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' };
127
+ // Parse the FQN
128
+ const parsed = parseServiceFqn(fqn);
129
+ if (!parsed) {
130
+ return { valid: false, error: 'Service FQN must be in format: service:version[@username]' };
208
131
  }
209
132
 
210
- const [serviceName, version] = parts;
133
+ const { serviceName, version, username } = parsed;
211
134
 
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])?)+$/;
135
+ // Validate service name (alphanumeric with dots/dashes)
136
+ const serviceNameRegex = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
214
137
  if (!serviceNameRegex.test(serviceName)) {
215
- return { valid: false, error: 'Service name must be reverse domain notation (e.g., com.example.service)' };
138
+ return { valid: false, error: 'Service name must be lowercase alphanumeric with optional dots/dashes' };
216
139
  }
217
140
 
218
- if (serviceName.length < 3 || serviceName.length > 128) {
219
- return { valid: false, error: 'Service name must be 3-128 characters' };
141
+ if (serviceName.length < 1 || serviceName.length > 128) {
142
+ return { valid: false, error: 'Service name must be 1-128 characters' };
220
143
  }
221
144
 
222
145
  // Validate version (semantic versioning)
@@ -225,6 +148,14 @@ export function validateServiceFqn(fqn: string): { valid: boolean; error?: strin
225
148
  return { valid: false, error: 'Version must be semantic versioning (e.g., 1.0.0, 2.1.3-beta)' };
226
149
  }
227
150
 
151
+ // Validate username if present
152
+ if (username) {
153
+ const usernameCheck = validateUsername(username);
154
+ if (!usernameCheck.valid) {
155
+ return usernameCheck;
156
+ }
157
+ }
158
+
228
159
  return { valid: true };
229
160
  }
230
161
 
@@ -270,15 +201,41 @@ export function isVersionCompatible(requested: string, available: string): boole
270
201
  }
271
202
 
272
203
  /**
273
- * Parse service FQN into service name and version
204
+ * Parse service FQN into components
205
+ * Formats supported:
206
+ * - service:version@username (e.g., "chat:1.0.0@alice")
207
+ * - service:version (e.g., "chat:1.0.0") for discovery
274
208
  */
275
- export function parseServiceFqn(fqn: string): { serviceName: string; version: string } | null {
276
- const parts = fqn.split('@');
277
- if (parts.length !== 2) return null;
209
+ export function parseServiceFqn(fqn: string): { serviceName: string; version: string; username: string | null } | null {
210
+ if (!fqn || typeof fqn !== 'string') return null;
211
+
212
+ // Check if username is present
213
+ const atIndex = fqn.lastIndexOf('@');
214
+ let serviceVersion: string;
215
+ let username: string | null = null;
216
+
217
+ if (atIndex > 0) {
218
+ // Format: service:version@username
219
+ serviceVersion = fqn.substring(0, atIndex);
220
+ username = fqn.substring(atIndex + 1);
221
+ } else {
222
+ // Format: service:version (no username)
223
+ serviceVersion = fqn;
224
+ }
225
+
226
+ // Split service:version
227
+ const colonIndex = serviceVersion.indexOf(':');
228
+ if (colonIndex <= 0) return null; // No colon or colon at start
229
+
230
+ const serviceName = serviceVersion.substring(0, colonIndex);
231
+ const version = serviceVersion.substring(colonIndex + 1);
232
+
233
+ if (!serviceName || !version) return null;
278
234
 
279
235
  return {
280
- serviceName: parts[0],
281
- version: parts[1],
236
+ serviceName,
237
+ version,
238
+ username,
282
239
  };
283
240
  }
284
241
 
@@ -390,16 +347,24 @@ export async function validateServicePublish(
390
347
  }
391
348
 
392
349
  // Parse message format: "publish:{username}:{serviceFqn}:{timestamp}"
350
+ // Note: serviceFqn can contain colons (e.g., "chat:2.0.0@user"), so we need careful parsing
393
351
  const parts = message.split(':');
394
- if (parts.length !== 4 || parts[0] !== 'publish' || parts[1] !== username || parts[2] !== serviceFqn) {
352
+ if (parts.length < 4 || parts[0] !== 'publish' || parts[1] !== username) {
395
353
  return { valid: false, error: 'Invalid message format (expected: publish:{username}:{serviceFqn}:{timestamp})' };
396
354
  }
397
355
 
398
- const timestamp = parseInt(parts[3], 10);
356
+ // The timestamp is the last part
357
+ const timestamp = parseInt(parts[parts.length - 1], 10);
399
358
  if (isNaN(timestamp)) {
400
359
  return { valid: false, error: 'Invalid timestamp in message' };
401
360
  }
402
361
 
362
+ // The serviceFqn is everything between username and timestamp
363
+ const extractedServiceFqn = parts.slice(2, parts.length - 1).join(':');
364
+ if (extractedServiceFqn !== serviceFqn) {
365
+ return { valid: false, error: `Service FQN mismatch (expected: ${serviceFqn}, got: ${extractedServiceFqn})` };
366
+ }
367
+
403
368
  // Validate timestamp
404
369
  const timestampCheck = validateTimestamp(timestamp);
405
370
  if (!timestampCheck.valid) {