@xtr-dev/rondevu-server 0.5.12 → 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/crypto.ts CHANGED
@@ -1,86 +1,26 @@
1
1
  /**
2
- * Crypto utilities for credential generation and validation
3
- * Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
2
+ * Crypto utilities for Ed25519 signature verification and validation
3
+ * Uses @noble/ed25519 for Ed25519 operations and Web Crypto API for SHA-512
4
4
  */
5
5
 
6
6
  import { Buffer } from 'node:buffer';
7
+ import * as ed from '@noble/ed25519';
7
8
 
8
- // Username validation
9
- // Rules: 4-32 chars, lowercase alphanumeric + dashes + periods, must start/end with alphanumeric
10
- const USERNAME_REGEX = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$/;
11
- const USERNAME_MIN_LENGTH = 4;
12
- const USERNAME_MAX_LENGTH = 32;
13
-
14
- /**
15
- * Generates a random credential name
16
- * Format: {adjective}-{noun}-{random}
17
- * Example: "brave-tiger-7a3f2b1c9d8e", "quick-river-9b2e4c1a5f3d"
18
- */
19
- export function generateCredentialName(): string {
20
- const adjectives = [
21
- 'brave', 'calm', 'eager', 'fancy', 'gentle', 'happy', 'jolly', 'kind',
22
- 'lively', 'merry', 'nice', 'proud', 'quiet', 'swift', 'witty', 'young',
23
- 'bright', 'clever', 'daring', 'fair', 'grand', 'humble', 'noble', 'quick'
24
- ];
25
-
26
- const nouns = [
27
- 'tiger', 'eagle', 'river', 'mountain', 'ocean', 'forest', 'desert', 'valley',
28
- 'thunder', 'wind', 'fire', 'stone', 'cloud', 'star', 'moon', 'sun',
29
- 'wolf', 'bear', 'hawk', 'lion', 'fox', 'deer', 'owl', 'swan'
30
- ];
31
-
32
- const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
33
- const noun = nouns[Math.floor(Math.random() * nouns.length)];
34
-
35
- // Generate 16-character hex suffix for uniqueness (8 bytes = 2^64 combinations)
36
- // With 576 adjective-noun pairs, total space: 576 × 2^64 ≈ 1.06 × 10^22 names
37
- // Birthday paradox collision at ~4.3 billion credentials (extremely safe for large deployments)
38
- // Increased from 6 bytes to 8 bytes for maximum collision resistance
39
- const random = crypto.getRandomValues(new Uint8Array(8));
40
- const hex = Array.from(random).map(b => b.toString(16).padStart(2, '0')).join('');
41
-
42
- return `${adjective}-${noun}-${hex}`;
43
- }
44
-
45
- /**
46
- * Generates a random secret (API key style)
47
- * Format: 64-character hex string (256 bits of entropy)
48
- * 256 bits provides optimal security for HMAC-SHA256 and future-proofs against brute force
49
- */
50
- export function generateSecret(): string {
51
- const bytes = crypto.getRandomValues(new Uint8Array(32)); // 32 bytes = 256 bits
52
- const secret = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
53
-
54
- // Validation: Ensure output is exactly 64 characters and valid hex
55
- if (secret.length !== 64) {
56
- throw new Error('Secret generation failed: invalid length');
57
- }
58
-
59
- // Validate all characters are valid hex digits (0-9, a-f)
60
- for (let i = 0; i < secret.length; i++) {
61
- const c = secret[i];
62
- if ((c < '0' || c > '9') && (c < 'a' || c > 'f')) {
63
- throw new Error(`Secret generation failed: invalid hex character at position ${i}: '${c}'`);
64
- }
65
- }
66
-
67
- return secret;
68
- }
69
-
70
- // ===== Secret Encryption/Decryption (Database Storage) =====
9
+ // Configure @noble/ed25519 to use Web Crypto API's SHA-512
10
+ // Required for both Node.js and Cloudflare Workers compatibility
11
+ ed.hashes.sha512Async = async (message: Uint8Array): Promise<Uint8Array> => {
12
+ const hashBuffer = await crypto.subtle.digest('SHA-512', message);
13
+ return new Uint8Array(hashBuffer);
14
+ };
71
15
 
