@xtr-dev/rondevu-server 0.5.0 → 0.5.6

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,380 +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
-
9
- // Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+)
10
- // Uses Web Crypto API (compatible with both Node.js and Cloudflare Workers)
11
- ed25519.hashes.sha512Async = async (message: Uint8Array) => {
12
- return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource));
13
- };
6
+ import { Buffer } from 'node:buffer';
14
7
 
15
8
  // Username validation
16
- const USERNAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
17
- 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;
18
12
  const USERNAME_MAX_LENGTH = 32;
19
13
 
20
- // Timestamp validation (5 minutes tolerance)
21
- const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;
22
-
23
14
  /**
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
15
+ * Generates a random credential name
16
+ * Format: {adjective}-{noun}-{random}
17
+ * Example: "brave-tiger-7a3f2b1c9d8e", "quick-river-9b2e4c1a5f3d"
27
18
  */
28
- export function generateAnonymousUsername(): string {
29
- const timestamp = Date.now().toString(36);
30
- 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));
31
40
  const hex = Array.from(random).map(b => b.toString(16).padStart(2, '0')).join('');
32
- return `anon-${timestamp}-${hex}`;
33
- }
34
41
 
35
- /**
36
- * Convert Uint8Array to base64 string
37
- */
38
- function bytesToBase64(bytes: Uint8Array): string {
39
- const binString = Array.from(bytes, (byte) =>
40
- String.fromCodePoint(byte)
41
- ).join('');
42
- return btoa(binString);
42
+ return `${adjective}-${noun}-${hex}`;
43
43
  }
44
44
 
45
45
  /**
46
- * Convert base64 string to Uint8Array
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
- const binString = atob(base64);
50
- return Uint8Array.from(binString, (char) => char.codePointAt(0)!);
51
- }
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('');
52
53
 
53
- /**
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
57
- */
58
- export function validateAuthMessage(
59
- expectedUsername: string,
60
- message: string
61
- ): { valid: boolean; error?: string } {
62
- const parts = message.split(':');
63
-
64
- if (parts.length < 3) {
65
- 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');
66
57
  }
67
58
 
68
- // Extract username (second part) and timestamp (last part)
69
- const messageUsername = parts[1];
70
- const timestamp = parseInt(parts[parts.length - 1], 10);
71
-
72
- // Validate username matches
73
- if (messageUsername !== expectedUsername) {
74
- return { valid: false, error: 'Username in message does not match authenticated username' };
75
- }
76
-
77
- // Validate timestamp
78
- if (isNaN(timestamp)) {
79
- return { valid: false, error: 'Invalid timestamp in message' };
80
- }
81
-
82
- const timestampCheck = validateTimestamp(timestamp);
83
- if (!timestampCheck.valid) {
84
- 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
+ }
85
65
  }
86
66
 
87
- return { valid: true };
67
+ return secret;
88
68
  }
89
69
 
90
- // ===== Username and Ed25519 Signature Utilities =====
70
+ // ===== Secret Encryption/Decryption (Database Storage) =====
91
71
 
92
72
  /**
93
- * Validates username format
94
- * 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
95
76
  */
96
- export function validateUsername(username: string): { valid: boolean; error?: string } {
97
- if (typeof username !== 'string') {
98
- 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');
99
80
  }
100
81
 
101
- if (username.length < USERNAME_MIN_LENGTH) {
102
- 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
+ }
103
89
  }
104
90
 
105
- if (username.length > USERNAME_MAX_LENGTH) {
106
- 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');
107
94
  }
108
95
 
109
- if (!USERNAME_REGEX.test(username)) {
110
- 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)');
111
117
  }
112
118
 
