@xtr-dev/rondevu-server 0.5.1 → 0.5.7

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,379 +1,477 @@
1
1
  /**
2
- * Crypto utilities for Ed25519-based authentication
3
- * Uses @noble/ed25519 for Ed25519 signature verification
2
+ * Crypto utilities for credential generation and validation
4
3
  * Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
5
4
  */
6
5
 
7
- import * as ed25519 from '@noble/ed25519';
8
6
  import { Buffer } from 'node:buffer';
9
7
 
10
- // Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+)
11
- // Uses Web Crypto API (compatible with both Node.js and Cloudflare Workers)
12
- ed25519.hashes.sha512Async = async (message: Uint8Array) => {
13
- return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource));
14
- };
15
-
16
8
  // Username validation
17
- const USERNAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
18
- const USERNAME_MIN_LENGTH = 3;
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;
19
12
  const USERNAME_MAX_LENGTH = 32;
20
13
 
21
- // Timestamp validation (5 minutes tolerance)
22
- const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;
23
-
24
14
  /**
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
15
+ * Generates a random credential name
16
+ * Format: {adjective}-{noun}-{random}
17
+ * Example: "brave-tiger-7a3f2b1c9d8e", "quick-river-9b2e4c1a5f3d"
28
18
  */
29
- export function generateAnonymousUsername(): string {
30
- const timestamp = Date.now().toString(36);
31
- const random = crypto.getRandomValues(new Uint8Array(3));
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));
32
40
  const hex = Array.from(random).map(b => b.toString(16).padStart(2, '0')).join('');
33
- return `anon-${timestamp}-${hex}`;
34
- }
35
41
 
36
- /**
37
- * Convert Uint8Array to base64 string
38
- * Uses Buffer for compatibility with Node.js-based clients
39
- */
40
- function bytesToBase64(bytes: Uint8Array): string {
41
- return Buffer.from(bytes).toString('base64');
42
+ return `${adjective}-${noun}-${hex}`;
42
43
  }
43
44
 
44
45
  /**
45
- * Convert base64 string to Uint8Array
46
- * Uses Buffer for compatibility with Node.js-based clients
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
47
49
  */
48
- function base64ToBytes(base64: string): Uint8Array {
49
- return new Uint8Array(Buffer.from(base64, 'base64'));
50
- }
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('');
51
53
 
52
- /**
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
56
- */
57
- export function validateAuthMessage(
58
- expectedUsername: string,
59
- message: string
60
- ): { valid: boolean; error?: string } {
61
- const parts = message.split(':');
62
-
63
- if (parts.length < 3) {
64
- return { valid: false, error: 'Invalid message format: must have at least action:username:timestamp' };
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');
65
57
  }
66
58
 
67
- // Extract username (second part) and timestamp (last part)
68
- const messageUsername = parts[1];
69
- const timestamp = parseInt(parts[parts.length - 1], 10);
70
-
71
- // Validate username matches
72
- if (messageUsername !== expectedUsername) {
73
- return { valid: false, error: 'Username in message does not match authenticated username' };
74
- }
75
-
76
- // Validate timestamp
77
- if (isNaN(timestamp)) {
78
- return { valid: false, error: 'Invalid timestamp in message' };
79
- }
80
-
81
- const timestampCheck = validateTimestamp(timestamp);
82
- if (!timestampCheck.valid) {
83
- return timestampCheck;
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
+ }
84
65
  }
85
66
 
86
- return { valid: true };
67
+ return secret;
87
68
  }
88
69
 
89
- // ===== Username and Ed25519 Signature Utilities =====
70
+ // ===== Secret Encryption/Decryption (Database Storage) =====
90
71
 
91
72
  /**
92
- * Validates username format
93
- * Rules: 3-32 chars, lowercase alphanumeric + dash, must start/end with alphanumeric
73
+ * Convert hex string to byte array with validation
74
+ * @param hex Hex string (must be even length)
75
+ * @returns Uint8Array of bytes
94
76
  */
