Files
rondevu-server/src/crypto.ts
Bas van den Aakster 8111cb9cec v0.5.0: Service discovery and FQN format refactoring
- Changed service FQN format: service:version@username (colon instead of @)
- Added service discovery: direct lookup, random selection, paginated queries
- Updated parseServiceFqn to handle optional username for discovery
- Removed UUID privacy layer (service_index table)
- Updated storage interface with discovery methods (discoverServices, getRandomService, getServiceByFqn)
- Removed deprecated methods (getServiceByUuid, queryService, listServicesForUsername, findServicesByName, touchUsername, batchCreateServices)
- Updated API routes: /services/:fqn with three modes (direct, random, paginated)
- Changed offer/answer/ICE routes to offer-specific: /services/:fqn/offers/:offerId/*
- Added extracted fields to services table (service_name, version, username) for efficient discovery
- Created migration 0007 to update schema and migrate existing data
- Added discovery indexes for performance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-09 22:22:37 +01:00

452 lines
13 KiB
TypeScript

/**
* Crypto utilities for stateless peer authentication
* Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
* Uses @noble/ed25519 for Ed25519 signature verification
*/
import * as ed25519 from '@noble/ed25519';
// Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+)
// Uses Web Crypto API (compatible with both Node.js and Cloudflare Workers)
ed25519.hashes.sha512Async = async (message: Uint8Array) => {
return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource));
};
const ALGORITHM = 'AES-GCM';
const IV_LENGTH = 12; // 96 bits for GCM
const KEY_LENGTH = 32; // 256 bits
// Username validation
const USERNAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
const USERNAME_MIN_LENGTH = 3;
const USERNAME_MAX_LENGTH = 32;
// Timestamp validation (5 minutes tolerance)
const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;
/**
* Generates a random peer ID (16 bytes = 32 hex chars)
*/
export function generatePeerId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
/**
* Generates a random secret key for encryption (32 bytes = 64 hex chars)
*/
export function generateSecretKey(): string {
const bytes = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
/**
* Convert hex string to Uint8Array
*/
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
/**
* Convert Uint8Array to base64 string
*/
function bytesToBase64(bytes: Uint8Array): string {
const binString = Array.from(bytes, (byte) =>
String.fromCodePoint(byte)
).join('');
return btoa(binString);
}
/**
* Convert base64 string to Uint8Array
*/
function base64ToBytes(base64: string): Uint8Array {
const binString = atob(base64);
return Uint8Array.from(binString, (char) => char.codePointAt(0)!);
}
/**
* Encrypts a peer ID using the server secret key
* Returns base64-encoded encrypted data (IV + ciphertext)
*/
export async function encryptPeerId(peerId: string, secretKeyHex: string): Promise<string> {
const keyBytes = hexToBytes(secretKeyHex);
if (keyBytes.length !== KEY_LENGTH) {
throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`);
}
// Import key
const key = await crypto.subtle.importKey(
'raw',
keyBytes,
{ name: ALGORITHM, length: 256 },
false,
['encrypt']
);
// Generate random IV
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
// Encrypt peer ID
const encoder = new TextEncoder();
const data = encoder.encode(peerId);
const encrypted = await crypto.subtle.encrypt(
{ name: ALGORITHM, iv },
key,
data
);
// Combine IV + ciphertext and encode as base64
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(encrypted), iv.length);
return bytesToBase64(combined);
}
/**
* Decrypts an encrypted peer ID secret
* Returns the plaintext peer ID or throws if decryption fails
*/
export async function decryptPeerId(encryptedSecret: string, secretKeyHex: string): Promise<string> {
try {
const keyBytes = hexToBytes(secretKeyHex);
if (keyBytes.length !== KEY_LENGTH) {
throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`);
}
// Decode base64
const combined = base64ToBytes(encryptedSecret);
// Extract IV and ciphertext
const iv = combined.slice(0, IV_LENGTH);
const ciphertext = combined.slice(IV_LENGTH);
// Import key
const key = await crypto.subtle.importKey(
'raw',
keyBytes,
{ name: ALGORITHM, length: 256 },
false,
['decrypt']
);
// Decrypt
const decrypted = await crypto.subtle.decrypt(
{ name: ALGORITHM, iv },
key,
ciphertext
);
const decoder = new TextDecoder();
return decoder.decode(decrypted);
} catch (err) {
throw new Error('Failed to decrypt peer ID: invalid secret or secret key');
}
}
/**
* Validates that a peer ID and secret match
* Returns true if valid, false otherwise
*/
export async function validateCredentials(peerId: string, encryptedSecret: string, secretKey: string): Promise<boolean> {
try {
const decryptedPeerId = await decryptPeerId(encryptedSecret, secretKey);
return decryptedPeerId === peerId;
} catch {
return false;
}
}
// ===== Username and Ed25519 Signature Utilities =====
/**
* Validates username format
* Rules: 3-32 chars, lowercase alphanumeric + dash, must start/end with alphanumeric
*/
export function validateUsername(username: string): { valid: boolean; error?: string } {
if (typeof username !== 'string') {
return { valid: false, error: 'Username must be a string' };
}
if (username.length < USERNAME_MIN_LENGTH) {
return { valid: false, error: `Username must be at least ${USERNAME_MIN_LENGTH} characters` };
}
if (username.length > USERNAME_MAX_LENGTH) {
return { valid: false, error: `Username must be at most ${USERNAME_MAX_LENGTH} characters` };
}
if (!USERNAME_REGEX.test(username)) {
return { valid: false, error: 'Username must be lowercase alphanumeric with optional dashes, and start/end with alphanumeric' };
}
return { valid: true };
}
/**
* Validates service FQN format (service:version@username or service:version)
* Service name: lowercase alphanumeric with dots/dashes (e.g., chat, file-share, com.example.chat)
* Version: semantic versioning (1.0.0, 2.1.3-beta, etc.)
* Username: optional, lowercase alphanumeric with dashes
*/
export function validateServiceFqn(fqn: string): { valid: boolean; error?: string } {
if (typeof fqn !== 'string') {
return { valid: false, error: 'Service FQN must be a string' };
}
// Parse the FQN
const parsed = parseServiceFqn(fqn);
if (!parsed) {
return { valid: false, error: 'Service FQN must be in format: service:version[@username]' };
}
const { serviceName, version, username } = parsed;
// Validate service name (alphanumeric with dots/dashes)
const serviceNameRegex = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
if (!serviceNameRegex.test(serviceName)) {
return { valid: false, error: 'Service name must be lowercase alphanumeric with optional dots/dashes' };
}
if (serviceName.length < 1 || serviceName.length > 128) {
return { valid: false, error: 'Service name must be 1-128 characters' };
}
// Validate version (semantic versioning)
const versionRegex = /^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.-]+)?$/;
if (!versionRegex.test(version)) {
return { valid: false, error: 'Version must be semantic versioning (e.g., 1.0.0, 2.1.3-beta)' };
}
// Validate username if present
if (username) {
const usernameCheck = validateUsername(username);
if (!usernameCheck.valid) {
return usernameCheck;
}
}
return { valid: true };
}
/**
* Parse semantic version string into components
*/
export function parseVersion(version: string): { major: number; minor: number; patch: number; prerelease?: string } | null {
const match = version.match(/^([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-z0-9.-]+)?$/);
if (!match) return null;
return {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10),
prerelease: match[4]?.substring(1), // Remove leading dash
};
}
/**
* Check if two versions are compatible (same major version)
* Following semver rules: ^1.0.0 matches 1.x.x but not 2.x.x
*/
export function isVersionCompatible(requested: string, available: string): boolean {
const req = parseVersion(requested);
const avail = parseVersion(available);
if (!req || !avail) return false;
// Major version must match
if (req.major !== avail.major) return false;
// If major is 0, minor must also match (0.x.y is unstable)
if (req.major === 0 && req.minor !== avail.minor) return false;
// Available version must be >= requested version
if (avail.minor < req.minor) return false;
if (avail.minor === req.minor && avail.patch < req.patch) return false;
// Prerelease versions are only compatible with exact matches
if (req.prerelease && req.prerelease !== avail.prerelease) return false;
return true;
}
/**
* Parse service FQN into components
* Formats supported:
* - service:version@username (e.g., "chat:1.0.0@alice")
* - service:version (e.g., "chat:1.0.0") for discovery
*/
export function parseServiceFqn(fqn: string): { serviceName: string; version: string; username: string | null } | null {
if (!fqn || typeof fqn !== 'string') return null;
// Check if username is present
const atIndex = fqn.lastIndexOf('@');
let serviceVersion: string;
let username: string | null = null;
if (atIndex > 0) {
// Format: service:version@username
serviceVersion = fqn.substring(0, atIndex);
username = fqn.substring(atIndex + 1);
} else {
// Format: service:version (no username)
serviceVersion = fqn;
}
// Split service:version
const colonIndex = serviceVersion.indexOf(':');
if (colonIndex <= 0) return null; // No colon or colon at start
const serviceName = serviceVersion.substring(0, colonIndex);
const version = serviceVersion.substring(colonIndex + 1);
if (!serviceName || !version) return null;
return {
serviceName,
version,
username,
};
}
/**
* Validates timestamp is within acceptable range (prevents replay attacks)
*/
export function validateTimestamp(timestamp: number): { valid: boolean; error?: string } {
if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) {
return { valid: false, error: 'Timestamp must be a finite number' };
}
const now = Date.now();
const diff = Math.abs(now - timestamp);
if (diff > TIMESTAMP_TOLERANCE_MS) {
return { valid: false, error: `Timestamp too old or too far in future (tolerance: ${TIMESTAMP_TOLERANCE_MS / 1000}s)` };
}
return { valid: true };
}
/**
* Verifies Ed25519 signature
* @param publicKey Base64-encoded Ed25519 public key (32 bytes)
* @param signature Base64-encoded Ed25519 signature (64 bytes)
* @param message Message that was signed (UTF-8 string)
* @returns true if signature is valid, false otherwise
*/
export async function verifyEd25519Signature(
publicKey: string,
signature: string,
message: string
): Promise<boolean> {
try {
// Decode base64 to bytes
const publicKeyBytes = base64ToBytes(publicKey);
const signatureBytes = base64ToBytes(signature);
// Encode message as UTF-8
const encoder = new TextEncoder();
const messageBytes = encoder.encode(message);
// Verify signature using @noble/ed25519 (async version)
const isValid = await ed25519.verifyAsync(signatureBytes, messageBytes, publicKeyBytes);
return isValid;
} catch (err) {
console.error('Ed25519 signature verification failed:', err);
return false;
}
}
/**
* Validates a username claim request
* Verifies format, timestamp, and signature
*/
export async function validateUsernameClaim(
username: string,
publicKey: string,
signature: string,
message: string
): Promise<{ valid: boolean; error?: string }> {
// Validate username format
const usernameCheck = validateUsername(username);
if (!usernameCheck.valid) {
return usernameCheck;
}
// Parse message format: "claim:{username}:{timestamp}"
const parts = message.split(':');
if (parts.length !== 3 || parts[0] !== 'claim' || parts[1] !== username) {
return { valid: false, error: 'Invalid message format (expected: claim:{username}:{timestamp})' };
}
const timestamp = parseInt(parts[2], 10);
if (isNaN(timestamp)) {
return { valid: false, error: 'Invalid timestamp in message' };
}
// Validate timestamp
const timestampCheck = validateTimestamp(timestamp);
if (!timestampCheck.valid) {
return timestampCheck;
}
// Verify signature
const signatureValid = await verifyEd25519Signature(publicKey, signature, message);
if (!signatureValid) {
return { valid: false, error: 'Invalid signature' };
}
return { valid: true };
}
/**
* Validates a service publish signature
* Message format: publish:{username}:{serviceFqn}:{timestamp}
*/
export async function validateServicePublish(
username: string,
serviceFqn: string,
publicKey: string,
signature: string,
message: string
): Promise<{ valid: boolean; error?: string }> {
// Validate username format
const usernameCheck = validateUsername(username);
if (!usernameCheck.valid) {
return usernameCheck;
}
// Parse message format: "publish:{username}:{serviceFqn}:{timestamp}"
const parts = message.split(':');
if (parts.length !== 4 || parts[0] !== 'publish' || parts[1] !== username || parts[2] !== serviceFqn) {
return { valid: false, error: 'Invalid message format (expected: publish:{username}:{serviceFqn}:{timestamp})' };
}
const timestamp = parseInt(parts[3], 10);
if (isNaN(timestamp)) {
return { valid: false, error: 'Invalid timestamp in message' };
}
// Validate timestamp
const timestampCheck = validateTimestamp(timestamp);
if (!timestampCheck.valid) {
return timestampCheck;
}
// Verify signature
const signatureValid = await verifyEd25519Signature(publicKey, signature, message);
if (!signatureValid) {
return { valid: false, error: 'Invalid signature' };
}
return { valid: true };
}