113
- 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}`;
114
152
  }
115
153
 
116
154
  /**
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)
119
- * Version: semantic versioning (1.0.0, 2.1.3-beta, etc.)
120
- * 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
121
160
  */
122
- export function validateServiceFqn(fqn: string): { valid: boolean; error?: string } {
123
- if (typeof fqn !== 'string') {
124
- 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)');
125
165
  }
126
166
 
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]' };
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)');
131
171
  }
132
172
 
133
- const { serviceName, version, username } = parsed;
173
+ const [ivHex, ciphertextHex] = parts;
134
174
 
135
- // Validate service name (alphanumeric with dots/dashes)
136
- const serviceNameRegex = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
137
- if (!serviceNameRegex.test(serviceName)) {
138
- 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)');
139
178
  }
140
179
 
141
- if (serviceName.length < 1 || serviceName.length > 128) {
142
- 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)');
143
184
  }
144
185
 
145
- // Validate version (semantic versioning)
146
- const versionRegex = /^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.-]+)?$/;
147
- if (!versionRegex.test(version)) {
148
- return { valid: false, error: 'Version must be semantic versioning (e.g., 1.0.0, 2.1.3-beta)' };
149
- }
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
+ }
150
213
 
151
- // Validate username if present
152
- if (username) {
153
- const usernameCheck = validateUsername(username);
154
- if (!usernameCheck.valid) {
155
- return usernameCheck;
156
- }
157
- }
214
+ // ===== HMAC Signature Generation and Verification =====
158
215
 
159
- 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');
160
246
  }
161
247
 
162
248
  /**
163
- * 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
164
256
  */
165
- export function parseVersion(version: string): { major: number; minor: number; patch: number; prerelease?: string } | null {
166
- const match = version.match(/^([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-z0-9.-]+)?$/);
167
- if (!match) return null;
168
-
169
- return {
170
- major: parseInt(match[1], 10),
171
- minor: parseInt(match[2], 10),
172
- patch: parseInt(match[3], 10),
173
- prerelease: match[4]?.substring(1), // Remove leading dash
174
- };
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
+ }
175
286
  }
176
287
 
177
288
  /**
178
- * Check if two versions are compatible (same major version)
179
- * 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
180
292
  */
181
- export function isVersionCompatible(requested: string, available: string): boolean {
182
- const req = parseVersion(requested);
183
- const avail = parseVersion(available);
293
+ function canonicalJSON(obj: any, depth: number = 0): string {
294
+ const MAX_DEPTH = 100;
184
295
 
185
- 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);
186
302
 
187
- // Major version must match
188
- if (req.major !== avail.major) return false;
303
+ const type = typeof obj;
189
304
 
190
- // If major is 0, minor must also match (0.x.y is unstable)
191
- 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');
192
308
 
193
- // Available version must be >= requested version
194
- if (avail.minor < req.minor) return false;
195
- if (avail.minor === req.minor && avail.patch < req.patch) return false;
309
+ if (type !== 'object') return JSON.stringify(obj);
196
310
 
197
- // Prerelease versions are only compatible with exact matches
198
- 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
+ }
199
314
 
200
- 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(',') + '}';
201
318
  }
202
319
 
203
320
  /**
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
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
208
332
  */
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;
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
+ }
216
342
 
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;
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())');
224
346
  }
225
347
 
226
- // Split service:version
227
- const colonIndex = serviceVersion.indexOf(':');
228
- 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
+ }
229
352
 
230
- const serviceName = serviceVersion.substring(0, colonIndex);
231
- 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
+ }
232
358
 
233
- 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
+ }
234
367
 
235
- return {
236
- serviceName,
237
- version,
238
- username,
239
- };
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}`;
240
373
  }
241
374
 
375
+ // ===== Username Validation =====
376
+
242
377
  /**
243
- * 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
244
380
  */
245
- export function validateTimestamp(timestamp: number): { valid: boolean; error?: string } {
246
- if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) {
247
- 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' };
248
384
  }
249
385
 
250
- const now = Date.now();
251
- 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
+ }
252
393
 
253
- if (diff > TIMESTAMP_TOLERANCE_MS) {
254
- 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' };
255
396
  }
256
397
 
257
398
  return { valid: true };
258
399
  }
259
400
 