95
- export function validateUsername(username: string): { valid: boolean; error?: string } {
96
- if (typeof username !== 'string') {
97
- return { valid: false, error: 'Username must be a string' };
77
+ function hexToBytes(hex: string): Uint8Array {
78
+ if (hex.length % 2 !== 0) {
79
+ throw new Error('Hex string must have even length');
98
80
  }
99
81
 
100
- if (username.length < USERNAME_MIN_LENGTH) {
101
- return { valid: false, error: `Username must be at least ${USERNAME_MIN_LENGTH} characters` };
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
+ for (let i = 0; i < hex.length; i++) {
85
+ const c = hex[i].toLowerCase();
86
+ if ((c < '0' || c > '9') && (c < 'a' || c > 'f')) {
87
+ throw new Error(`Invalid hex character at position ${i}: '${hex[i]}'`);
88
+ }
102
89
  }
103
90
 
104
- if (username.length > USERNAME_MAX_LENGTH) {
105
- return { valid: false, error: `Username must be at most ${USERNAME_MAX_LENGTH} characters` };
91
+ const match = hex.match(/.{1,2}/g);
92
+ if (!match) {
93
+ throw new Error('Invalid hex string format');
106
94
  }
107
95
 
108
- if (!USERNAME_REGEX.test(username)) {
109
- return { valid: false, error: 'Username must be lowercase alphanumeric with optional dashes, and start/end with alphanumeric' };
96
+ return new Uint8Array(match.map(byte => {
97
+ const parsed = parseInt(byte, 16);
98
+ if (isNaN(parsed)) {
99
+ throw new Error(`Invalid hex byte: ${byte}`);
100
+ }
101
+ return parsed;
102
+ }));
103
+ }
104
+
105
+ /**
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)');
110
117
  }
111
118
 
112
- return { valid: true };
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}`;
113
152
  }
114
153
 
115
154
  /**
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)
118
- * Version: semantic versioning (1.0.0, 2.1.3-beta, etc.)
119
- * Username: optional, lowercase alphanumeric with dashes
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
120
160
  */
121
- export function validateServiceFqn(fqn: string): { valid: boolean; error?: string } {
122
- if (typeof fqn !== 'string') {
123
- return { valid: false, error: 'Service FQN must be a string' };
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)');
124
165
  }
125
166
 
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]' };
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)');
130
171
  }
131
172
 
132
- const { serviceName, version, username } = parsed;
173
+ const [ivHex, ciphertextHex] = parts;
133
174
 
134
- // Validate service name (alphanumeric with dots/dashes)
135
- const serviceNameRegex = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
136
- if (!serviceNameRegex.test(serviceName)) {
137
- return { valid: false, error: 'Service name must be lowercase alphanumeric with optional dots/dashes' };
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)');
138
178
  }
139
179
 
140
- if (serviceName.length < 1 || serviceName.length > 128) {
141
- return { valid: false, error: 'Service name must be 1-128 characters' };
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)');
142
184
  }
143
185
 
144
- // Validate version (semantic versioning)
145
- const versionRegex = /^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.-]+)?$/;
146
- if (!versionRegex.test(version)) {
147
- return { valid: false, error: 'Version must be semantic versioning (e.g., 1.0.0, 2.1.3-beta)' };
148
- }
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
+ }
149
213
 
150
- // Validate username if present
151
- if (username) {
152
- const usernameCheck = validateUsername(username);
153
- if (!usernameCheck.valid) {
154
- return usernameCheck;
155
- }
156
- }
214
+ // ===== HMAC Signature Generation and Verification =====
157
215
 
158
- return { valid: true };
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');
159
246
  }
160
247
 
161
248
  /**
162
- * Parse semantic version string into components
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
163
256
  */
164
- export function parseVersion(version: string): { major: number; minor: number; patch: number; prerelease?: string } | null {
165
- const match = version.match(/^([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-z0-9.-]+)?$/);
166
- if (!match) return null;
167
-
168
- return {
169
- major: parseInt(match[1], 10),
170
- minor: parseInt(match[2], 10),
171
- patch: parseInt(match[3], 10),
172
- prerelease: match[4]?.substring(1), // Remove leading dash
173
- };
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
+ }
174
286
  }
175
287
 
176
288
  /**
177
- * Check if two versions are compatible (same major version)
178
- * Following semver rules: ^1.0.0 matches 1.x.x but not 2.x.x
289
+ * Canonical JSON serialization with sorted keys
290
+ * Ensures deterministic output regardless of property insertion order
291
+ * Must match client's canonicalJSON implementation exactly
179
292
  */
