@xtr-dev/rondevu-server 0.1.5 → 0.2.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/src/crypto.ts CHANGED
@@ -1,12 +1,29 @@
1
1
  /**
2
2
  * Crypto utilities for stateless peer authentication
3
3
  * Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
4
+ * Uses @noble/ed25519 for Ed25519 signature verification
4
5
  */
5
6
 
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
+ };
14
+
6
15
  const ALGORITHM = 'AES-GCM';
7
16
  const IV_LENGTH = 12; // 96 bits for GCM
8
17
  const KEY_LENGTH = 32; // 256 bits
9
18
 
19
+ // Username validation
20
+ const USERNAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
21
+ const USERNAME_MIN_LENGTH = 3;
22
+ const USERNAME_MAX_LENGTH = 32;
23
+
24
+ // Timestamp validation (5 minutes tolerance)
25
+ const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;
26
+
10
27
  /**
11
28
  * Generates a random peer ID (16 bytes = 32 hex chars)
12
29
  */
@@ -147,3 +164,156 @@ export async function validateCredentials(peerId: string, encryptedSecret: strin
147
164
  return false;
148
165
  }
149
166
  }
167
+
168
+ // ===== Username and Ed25519 Signature Utilities =====
169
+
170
+ /**
171
+ * Validates username format
172
+ * Rules: 3-32 chars, lowercase alphanumeric + dash, must start/end with alphanumeric
173
+ */
174
+ export function validateUsername(username: string): { valid: boolean; error?: string } {
175
+ if (typeof username !== 'string') {
176
+ return { valid: false, error: 'Username must be a string' };
177
+ }
178
+
179
+ if (username.length < USERNAME_MIN_LENGTH) {
180
+ return { valid: false, error: `Username must be at least ${USERNAME_MIN_LENGTH} characters` };
181
+ }
182
+
183
+ if (username.length > USERNAME_MAX_LENGTH) {
184
+ return { valid: false, error: `Username must be at most ${USERNAME_MAX_LENGTH} characters` };
185
+ }
186
+
187
+ if (!USERNAME_REGEX.test(username)) {
188
+ return { valid: false, error: 'Username must be lowercase alphanumeric with optional dashes, and start/end with alphanumeric' };
189
+ }
190
+
191
+ return { valid: true };
192
+ }
193
+
194
+ /**
195
+ * Validates service FQN format (service-name@version)
196
+ * Service name: reverse domain notation (com.example.service)
197
+ * Version: semantic versioning (1.0.0, 2.1.3-beta, etc.)
198
+ */
199
+ export function validateServiceFqn(fqn: string): { valid: boolean; error?: string } {
200
+ if (typeof fqn !== 'string') {
201
+ return { valid: false, error: 'Service FQN must be a string' };
202
+ }
203
+
204
+ // Split into service name and version
205
+ const parts = fqn.split('@');
206
+ if (parts.length !== 2) {
207
+ return { valid: false, error: 'Service FQN must be in format: service-name@version' };
208
+ }
209
+
210
+ const [serviceName, version] = parts;
211
+
212
+ // Validate service name (reverse domain notation)
213
+ const serviceNameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
214
+ if (!serviceNameRegex.test(serviceName)) {
215
+ return { valid: false, error: 'Service name must be reverse domain notation (e.g., com.example.service)' };
216
+ }
217
+
218
+ if (serviceName.length < 3 || serviceName.length > 128) {
219
+ return { valid: false, error: 'Service name must be 3-128 characters' };
220
+ }
221
+
222
+ // Validate version (semantic versioning)
223
+ const versionRegex = /^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.-]+)?$/;
224
+ if (!versionRegex.test(version)) {
225
+ return { valid: false, error: 'Version must be semantic versioning (e.g., 1.0.0, 2.1.3-beta)' };
226
+ }
227
+
228
+ return { valid: true };
229
+ }
230
+
231
+ /**
232
+ * Validates timestamp is within acceptable range (prevents replay attacks)
233
+ */
234
+ export function validateTimestamp(timestamp: number): { valid: boolean; error?: string } {
235
+ if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) {
236
+ return { valid: false, error: 'Timestamp must be a finite number' };
237
+ }
238
+
239
+ const now = Date.now();
240
+ const diff = Math.abs(now - timestamp);
241
+
242
+ if (diff > TIMESTAMP_TOLERANCE_MS) {
243
+ return { valid: false, error: `Timestamp too old or too far in future (tolerance: ${TIMESTAMP_TOLERANCE_MS / 1000}s)` };
244
+ }
245
+
246
+ return { valid: true };
247
+ }
248
+
249
+ /**
250
+ * Verifies Ed25519 signature
251
+ * @param publicKey Base64-encoded Ed25519 public key (32 bytes)
252
+ * @param signature Base64-encoded Ed25519 signature (64 bytes)
253
+ * @param message Message that was signed (UTF-8 string)
254
+ * @returns true if signature is valid, false otherwise
255
+ */
256
+ export async function verifyEd25519Signature(
257
+ publicKey: string,
258
+ signature: string,
259
+ message: string
260
+ ): Promise<boolean> {
261
+ try {
262
+ // Decode base64 to bytes
263
+ const publicKeyBytes = base64ToBytes(publicKey);
264
+ const signatureBytes = base64ToBytes(signature);
265
+
266
+ // Encode message as UTF-8
267
+ const encoder = new TextEncoder();
268
+ const messageBytes = encoder.encode(message);
269
+
270
+ // Verify signature using @noble/ed25519
271
+ const isValid = await ed25519.verify(signatureBytes, messageBytes, publicKeyBytes);
272
+ return isValid;
273
+ } catch (err) {
274
+ console.error('Ed25519 signature verification failed:', err);
275
+ return false;
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Validates a username claim request
281
+ * Verifies format, timestamp, and signature
282
+ */
283
+ export async function validateUsernameClaim(
284
+ username: string,
285
+ publicKey: string,
286
+ signature: string,
287
+ message: string
288
+ ): Promise<{ valid: boolean; error?: string }> {
289
+ // Validate username format
290
+ const usernameCheck = validateUsername(username);
291
+ if (!usernameCheck.valid) {
292
+ return usernameCheck;
293
+ }
294
+
295
+ // Parse message format: "claim:{username}:{timestamp}"
296
+ const parts = message.split(':');
297
+ if (parts.length !== 3 || parts[0] !== 'claim' || parts[1] !== username) {
298
+ return { valid: false, error: 'Invalid message format (expected: claim:{username}:{timestamp})' };
299
+ }
300
+
301
+ const timestamp = parseInt(parts[2], 10);
302
+ if (isNaN(timestamp)) {
303
+ return { valid: false, error: 'Invalid timestamp in message' };
304
+ }
305
+
306
+ // Validate timestamp
307
+ const timestampCheck = validateTimestamp(timestamp);
308
+ if (!timestampCheck.valid) {
309
+ return timestampCheck;
310
+ }
311
+
312
+ // Verify signature
313
+ const signatureValid = await verifyEd25519Signature(publicKey, signature, message);
314
+ if (!signatureValid) {
315
+ return { valid: false, error: 'Invalid signature' };
316
+ }
317
+
318
+ return { valid: true };
319
+ }