@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/ADVANCED.md +502 -0
- package/README.md +136 -282
- package/dist/index.js +694 -733
- 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 -591
- package/src/config.ts +0 -13
- package/src/crypto.ts +103 -139
- 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,10 +1,11 @@
|
|
|
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';
|
|
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
|
|
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
|
|
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
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
return Uint8Array.from(binString, (char) => char.codePointAt(0)!);
|
|
49
|
+
return new Uint8Array(Buffer.from(base64, 'base64'));
|
|
70
50
|
}
|
|
71
51
|
|
|
72
52
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
|
77
|
-
|
|
57
|
+
export function validateAuthMessage(
|
|
58
|
+
expectedUsername: string,
|
|
59
|
+
message: string
|
|
60
|
+
): { valid: boolean; error?: string } {
|
|
61
|
+
const parts = message.split(':');
|
|
78
62
|
|
|
79
|
-
if (
|
|
80
|
-
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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');
|
|
76
|
+
// Validate timestamp
|
|
77
|
+
if (isNaN(timestamp)) {
|
|
78
|
+
return { valid: false, error: 'Invalid timestamp in message' };
|
|
152
79
|
}
|
|
153
|
-
}
|
|
154
80
|
|
|
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;
|
|
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
|
|
196
|
-
* Service name:
|
|
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
|
-
//
|
|
205
|
-
const
|
|
206
|
-
if (
|
|
207
|
-
return { valid: false, error: 'Service FQN must be in format: service
|
|
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
|
|
132
|
+
const { serviceName, version, username } = parsed;
|
|
211
133
|
|
|
212
|
-
// Validate service name (
|
|
213
|
-
const serviceNameRegex = /^[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
|
|
137
|
+
return { valid: false, error: 'Service name must be lowercase alphanumeric with optional dots/dashes' };
|
|
216
138
|
}
|
|
217
139
|
|
|
218
|
-
if (serviceName.length <
|
|
219
|
-
return { valid: false, error: 'Service name must be
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
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
|
|
281
|
-
version
|
|
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
|
|
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
|
-
|
|
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) {
|