180
- export function isVersionCompatible(requested: string, available: string): boolean {
181
- const req = parseVersion(requested);
182
- const avail = parseVersion(available);
293
+ function canonicalJSON(obj: any, depth: number = 0): string {
294
+ const MAX_DEPTH = 100;
183
295
 
184
- if (!req || !avail) return false;
296
+ if (depth > MAX_DEPTH) {
297
+ throw new Error('Object nesting too deep for canonicalization');
298
+ }
299
+
300
+ if (obj === null) return 'null';
301
+ if (obj === undefined) return JSON.stringify(undefined);
185
302
 
186
- // Major version must match
187
- if (req.major !== avail.major) return false;
303
+ const type = typeof obj;
188
304
 
189
- // If major is 0, minor must also match (0.x.y is unstable)
190
- if (req.major === 0 && req.minor !== avail.minor) return false;
305
+ if (type === 'function') throw new Error('Functions are not supported in RPC parameters');
306
+ if (type === 'symbol' || type === 'bigint') throw new Error(`${type} is not supported in RPC parameters`);
307
+ if (type === 'number' && !Number.isFinite(obj)) throw new Error('NaN and Infinity are not supported in RPC parameters');
191
308
 
192
- // Available version must be >= requested version
193
- if (avail.minor < req.minor) return false;
194
- if (avail.minor === req.minor && avail.patch < req.patch) return false;
309
+ if (type !== 'object') return JSON.stringify(obj);
195
310
 
196
- // Prerelease versions are only compatible with exact matches
197
- if (req.prerelease && req.prerelease !== avail.prerelease) return false;
311
+ if (Array.isArray(obj)) {
312
+ return '[' + obj.map(item => canonicalJSON(item, depth + 1)).join(',') + ']';
313
+ }
198
314
 
199
- return true;
315
+ const sortedKeys = Object.keys(obj).sort();
316
+ const pairs = sortedKeys.map(key => JSON.stringify(key) + ':' + canonicalJSON(obj[key], depth + 1));
317
+ return '{' + pairs.join(',') + '}';
200
318
  }
201
319
 
202
320
  /**
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
321
+ * Build the message string for signing
322
+ * 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
207
332
  */
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;
333
+ 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)
339
+ if (nonce.length !== 36) {
340
+ throw new Error('Nonce must be a valid UUID v4 (use crypto.randomUUID())');
341
+ }
215
342
 
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;
343
+ // Check dash positions (indices 8, 13, 18, 23)
344
+ if (nonce[8] !== '-' || nonce[13] !== '-' || nonce[18] !== '-' || nonce[23] !== '-') {
345
+ throw new Error('Nonce must be a valid UUID v4 (use crypto.randomUUID())');
223
346
  }
224
347
 
225
- // Split service:version
226
- const colonIndex = serviceVersion.indexOf(':');
227
- if (colonIndex <= 0) return null; // No colon or colon at start
348
+ // Check version (character at index 14 must be '4')
349
+ if (nonce[14] !== '4') {
350
+ throw new Error('Nonce must be a valid UUID v4 (use crypto.randomUUID())');
351
+ }
228
352
 
229
- const serviceName = serviceVersion.substring(0, colonIndex);
230
- const version = serviceVersion.substring(colonIndex + 1);
353
+ // Check variant (character at index 19 must be 8, 9, a, or b)
354
+ const variant = nonce[19].toLowerCase();
355
+ if (variant !== '8' && variant !== '9' && variant !== 'a' && variant !== 'b') {
356
+ throw new Error('Nonce must be a valid UUID v4 (use crypto.randomUUID())');
357
+ }
231
358
 
232
- if (!serviceName || !version) return null;
359
+ // Validate all other characters are hex digits (0-9, a-f)
360
+ const hexChars = nonce.replace(/-/g, ''); // Remove dashes
361
+ for (let i = 0; i < hexChars.length; i++) {
362
+ const c = hexChars[i].toLowerCase();
363
+ if ((c < '0' || c > '9') && (c < 'a' || c > 'f')) {
364
+ throw new Error('Nonce must be a valid UUID v4 (use crypto.randomUUID())');
365
+ }
366
+ }
233
367
 