260
- /**
261
- * Verifies Ed25519 signature
262
- * @param publicKey Base64-encoded Ed25519 public key (32 bytes)
263
- * @param signature Base64-encoded Ed25519 signature (64 bytes)
264
- * @param message Message that was signed (UTF-8 string)
265
- * @returns true if signature is valid, false otherwise
266
- */
267
- export async function verifyEd25519Signature(
268
- publicKey: string,
269
- signature: string,
270
- message: string
271
- ): Promise<boolean> {
272
- try {
273
- // Decode base64 to bytes
274
- const publicKeyBytes = base64ToBytes(publicKey);
275
- const signatureBytes = base64ToBytes(signature);
276
-
277
- // Encode message as UTF-8
278
- const encoder = new TextEncoder();
279
- const messageBytes = encoder.encode(message);
401
+ // ===== Tag Validation =====
280
402
 
281
- // Verify signature using @noble/ed25519 (async version)
282
- const isValid = await ed25519.verifyAsync(signatureBytes, messageBytes, publicKeyBytes);
283
- return isValid;
284
- } catch (err) {
285
- console.error('Ed25519 signature verification failed:', err);
286
- return false;
287
- }
288
- }
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])?$/;
289
407
 
290
408
  /**
291
- * Validates a username claim request
292
- * 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-"
293
415
  */
294
- export async function validateUsernameClaim(
295
- username: string,
296
- publicKey: string,
297
- signature: string,
298
- message: string
299
- ): Promise<{ valid: boolean; error?: string }> {
300
- // Validate username format
301
- const usernameCheck = validateUsername(username);
302
- if (!usernameCheck.valid) {
303
- 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' };
304
419
  }
305
420
 
306
- // Parse message format: "claim:{username}:{timestamp}"
307
- const parts = message.split(':');
308
- if (parts.length !== 3 || parts[0] !== 'claim' || parts[1] !== username) {
309
- 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` };
310
423
  }
311
424
 
312
- const timestamp = parseInt(parts[2], 10);
313
- if (isNaN(timestamp)) {
314
- 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` };
315
427
  }
316
428
 
317
- // Validate timestamp
318
- const timestampCheck = validateTimestamp(timestamp);
319
- if (!timestampCheck.valid) {
320
- 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 };
321
435
  }
322
436
 
323
- // Verify signature
324
- const signatureValid = await verifyEd25519Signature(publicKey, signature, message);
325
- if (!signatureValid) {
326
- 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' };
327
440
  }
328
441
 
329
442
  return { valid: true };
330
443
  }
331
444
 
332
445
  /**
333
- * Validates a service publish signature
334
- * 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)
335
449
  */
336
- export async function validateServicePublish(
337
- username: string,
338
- serviceFqn: string,
339
- publicKey: string,
340
- signature: string,
341
- message: string
342
- ): Promise<{ valid: boolean; error?: string }> {
343
- // Validate username format
344
- const usernameCheck = validateUsername(username);
345
- if (!usernameCheck.valid) {
346
- 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' };
347
453
  }
348
454
 
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
351
- const parts = message.split(':');
352
- if (parts.length < 4 || parts[0] !== 'publish' || parts[1] !== username) {
353
- 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' };
354
457
  }
355
458
 
356
- // The timestamp is the last part
357
- const timestamp = parseInt(parts[parts.length - 1], 10);
358
- if (isNaN(timestamp)) {
359
- return { valid: false, error: 'Invalid timestamp in message' };
459
+ if (tags.length > maxTags) {
460
+ return { valid: false, error: `Maximum ${maxTags} tags allowed` };
360
461
  }
361
462
 
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
-
368
- // Validate timestamp
369
- const timestampCheck = validateTimestamp(timestamp);
370
- if (!timestampCheck.valid) {
371
- 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
+ }
372
469
  }
373
470
 
374
- // Verify signature
375
- const signatureValid = await verifyEd25519Signature(publicKey, signature, message);
376
- if (!signatureValid) {
377
- 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' };
378
475
  }
379
476
 
380
477
  return { valid: true };