72
16
  /**
73
17
  * Convert hex string to byte array with validation
74
- * @param hex Hex string (must be even length)
75
- * @returns Uint8Array of bytes
76
18
  */
77
19
  function hexToBytes(hex: string): Uint8Array {
78
20
  if (hex.length % 2 !== 0) {
79
21
  throw new Error('Hex string must have even length');
80
22
  }
81
23
 
82
- // Pre-validate that all characters are valid hex digits (0-9, a-f, A-F)
83
- // This prevents parseInt from silently truncating invalid input like "0z" -> 0
84
24
  for (let i = 0; i < hex.length; i++) {
85
25
  const c = hex[i].toLowerCase();
86
26
  if ((c < '0' || c > '9') && (c < 'a' || c > 'f')) {
@@ -103,192 +43,9 @@ function hexToBytes(hex: string): Uint8Array {
103
43
  }
104
44
 
105
45
  /**
106
- * Encrypt a secret using AES-256-GCM with master key
107
- * Format: iv:ciphertext (all hex-encoded, auth tag included in ciphertext)
108
- *
109
- * @param secret The plaintext secret to encrypt
110
- * @param masterKeyHex The master encryption key (64-char hex = 32 bytes)
111
- * @returns Encrypted secret in format "iv:ciphertext"
112
- */
113
- export async function encryptSecret(secret: string, masterKeyHex: string): Promise<string> {
114
- // Validate master key
115
- if (!masterKeyHex || masterKeyHex.length !== 64) {
116
- throw new Error('Master key must be 64-character hex string (32 bytes)');
117
- }
118
-
119
- // Convert master key from hex to bytes (with validation)
120
- const keyBytes = hexToBytes(masterKeyHex);
121
-
122
- // Import master key
123
- const key = await crypto.subtle.importKey(
124
- 'raw',
125
- keyBytes,
126
- { name: 'AES-GCM', length: 256 },
127
- false,
128
- ['encrypt']
129
- );
130
-
131
- // Generate random IV (12 bytes for AES-GCM)
132
- const iv = crypto.getRandomValues(new Uint8Array(12));
133
-
134
- // Encrypt secret
135
- const encoder = new TextEncoder();
136
- const secretBytes = encoder.encode(secret);
137
-
138
- // AES-GCM returns ciphertext with auth tag already appended (no manual splitting needed)
139
- const ciphertext = await crypto.subtle.encrypt(
140
- { name: 'AES-GCM', iv, tagLength: 128 },
141
- key,
142
- secretBytes
143
- );
144
-
145
- // Convert to hex: iv:ciphertext (ciphertext includes 16-byte auth tag at end)
146
- const ivHex = Array.from(iv).map(b => b.toString(16).padStart(2, '0')).join('');
147
- const ciphertextHex = Array.from(new Uint8Array(ciphertext))
148
- .map(b => b.toString(16).padStart(2, '0'))
149
- .join('');
150
-
151
- return `${ivHex}:${ciphertextHex}`;
152
- }
153
-
154
- /**
155
- * Decrypt a secret using AES-256-GCM with master key
156
- *
157
- * @param encryptedSecret Encrypted secret in format "iv:ciphertext" (ciphertext includes auth tag)
158
- * @param masterKeyHex The master encryption key (64-char hex = 32 bytes)
159
- * @returns Decrypted plaintext secret
160
- */
161
- export async function decryptSecret(encryptedSecret: string, masterKeyHex: string): Promise<string> {
162
- // Validate master key
163
- if (!masterKeyHex || masterKeyHex.length !== 64) {
164
- throw new Error('Master key must be 64-character hex string (32 bytes)');
165
- }
166
-
167
- // Parse encrypted format: iv:ciphertext
168
- const parts = encryptedSecret.split(':');
169
- if (parts.length !== 2) {
170
- throw new Error('Invalid encrypted secret format (expected iv:ciphertext)');
171
- }
172
-
173
- const [ivHex, ciphertextHex] = parts;
174
-
175
- // Validate IV length (must be 12 bytes = 24 hex characters for AES-GCM)
176
- if (ivHex.length !== 24) {
177
- throw new Error('Invalid IV length (expected 12 bytes = 24 hex characters)');
178
- }
179
-
180
- // Validate ciphertext length (must include at least 16-byte auth tag)
181
- // Minimum: 16 bytes for auth tag = 32 hex characters
182
- if (ciphertextHex.length < 32) {
183
- throw new Error('Invalid ciphertext length (must include 16-byte auth tag)');
184
- }
185
-
186
- // Convert from hex to bytes (with validation)
187
- const iv = hexToBytes(ivHex);
188
- const ciphertext = hexToBytes(ciphertextHex);
189
-
190
- // Convert master key from hex to bytes (with validation)
191
- const keyBytes = hexToBytes(masterKeyHex);
192
-
193
- // Import master key
194
- const key = await crypto.subtle.importKey(
195
- 'raw',
196
- keyBytes,
197
- { name: 'AES-GCM', length: 256 },
198
- false,
199
- ['decrypt']
200
- );
201
-
202
- // Decrypt (ciphertext already includes 16-byte auth tag at end)
203
- const decryptedBytes = await crypto.subtle.decrypt(
204
- { name: 'AES-GCM', iv, tagLength: 128 },
205
- key,
206
- ciphertext
207
- );
208
-
209
- // Convert to string
210
- const decoder = new TextDecoder();
211
- return decoder.decode(decryptedBytes);
212
- }
213
-
214
- // ===== HMAC Signature Generation and Verification =====
215
-
216
- /**
217
- * Generate HMAC-SHA256 signature for request authentication
218
- * Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
219
- *
220
- * @param secret The credential secret (hex string)
221
- * @param message The message to sign (typically: timestamp + method + params)
222
- * @returns Promise<string> Base64-encoded signature
223
- */
224
- export async function generateSignature(secret: string, message: string): Promise<string> {
225
- // Convert secret from hex to bytes (with validation)
226
- const secretBytes = hexToBytes(secret);
227
-
228
- // Import secret as HMAC key
229
- const key = await crypto.subtle.importKey(
230
- 'raw',
231
- secretBytes,
232
- { name: 'HMAC', hash: 'SHA-256' },
233
- false,
234
- ['sign']
235
- );
236
-
237
- // Convert message to bytes
238
- const encoder = new TextEncoder();
239
- const messageBytes = encoder.encode(message);
240
-
241
- // Generate HMAC signature
242
- const signatureBytes = await crypto.subtle.sign('HMAC', key, messageBytes);
243
-
244
- // Convert to base64
245
- return Buffer.from(signatureBytes).toString('base64');
246
- }
247
-
248
- /**
249
- * Verify HMAC-SHA256 signature for request authentication
250
- * Uses crypto.subtle.verify() for constant-time comparison
251
- *
252
- * @param secret The credential secret (hex string)
253
- * @param message The message that was signed
254
- * @param signature The signature to verify (base64)
255
- * @returns Promise<boolean> True if signature is valid
256
- */
257
- export async function verifySignature(secret: string, message: string, signature: string): Promise<boolean> {
258
- try {
259
- // Convert secret from hex to bytes (with validation)
260
- const secretBytes = hexToBytes(secret);
261
-
262
- // Import secret as HMAC key for verification
263
- const key = await crypto.subtle.importKey(
264
- 'raw',
265
- secretBytes,
266
- { name: 'HMAC', hash: 'SHA-256' },
267
- false,
268
- ['verify']
269
- );
270
-
271
- // Convert message to bytes
272
- const encoder = new TextEncoder();
273
- const messageBytes = encoder.encode(message);
274
-
275
- // Convert signature from base64 to bytes
276
- const signatureBytes = Buffer.from(signature, 'base64');
277
-
278
- // Use Web Crypto API's verify() for constant-time comparison
279
- // This is cryptographically secure and resistant to timing attacks
280
- return await crypto.subtle.verify('HMAC', key, signatureBytes, messageBytes);
281
- } catch (error) {
282
- // Log error for debugging (helps identify implementation bugs)
283
- console.error('Signature verification error:', error);
284
- return false;
285
- }
286
- }
287
-
288
- /**
289
- * Canonical JSON serialization with sorted keys
290
- * Ensures deterministic output regardless of property insertion order
291
- * Must match client's canonicalJSON implementation exactly
46
+ * Canonical JSON serialization with sorted keys.
47
+ * Ensures deterministic output regardless of property insertion order.
48
+ * Must match client's canonicalJSON implementation exactly.
292
49
  */
293
50
  function canonicalJSON(obj: any, depth: number = 0): string {
294
51
  const MAX_DEPTH = 100;
@@ -318,46 +75,29 @@ function canonicalJSON(obj: any, depth: number = 0): string {
318
75
  }
319
76
 
320
77
  /**
321
- * Build the message string for signing
78
+ * Build the message string for signing.
322
79
  * Format: timestamp:nonce:method:canonicalJSON(params || {})
323
- * Uses colons as delimiters to prevent collision attacks
324
- * Includes nonce to prevent signature reuse within timestamp window
325
- * Uses canonical JSON (sorted keys) for deterministic serialization
326
- *
327
- * @param timestamp Unix timestamp in milliseconds
328
- * @param nonce Cryptographic nonce (UUID v4) to prevent replay attacks
329
- * @param method RPC method name
330
- * @param params RPC method parameters (optional)
331
- * @returns String to be signed
332
80
  */
333
81
  export function buildSignatureMessage(timestamp: number, nonce: string, method: string, params?: any): string {
334
- // Validate nonce is UUID v4 format to prevent colon injection attacks
335
- // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx (8-4-4-4-12 hex digits with dashes)
336
- // Use simple format checks instead of regex to avoid any timing or ReDoS concerns
337
-
338
- // Check total length (36 characters for UUID v4)
82
+ // Validate nonce is UUID v4 format
339
83
  if (nonce.length !== 36) {
340
84
  throw new Error('Nonce must be a valid UUID v4 (use crypto.randomUUID())');
341
85
  }
342
86
 
343
- // Check dash positions (indices 8, 13, 18, 23)
344
87
  if (nonce[8] !== '-' || nonce[13] !== '-' || nonce[18] !== '-' || nonce[23] !== '-') {
345
88
  throw new Error('Nonce must be a valid UUID v4 (use crypto.randomUUID())');
346
89
  }
347
90
 
348
- // Check version (character at index 14 must be '4')
349
91
  if (nonce[14] !== '4') {
350
92
  throw new Error('Nonce must be a valid UUID v4 (use crypto.randomUUID())');
351
93
  }
352
94
 
353
- // Check variant (character at index 19 must be 8, 9, a, or b)
354
95
  const variant = nonce[19].toLowerCase();
355
96
  if (variant !== '8' && variant !== '9' && variant !== 'a' && variant !== 'b') {
356
97
  throw new Error('Nonce must be a valid UUID v4 (use crypto.randomUUID())');
357
98
  }
358
99
 
359
- // Validate all other characters are hex digits (0-9, a-f)
360
- const hexChars = nonce.replace(/-/g, ''); // Remove dashes
100
+ const hexChars = nonce.replace(/-/g, '');
361
101
  for (let i = 0; i < hexChars.length; i++) {
362
102
  const c = hexChars[i].toLowerCase();
363
103
  if ((c < '0' || c > '9') && (c < 'a' || c > 'f')) {
@@ -365,53 +105,83 @@ export function buildSignatureMessage(timestamp: number, nonce: string, method:
365
105
  }
366
106
  }
367
107
 
368
- // Use canonical JSON (sorted keys) to match client's signature
369
108
  const paramsStr = canonicalJSON(params || {});
370
- // Use delimiters to prevent collision: timestamp=12,method="34" vs timestamp=1,method="234"
371
- // Include nonce to make each request unique (prevents signature reuse in same millisecond)
372
109
  return `${timestamp}:${nonce}:${method}:${paramsStr}`;
373
110
  }
374
111
 
375
- // ===== Username Validation =====
112
+ // ===== Ed25519 Public Key Identity =====
113
+
114
+ const ED25519_PUBLIC_KEY_LENGTH = 32; // 32 bytes = 64 hex chars
115
+ const ED25519_SIGNATURE_LENGTH = 64; // 64 bytes
376
116
 
377
117
  /**
378
- * Validates username format
379
- * Rules: 4-32 chars, lowercase alphanumeric + dashes + periods, must start/end with alphanumeric
118
+ * Validates an Ed25519 public key format.
119
+ * @param publicKey 64-character lowercase hex string (32 bytes)
380
120
  */
381
- export function validateUsername(username: string): { valid: boolean; error?: string } {
382
- if (typeof username !== 'string') {
383
- return { valid: false, error: 'Username must be a string' };
384
- }
385
-
386
- if (username.length < USERNAME_MIN_LENGTH) {
387
- return { valid: false, error: `Username must be at least ${USERNAME_MIN_LENGTH} characters` };
121
+ export function validatePublicKey(publicKey: string): { valid: boolean; error?: string } {
122
+ if (typeof publicKey !== 'string') {
123
+ return { valid: false, error: 'Public key must be a string' };
388
124
  }
389
125
 
390
- if (username.length > USERNAME_MAX_LENGTH) {
391
- return { valid: false, error: `Username must be at most ${USERNAME_MAX_LENGTH} characters` };
126
+ if (publicKey.length !== ED25519_PUBLIC_KEY_LENGTH * 2) {
127
+ return { valid: false, error: `Public key must be ${ED25519_PUBLIC_KEY_LENGTH * 2} hex characters (${ED25519_PUBLIC_KEY_LENGTH} bytes)` };
392
128
  }
393
129
 
394
- if (!USERNAME_REGEX.test(username)) {
395
- return { valid: false, error: 'Username must be lowercase alphanumeric with optional dashes/periods, and start/end with alphanumeric' };
130
+ for (let i = 0; i < publicKey.length; i++) {
131
+ const c = publicKey[i];
132
+ if ((c < '0' || c > '9') && (c < 'a' || c > 'f')) {
133
+ return { valid: false, error: `Invalid hex character at position ${i}: '${c}' (use lowercase hex)` };
134
+ }
396
135
  }
397
136
 
398
137
  return { valid: true };
399
138
  }
400
139
 
140
+ /**
141
+ * Verify an Ed25519 signature.
142
+ * @param publicKey Signer's public key (64-char hex)
143
+ * @param message Message that was signed
144
+ * @param signature Signature to verify (base64 encoded)
145
+ */
146
+ export async function verifyEd25519Signature(
147
+ publicKey: string,
148
+ message: string,
149
+ signature: string
150
+ ): Promise<boolean> {
151
+ try {
152
+ const pkValidation = validatePublicKey(publicKey);
153
+ if (!pkValidation.valid) {
154
+ console.error('Ed25519 verification error: invalid public key:', pkValidation.error);
155
+ return false;
156
+ }
157
+
158
+ const publicKeyBytes = hexToBytes(publicKey);
159
+ const encoder = new TextEncoder();
160
+ const messageBytes = encoder.encode(message);
161
+ const signatureBytes = Buffer.from(signature, 'base64');
162
+
163
+ if (signatureBytes.length !== ED25519_SIGNATURE_LENGTH) {
164
+ console.error(`Ed25519 verification error: signature length ${signatureBytes.length}, expected ${ED25519_SIGNATURE_LENGTH}`);
165
+ return false;
166
+ }
167
+
168
+ return await ed.verifyAsync(signatureBytes, messageBytes, publicKeyBytes);
169
+ } catch (error) {
170
+ console.error('Ed25519 verification error:', error);
171
+ return false;
172
+ }
173
+ }
174
+
401
175
  // ===== Tag Validation =====
402
176
 
403
- // Tag validation constants
404
177
  const TAG_MIN_LENGTH = 1;
405
178
  const TAG_MAX_LENGTH = 64;
406
179
  const TAG_REGEX = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
407
180
 
408
181
  /**
409
- * Validates a single tag format
410
- * Rules: 1-64 chars, lowercase alphanumeric with optional dots/dashes
411
- * Must start and end with alphanumeric character
412
- *
413
- * Valid examples: "chat", "video-call", "com.example.service", "v2"
414
- * Invalid examples: "", "UPPERCASE", "-starts-dash", "ends-dash-"
182
+ * Validates a single tag format.
183
+ * Rules: 1-64 chars, lowercase alphanumeric with optional dots/dashes,
184
+ * must start and end with alphanumeric character.
415
185
  */
416
186
  export function validateTag(tag: string): { valid: boolean; error?: string } {
417
187
  if (typeof tag !== 'string') {
@@ -426,7 +196,6 @@ export function validateTag(tag: string): { valid: boolean; error?: string } {
426
196
  return { valid: false, error: `Tag must be at most ${TAG_MAX_LENGTH} characters` };
427
197
  }
428
198
 
429
- // Single character tags just need to be alphanumeric
430
199
  if (tag.length === 1) {
431
200
  if (!/^[a-z0-9]$/.test(tag)) {
432
201
  return { valid: false, error: 'Tag must be lowercase alphanumeric' };
@@ -434,7 +203,6 @@ export function validateTag(tag: string): { valid: boolean; error?: string } {
434
203
  return { valid: true };
435
204
  }
436
205
 
437
- // Multi-character tags must match the pattern
438
206
  if (!TAG_REGEX.test(tag)) {
439
207
  return { valid: false, error: 'Tag must be lowercase alphanumeric with optional dots/dashes, and start/end with alphanumeric' };
440
208
  }
@@ -443,7 +211,7 @@ export function validateTag(tag: string): { valid: boolean; error?: string } {
443
211
  }
444
212
 
445
213
  /**
446
- * Validates an array of tags
214
+ * Validates an array of tags.
447
215
  * @param tags Array of tags to validate
448
216
  * @param maxTags Maximum number of tags allowed (default: 20)
449
217
  */
@@ -460,7 +228,6 @@ export function validateTags(tags: string[], maxTags: number = 20): { valid: boo
460
228
  return { valid: false, error: `Maximum ${maxTags} tags allowed` };
461
229
  }
462
230
 
463
- // Validate each tag
464
231
  for (let i = 0; i < tags.length; i++) {
465
232
  const result = validateTag(tags[i]);
466
233
  if (!result.valid) {
@@ -468,7 +235,6 @@ export function validateTags(tags: string[], maxTags: number = 20): { valid: boo
468
235
  }
469
236
  }
470
237
 
471
- // Check for duplicates
472
238
  const uniqueTags = new Set(tags);
473
239
  if (uniqueTags.size !== tags.length) {
474
240
  return { valid: false, error: 'Duplicate tags are not allowed' };
package/src/index.ts CHANGED
@@ -21,7 +21,6 @@ async function main() {
21
21
 
22
22
  const storage: Storage = await createStorage({
23
23
  type: config.storageType,
24
- masterEncryptionKey: config.masterEncryptionKey,
25
24
  sqlitePath: config.storagePath,
26
25
  connectionString: config.databaseUrl,
27
26
  poolSize: config.dbPoolSize,
@@ -32,9 +31,9 @@ async function main() {
32
31
  const cleanupTimer = setInterval(async () => {
33
32
  try {
34
33
  const result = await runCleanup(storage, Date.now());
35
- const total = result.offers + result.credentials + result.rateLimits + result.nonces;
34
+ const total = result.offers + result.rateLimits + result.nonces;
36
35
  if (total > 0) {
37
- console.log(`Cleanup: ${result.offers} offers, ${result.credentials} credentials, ${result.rateLimits} rate limits, ${result.nonces} nonces`);
36
+ console.log(`Cleanup: ${result.offers} offers, ${result.rateLimits} rate limits, ${result.nonces} nonces`);
38
37
  }
39
38
  } catch (err) {
40
39
  console.error('Cleanup error:', err);