234
- return {
235
- serviceName,
236
- version,
237
- username,
238
- };
368
+ // Use canonical JSON (sorted keys) to match client's signature
369
+ 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
+ return `${timestamp}:${nonce}:${method}:${paramsStr}`;
239
373
  }
240
374
 
375
+ // ===== Username Validation =====
376
+
241
377
  /**
242
- * Validates timestamp is within acceptable range (prevents replay attacks)
378
+ * Validates username format
379
+ * Rules: 4-32 chars, lowercase alphanumeric + dashes + periods, must start/end with alphanumeric
243
380
  */
244
- export function validateTimestamp(timestamp: number): { valid: boolean; error?: string } {
245
- if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) {
246
- return { valid: false, error: 'Timestamp must be a finite number' };
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' };
247
384
  }
248
385
 
249
- const now = Date.now();
250
- const diff = Math.abs(now - timestamp);
386
+ if (username.length < USERNAME_MIN_LENGTH) {
387
+ return { valid: false, error: `Username must be at least ${USERNAME_MIN_LENGTH} characters` };
388
+ }
389
+
390
+ if (username.length > USERNAME_MAX_LENGTH) {
391
+ return { valid: false, error: `Username must be at most ${USERNAME_MAX_LENGTH} characters` };
392
+ }
251
393
 
252
- if (diff > TIMESTAMP_TOLERANCE_MS) {
253
- return { valid: false, error: `Timestamp too old or too far in future (tolerance: ${TIMESTAMP_TOLERANCE_MS / 1000}s)` };
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' };
254
396
  }
255
397
 
256
398
  return { valid: true };
257
399
  }
258
400
 
259
- /**
260
- * Verifies Ed25519 signature
261
- * @param publicKey Base64-encoded Ed25519 public key (32 bytes)
262
- * @param signature Base64-encoded Ed25519 signature (64 bytes)
263
- * @param message Message that was signed (UTF-8 string)
264
- * @returns true if signature is valid, false otherwise
265
- */
266
- export async function verifyEd25519Signature(
267
- publicKey: string,
268
- signature: string,
269
- message: string
270
- ): Promise<boolean> {
271
- try {
272
- // Decode base64 to bytes
273
- const publicKeyBytes = base64ToBytes(publicKey);
274
- const signatureBytes = base64ToBytes(signature);
275
-
276
- // Encode message as UTF-8
277
- const encoder = new TextEncoder();
278
- const messageBytes = encoder.encode(message);
401
+ // ===== Tag Validation =====
279
402
 
280
- // Verify signature using @noble/ed25519 (async version)
281
- const isValid = await ed25519.verifyAsync(signatureBytes, messageBytes, publicKeyBytes);
282
- return isValid;
283
- } catch (err) {
284
- console.error('Ed25519 signature verification failed:', err);
285
- return false;
286
- }
287
- }
403
+ // Tag validation constants
404
+ const TAG_MIN_LENGTH = 1;
405
+ const TAG_MAX_LENGTH = 64;
406
+ const TAG_REGEX = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
288
407
 
289
408
  /**
290
- * Validates a username claim request
291
- * Verifies format, timestamp, and signature
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-"
292
415
  */
