Files
rondevu-server/src/rpc.ts
Bas van den Aakster 34babd036e Fix: Auto-claim should not validate claim message format
Auto-claim was incorrectly using validateUsernameClaim() which
expects 'claim:{username}:{timestamp}' message format. This failed
when users tried to auto-claim via publishService or getService.

Now auto-claim only:
- Validates username format
- Verifies signature against the actual message
- Claims the username

This allows implicit username claiming on first authenticated request.
2025-12-12 21:03:44 +01:00

758 lines
19 KiB
TypeScript

import { Context } from 'hono';
import { Storage } from './storage/types.ts';
import { Config } from './config.ts';
import {
validateUsernameClaim,
validateServicePublish,
validateServiceFqn,
parseServiceFqn,
isVersionCompatible,
verifyEd25519Signature,
validateAuthMessage,
validateUsername,
} from './crypto.ts';
/**
* RPC request format
*/
export interface RpcRequest {
method: string;
message: string;
signature: string;
publicKey?: string; // Optional: for auto-claiming usernames
params?: any;
}
/**
* RPC response format
*/
export interface RpcResponse {
success: boolean;
result?: any;
error?: string;
}
/**
* RPC method handler
*/
type RpcHandler = (
params: any,
message: string,
signature: string,
publicKey: string | undefined,
storage: Storage,
config: Config
) => Promise<any>;
/**
* Verify authentication for a method call
* Automatically claims username if it doesn't exist
*/
async function verifyAuth(
username: string,
message: string,
signature: string,
publicKey: string | undefined,
storage: Storage
): Promise<{ valid: boolean; error?: string }> {
// Get username record to fetch public key
let usernameRecord = await storage.getUsername(username);
// Auto-claim username if it doesn't exist
if (!usernameRecord) {
if (!publicKey) {
return {
valid: false,
error: `Username "${username}" is not claimed and no public key provided for auto-claim.`,
};
}
// Validate username format before claiming
const usernameValidation = validateUsername(username);
if (!usernameValidation.valid) {
return usernameValidation;
}
// Verify signature against the current message (not a claim message)
const signatureValid = await verifyEd25519Signature(publicKey, signature, message);
if (!signatureValid) {
return { valid: false, error: 'Invalid signature for auto-claim' };
}
// Auto-claim the username
const expiresAt = Date.now() + 365 * 24 * 60 * 60 * 1000; // 365 days
await storage.claimUsername({
username,
publicKey,
expiresAt,
});
usernameRecord = await storage.getUsername(username);
if (!usernameRecord) {
return { valid: false, error: 'Failed to claim username' };
}
}
// Verify Ed25519 signature
const isValid = await verifyEd25519Signature(
usernameRecord.publicKey,
signature,
message
);
if (!isValid) {
return { valid: false, error: 'Invalid signature' };
}
// Validate message format and timestamp
const validation = validateAuthMessage(username, message);
if (!validation.valid) {
return { valid: false, error: validation.error };
}
return { valid: true };
}
/**
* Extract username from message
*/
function extractUsername(message: string): string | null {
// Message format: method:username:...
const parts = message.split(':');
if (parts.length < 2) return null;
return parts[1];
}
/**
* RPC Method Handlers
*/
const handlers: Record<string, RpcHandler> = {
/**
* Check if username is available
*/
async getUser(params, message, signature, publicKey, storage, config) {
const { username } = params;
const claimed = await storage.getUsername(username);
if (!claimed) {
return {
username,
available: true,
};
}
return {
username: claimed.username,
available: false,
claimedAt: claimed.claimedAt,
expiresAt: claimed.expiresAt,
publicKey: claimed.publicKey,
};
},
/**
* Claim a username
*/
async claimUsername(params, message, signature, publicKey, storage, config) {
const { username, publicKey: paramPublicKey } = params;
// Validate claim
const validation = await validateUsernameClaim(
username,
paramPublicKey,
signature,
message
);
if (!validation.valid) {
throw new Error(validation.error || 'Invalid username claim');
}
// Claim the username
const expiresAt = Date.now() + 365 * 24 * 60 * 60 * 1000; // 365 days
await storage.claimUsername({
username,
publicKey: paramPublicKey,
expiresAt,
});
return { success: true, username };
},
/**
* Get service by FQN
*/
async getService(params, message, signature, publicKey, storage, config) {
const { serviceFqn, limit, offset } = params;
const username = extractUsername(message);
// Verify authentication
if (username) {
const auth = await verifyAuth(username, message, signature, publicKey, storage);
if (!auth.valid) {
throw new Error(auth.error);
}
}
// Parse and validate FQN
const fqnValidation = validateServiceFqn(serviceFqn);
if (!fqnValidation.valid) {
throw new Error(fqnValidation.error || 'Invalid service FQN');
}
const parsed = parseServiceFqn(serviceFqn);
if (!parsed) {
throw new Error('Failed to parse service FQN');
}
// Paginated discovery mode
if (limit !== undefined) {
const pageLimit = Math.min(Math.max(1, limit), 100);
const pageOffset = Math.max(0, offset || 0);
const allServices = await storage.getServicesByName(
parsed.service,
parsed.version
);
const compatibleServices = allServices.filter((s) => {
const serviceVersion = parseServiceFqn(s.serviceFqn);
return (
serviceVersion &&
isVersionCompatible(parsed.version, serviceVersion.version)
);
});
const usernameSet = new Set<string>();
const uniqueServices: any[] = [];
for (const service of compatibleServices) {
if (!usernameSet.has(service.username)) {
usernameSet.add(service.username);
const offers = await storage.getOffersByService(service.id);
const availableOffer = offers.find((o) => !o.answererUsername);
if (availableOffer) {
uniqueServices.push({
serviceId: service.id,
username: service.username,
serviceFqn: service.serviceFqn,
offerId: availableOffer.id,
sdp: availableOffer.sdp,
createdAt: service.createdAt,
expiresAt: service.expiresAt,
});
}
}
}
const paginatedServices = uniqueServices.slice(
pageOffset,
pageOffset + pageLimit
);
return {
services: paginatedServices,
count: paginatedServices.length,
limit: pageLimit,
offset: pageOffset,
};
}
// Direct lookup with username
if (parsed.username) {
const service = await storage.getServiceByFqn(serviceFqn);
if (!service) {
throw new Error('Service not found');
}
const offers = await storage.getOffersByService(service.id);
const availableOffer = offers.find((o) => !o.answererUsername);
if (!availableOffer) {
throw new Error('Service has no available offers');
}
return {
serviceId: service.id,
username: service.username,
serviceFqn: service.serviceFqn,
offerId: availableOffer.id,
sdp: availableOffer.sdp,
createdAt: service.createdAt,
expiresAt: service.expiresAt,
};
}
// Random discovery without username
const allServices = await storage.getServicesByName(
parsed.service,
parsed.version
);
const compatibleServices = allServices.filter((s) => {
const serviceVersion = parseServiceFqn(s.serviceFqn);
return (
serviceVersion &&
isVersionCompatible(parsed.version, serviceVersion.version)
);
});
if (compatibleServices.length === 0) {
throw new Error('No services found');
}
const randomService =
compatibleServices[
Math.floor(Math.random() * compatibleServices.length)
];
const offers = await storage.getOffersByService(randomService.id);
const availableOffer = offers.find((o) => !o.answererUsername);
if (!availableOffer) {
throw new Error('Service has no available offers');
}
return {
serviceId: randomService.id,
username: randomService.username,
serviceFqn: randomService.serviceFqn,
offerId: availableOffer.id,
sdp: availableOffer.sdp,
createdAt: randomService.createdAt,
expiresAt: randomService.expiresAt,
};
},
/**
* Publish a service
*/
async publishService(params, message, signature, publicKey, storage, config) {
const { serviceFqn, offers, ttl } = params;
const username = extractUsername(message);
if (!username) {
throw new Error('Username required for service publishing');
}
// Verify authentication
const auth = await verifyAuth(username, message, signature, publicKey, storage);
if (!auth.valid) {
throw new Error(auth.error);
}
// Validate service FQN
const fqnValidation = validateServiceFqn(serviceFqn);
if (!fqnValidation.valid) {
throw new Error(fqnValidation.error || 'Invalid service FQN');
}
const parsed = parseServiceFqn(serviceFqn);
if (!parsed || !parsed.username) {
throw new Error('Service FQN must include username');
}
if (parsed.username !== username) {
throw new Error('Service FQN username must match authenticated username');
}
// Validate offers
if (!offers || !Array.isArray(offers) || offers.length === 0) {
throw new Error('Must provide at least one offer');
}
if (offers.length > config.maxOffersPerRequest) {
throw new Error(
`Too many offers (max ${config.maxOffersPerRequest})`
);
}
// Create service
const now = Date.now();
const offerTtl =
ttl !== undefined
? Math.min(
Math.max(ttl, config.offerMinTtl),
config.offerMaxTtl
)
: config.offerDefaultTtl;
const expiresAt = now + offerTtl;
const service = await storage.createService({
username,
serviceFqn,
serviceName: parsed.service,
version: parsed.version,
expiresAt,
});
// Create offers
const createdOffers = [];
for (const offer of offers) {
const createdOffer = await storage.createOffer({
username,
serviceId: service.id,
serviceFqn,
sdp: offer.sdp,
ttl: offerTtl,
});
createdOffers.push({
offerId: createdOffer.id,
sdp: createdOffer.sdp,
createdAt: createdOffer.createdAt,
expiresAt: createdOffer.expiresAt,
});
}
return {
serviceId: service.id,
username: service.username,
serviceFqn: service.serviceFqn,
offers: createdOffers,
createdAt: service.createdAt,
expiresAt: service.expiresAt,
};
},
/**
* Delete a service
*/
async deleteService(params, message, signature, publicKey, storage, config) {
const { serviceFqn } = params;
const username = extractUsername(message);
if (!username) {
throw new Error('Username required');
}
// Verify authentication
const auth = await verifyAuth(username, message, signature, publicKey, storage);
if (!auth.valid) {
throw new Error(auth.error);
}
const parsed = parseServiceFqn(serviceFqn);
if (!parsed || !parsed.username) {
throw new Error('Service FQN must include username');
}
const service = await storage.getServiceByFqn(serviceFqn);
if (!service) {
throw new Error('Service not found');
}
const deleted = await storage.deleteService(service.id, username);
if (!deleted) {
throw new Error('Service not found or not owned by this username');
}
return { success: true };
},
/**
* Answer an offer
*/
async answerOffer(params, message, signature, publicKey, storage, config) {
const { serviceFqn, offerId, sdp } = params;
const username = extractUsername(message);
if (!username) {
throw new Error('Username required');
}
// Verify authentication
const auth = await verifyAuth(username, message, signature, publicKey, storage);
if (!auth.valid) {
throw new Error(auth.error);
}
if (!sdp || typeof sdp !== 'string' || sdp.length === 0) {
throw new Error('Invalid SDP');
}
if (sdp.length > 64 * 1024) {
throw new Error('SDP too large (max 64KB)');
}
const offer = await storage.getOfferById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
if (offer.answererUsername) {
throw new Error('Offer already answered');
}
await storage.answerOffer(offerId, username, sdp);
return { success: true, offerId };
},
/**
* Get answer for an offer
*/
async getOfferAnswer(params, message, signature, publicKey, storage, config) {
const { serviceFqn, offerId } = params;
const username = extractUsername(message);
if (!username) {
throw new Error('Username required');
}
// Verify authentication
const auth = await verifyAuth(username, message, signature, publicKey, storage);
if (!auth.valid) {
throw new Error(auth.error);
}
const offer = await storage.getOfferById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
if (offer.username !== username) {
throw new Error('Not authorized to access this offer');
}
if (!offer.answererUsername || !offer.answerSdp) {
throw new Error('Offer not yet answered');
}
return {
sdp: offer.answerSdp,
offerId: offer.id,
answererId: offer.answererUsername,
answeredAt: offer.answeredAt,
};
},
/**
* Combined polling for answers and ICE candidates
*/
async poll(params, message, signature, publicKey, storage, config) {
const { since } = params;
const username = extractUsername(message);
if (!username) {
throw new Error('Username required');
}
// Verify authentication
const auth = await verifyAuth(username, message, signature, publicKey, storage);
if (!auth.valid) {
throw new Error(auth.error);
}
const sinceTimestamp = since || 0;
// Get all answered offers
const answeredOffers = await storage.getAnsweredOffers(username);
const filteredAnswers = answeredOffers.filter(
(offer) => offer.answeredAt && offer.answeredAt > sinceTimestamp
);
// Get all user's offers
const allOffers = await storage.getOffersByUsername(username);
// For each offer, get ICE candidates from both sides
const iceCandidatesByOffer: Record<string, any[]> = {};
for (const offer of allOffers) {
const offererCandidates = await storage.getIceCandidates(
offer.id,
'offerer',
sinceTimestamp
);
const answererCandidates = await storage.getIceCandidates(
offer.id,
'answerer',
sinceTimestamp
);
const allCandidates = [
...offererCandidates.map((c: any) => ({
...c,
role: 'offerer' as const,
})),
...answererCandidates.map((c: any) => ({
...c,
role: 'answerer' as const,
})),
];
if (allCandidates.length > 0) {
const isOfferer = offer.username === username;
const filtered = allCandidates.filter((c) =>
isOfferer ? c.role === 'answerer' : c.role === 'offerer'
);
if (filtered.length > 0) {
iceCandidatesByOffer[offer.id] = filtered;
}
}
}
return {
answers: filteredAnswers.map((offer) => ({
offerId: offer.id,
serviceId: offer.serviceId,
answererId: offer.answererUsername,
sdp: offer.answerSdp,
answeredAt: offer.answeredAt,
})),
iceCandidates: iceCandidatesByOffer,
};
},
/**
* Add ICE candidates
*/
async addIceCandidates(params, message, signature, publicKey, storage, config) {
const { serviceFqn, offerId, candidates } = params;
const username = extractUsername(message);
if (!username) {
throw new Error('Username required');
}
// Verify authentication
const auth = await verifyAuth(username, message, signature, publicKey, storage);
if (!auth.valid) {
throw new Error(auth.error);
}
if (!Array.isArray(candidates) || candidates.length === 0) {
throw new Error('Missing or invalid required parameter: candidates');
}
const offer = await storage.getOfferById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
const role = offer.username === username ? 'offerer' : 'answerer';
const count = await storage.addIceCandidates(
offerId,
username,
role,
candidates
);
return { count, offerId };
},
/**
* Get ICE candidates
*/
async getIceCandidates(params, message, signature, publicKey, storage, config) {
const { serviceFqn, offerId, since } = params;
const username = extractUsername(message);
if (!username) {
throw new Error('Username required');
}
// Verify authentication
const auth = await verifyAuth(username, message, signature, publicKey, storage);
if (!auth.valid) {
throw new Error(auth.error);
}
const sinceTimestamp = since || 0;
const offer = await storage.getOfferById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
const isOfferer = offer.username === username;
const role = isOfferer ? 'answerer' : 'offerer';
const candidates = await storage.getIceCandidates(
offerId,
role,
sinceTimestamp
);
return {
candidates: candidates.map((c: any) => ({
candidate: c.candidate,
createdAt: c.createdAt,
})),
offerId,
};
},
};
/**
* Handle RPC batch request
*/
export async function handleRpc(
requests: RpcRequest[],
storage: Storage,
config: Config
): Promise<RpcResponse[]> {
const responses: RpcResponse[] = [];
for (const request of requests) {
try {
const { method, message, signature, publicKey, params } = request;
// Validate request
if (!method || typeof method !== 'string') {
responses.push({
success: false,
error: 'Missing or invalid method',
});
continue;
}
if (!message || typeof message !== 'string') {
responses.push({
success: false,
error: 'Missing or invalid message',
});
continue;
}
if (!signature || typeof signature !== 'string') {
responses.push({
success: false,
error: 'Missing or invalid signature',
});
continue;
}
// Get handler
const handler = handlers[method];
if (!handler) {
responses.push({
success: false,
error: `Unknown method: ${method}`,
});
continue;
}
// Execute handler
const result = await handler(
params || {},
message,
signature,
publicKey,
storage,
config
);
responses.push({
success: true,
result,
});
} catch (err) {
responses.push({
success: false,
error: (err as Error).message || 'Internal server error',
});
}
}
return responses;
}