@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/ADVANCED.md +502 -0
- package/README.md +139 -251
- package/dist/index.js +715 -770
- package/dist/index.js.map +4 -4
- package/migrations/0006_service_offer_refactor.sql +40 -0
- package/migrations/0007_simplify_schema.sql +54 -0
- package/migrations/0008_peer_id_to_username.sql +67 -0
- package/migrations/fresh_schema.sql +81 -0
- package/package.json +2 -1
- package/src/app.ts +38 -677
- package/src/config.ts +0 -13
- package/src/crypto.ts +98 -133
- package/src/rpc.ts +725 -0
- package/src/storage/d1.ts +169 -182
- package/src/storage/sqlite.ts +142 -168
- package/src/storage/types.ts +51 -95
- package/src/worker.ts +0 -6
- package/wrangler.toml +3 -3
- package/src/middleware/auth.ts +0 -51
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
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
*
|
|
74
|
-
*
|
|
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
|
|
77
|
-
|
|
58
|
+
export function validateAuthMessage(
|
|
59
|
+
expectedUsername: string,
|
|
60
|
+
message: string
|
|
61
|
+
): { valid: boolean; error?: string } {
|
|
62
|
+
const parts = message.split(':');
|
|
78
63
|
|
|
79
|
-
if (
|
|
80
|
-
|
|
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
|
-
//
|
|
84
|
-
const
|
|
85
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
|
196
|
-
* Service name:
|
|
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
|
-
//
|
|
205
|
-
const
|
|
206
|
-
if (
|
|
207
|
-
return { valid: false, error: 'Service FQN must be in format: service
|
|
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
|
|
133
|
+
const { serviceName, version, username } = parsed;
|
|
211
134
|
|
|
212
|
-
// Validate service name (
|
|
213
|
-
const serviceNameRegex = /^[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
|
|
138
|
+
return { valid: false, error: 'Service name must be lowercase alphanumeric with optional dots/dashes' };
|
|
216
139
|
}
|
|
217
140
|
|
|
218
|
-
if (serviceName.length <
|
|
219
|
-
return { valid: false, error: 'Service name must be
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
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
|
|
281
|
-
version
|
|
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
|
|
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
|
-
|
|
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) {
|