293
- export async function validateUsernameClaim(
294
- username: string,
295
- publicKey: string,
296
- signature: string,
297
- message: string
298
- ): Promise<{ valid: boolean; error?: string }> {
299
- // Validate username format
300
- const usernameCheck = validateUsername(username);
301
- if (!usernameCheck.valid) {
302
- return usernameCheck;
416
+ export function validateTag(tag: string): { valid: boolean; error?: string } {
417
+ if (typeof tag !== 'string') {
418
+ return { valid: false, error: 'Tag must be a string' };
303
419
  }
304
420
 
305
- // Parse message format: "claim:{username}:{timestamp}"
306
- const parts = message.split(':');
307
- if (parts.length !== 3 || parts[0] !== 'claim' || parts[1] !== username) {
308
- return { valid: false, error: 'Invalid message format (expected: claim:{username}:{timestamp})' };
421
+ if (tag.length < TAG_MIN_LENGTH) {
422
+ return { valid: false, error: `Tag must be at least ${TAG_MIN_LENGTH} character` };
309
423
  }
310
424
 
311
- const timestamp = parseInt(parts[2], 10);
312
- if (isNaN(timestamp)) {
313
- return { valid: false, error: 'Invalid timestamp in message' };
425
+ if (tag.length > TAG_MAX_LENGTH) {
426
+ return { valid: false, error: `Tag must be at most ${TAG_MAX_LENGTH} characters` };
314
427
  }
315
428
 
316
- // Validate timestamp
317
- const timestampCheck = validateTimestamp(timestamp);
318
- if (!timestampCheck.valid) {
319
- return timestampCheck;
429
+ // Single character tags just need to be alphanumeric
430
+ if (tag.length === 1) {
431
+ if (!/^[a-z0-9]$/.test(tag)) {
432
+ return { valid: false, error: 'Tag must be lowercase alphanumeric' };
433
+ }
434
+ return { valid: true };
320
435
  }
321
436
 
322
- // Verify signature
323
- const signatureValid = await verifyEd25519Signature(publicKey, signature, message);
324
- if (!signatureValid) {
325
- return { valid: false, error: 'Invalid signature' };
437
+ // Multi-character tags must match the pattern
438
+ if (!TAG_REGEX.test(tag)) {
439
+ return { valid: false, error: 'Tag must be lowercase alphanumeric with optional dots/dashes, and start/end with alphanumeric' };
326
440
  }
327
441
 
328
442
  return { valid: true };
329
443
  }
330
444
 
331
445
  /**
332
- * Validates a service publish signature
333
- * Message format: publish:{username}:{serviceFqn}:{timestamp}
446
+ * Validates an array of tags
447
+ * @param tags Array of tags to validate
448
+ * @param maxTags Maximum number of tags allowed (default: 20)
334
449
  */
335
- export async function validateServicePublish(
336
- username: string,
337
- serviceFqn: string,
338
- publicKey: string,
339
- signature: string,
340
- message: string
341
- ): Promise<{ valid: boolean; error?: string }> {
342
- // Validate username format
343
- const usernameCheck = validateUsername(username);
344
- if (!usernameCheck.valid) {
345
- return usernameCheck;
450
+ export function validateTags(tags: string[], maxTags: number = 20): { valid: boolean; error?: string } {
451
+ if (!Array.isArray(tags)) {
452
+ return { valid: false, error: 'Tags must be an array' };
346
453
  }
347
454
 
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
350
- const parts = message.split(':');
351
- if (parts.length < 4 || parts[0] !== 'publish' || parts[1] !== username) {
352
- return { valid: false, error: 'Invalid message format (expected: publish:{username}:{serviceFqn}:{timestamp})' };
455
+ if (tags.length === 0) {
456
+ return { valid: false, error: 'At least one tag is required' };
353
457
  }
354
458
 
355
- // The timestamp is the last part
356
- const timestamp = parseInt(parts[parts.length - 1], 10);
357
- if (isNaN(timestamp)) {
358
- return { valid: false, error: 'Invalid timestamp in message' };
459
+ if (tags.length > maxTags) {
460
+ return { valid: false, error: `Maximum ${maxTags} tags allowed` };
359
461
  }
360
462
 
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
-
367
- // Validate timestamp
368
- const timestampCheck = validateTimestamp(timestamp);
369
- if (!timestampCheck.valid) {
370
- return timestampCheck;
463
+ // Validate each tag
464
+ for (let i = 0; i < tags.length; i++) {
465
+ const result = validateTag(tags[i]);
466
+ if (!result.valid) {
467
+ return { valid: false, error: `Tag ${i + 1}: ${result.error}` };
468
+ }
371
469
  }
372
470
 
373
- // Verify signature
374
- const signatureValid = await verifyEd25519Signature(publicKey, signature, message);
375
- if (!signatureValid) {
376
- return { valid: false, error: 'Invalid signature' };
471
+ // Check for duplicates
472
+ const uniqueTags = new Set(tags);
473
+ if (uniqueTags.size !== tags.length) {
474
+ return { valid: false, error: 'Duplicate tags are not allowed' };
377
475
  }
378
476
 
379
477
  return { valid: true };