Unified Ed25519 authentication - remove peer_id/credentials system

BREAKING CHANGE: Remove dual authentication system

- Remove POST /register endpoint - no longer needed
- Remove peer_id/secret credential-based auth
- All authentication now uses username + Ed25519 signatures
- Anonymous users can generate random usernames (anon-{timestamp}-{hex})

Database schema:
- Rename peer_id → username in offers table
- Rename answerer_peer_id → answerer_username in offers table
- Rename peer_id → username in ice_candidates table
- Remove secret column from offers table
- Add FK constraints for username columns

Storage layer:
- Update D1 and SQLite implementations
- All methods use username instead of peerId
- Remove secret-related code

Auth middleware:
- Replace validateCredentials() with Ed25519 signature verification
- Extract auth from request body (POST) or query params (GET)
- Verify signature against username's public key
- Validate message format and timestamp

Crypto utilities:
- Remove generatePeerId(), encryptPeerId(), decryptPeerId(), validateCredentials()
- Add generateAnonymousUsername() - creates anon-{timestamp}-{random}
- Add validateAuthMessage() - validates auth message format

Config:
- Remove authSecret from Config interface (no longer needed)

All server endpoints updated to use getAuthenticatedUsername()
This commit is contained in:
2025-12-10 22:06:45 +01:00
parent 95596dd462
commit 51fe405440
8 changed files with 370 additions and 479 deletions

View File

@@ -1,4 +1,5 @@
-- Fresh schema for Rondevu v0.4.1+ -- Fresh schema for Rondevu v0.5.0+
-- Unified Ed25519 authentication - username/keypair only
-- This is the complete schema without migration steps -- This is the complete schema without migration steps
-- Drop existing tables if they exist -- Drop existing tables if they exist
@@ -7,44 +8,7 @@ DROP TABLE IF EXISTS services;
DROP TABLE IF EXISTS offers; DROP TABLE IF EXISTS offers;
DROP TABLE IF EXISTS usernames; DROP TABLE IF EXISTS usernames;
-- Offers table -- Usernames table (now required for all users, even anonymous)
CREATE TABLE offers (
id TEXT PRIMARY KEY,
peer_id TEXT NOT NULL,
service_id TEXT,
sdp TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
secret TEXT,
answerer_peer_id TEXT,
answer_sdp TEXT,
answered_at INTEGER
);
CREATE INDEX idx_offers_peer ON offers(peer_id);
CREATE INDEX idx_offers_service ON offers(service_id);
CREATE INDEX idx_offers_expires ON offers(expires_at);
CREATE INDEX idx_offers_last_seen ON offers(last_seen);
CREATE INDEX idx_offers_answerer ON offers(answerer_peer_id);
-- ICE candidates table
CREATE TABLE ice_candidates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
offer_id TEXT NOT NULL,
peer_id TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
candidate TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
);
CREATE INDEX idx_ice_offer ON ice_candidates(offer_id);
CREATE INDEX idx_ice_peer ON ice_candidates(peer_id);
CREATE INDEX idx_ice_role ON ice_candidates(role);
CREATE INDEX idx_ice_created ON ice_candidates(created_at);
-- Usernames table
CREATE TABLE usernames ( CREATE TABLE usernames (
username TEXT PRIMARY KEY, username TEXT PRIMARY KEY,
public_key TEXT NOT NULL UNIQUE, public_key TEXT NOT NULL UNIQUE,
@@ -75,3 +39,43 @@ CREATE INDEX idx_services_fqn ON services(service_fqn);
CREATE INDEX idx_services_discovery ON services(service_name, version); CREATE INDEX idx_services_discovery ON services(service_name, version);
CREATE INDEX idx_services_username ON services(username); CREATE INDEX idx_services_username ON services(username);
CREATE INDEX idx_services_expires ON services(expires_at); CREATE INDEX idx_services_expires ON services(expires_at);
-- Offers table (now uses username instead of peer_id)
CREATE TABLE offers (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
service_id TEXT,
service_fqn TEXT,
sdp TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
answerer_username TEXT,
answer_sdp TEXT,
answered_at INTEGER,
FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE,
FOREIGN KEY (answerer_username) REFERENCES usernames(username) ON DELETE SET NULL
);
CREATE INDEX idx_offers_username ON offers(username);
CREATE INDEX idx_offers_service ON offers(service_id);
CREATE INDEX idx_offers_expires ON offers(expires_at);
CREATE INDEX idx_offers_last_seen ON offers(last_seen);
CREATE INDEX idx_offers_answerer ON offers(answerer_username);
-- ICE candidates table (now uses username instead of peer_id)
CREATE TABLE ice_candidates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
offer_id TEXT NOT NULL,
username TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
candidate TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE,
FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE
);
CREATE INDEX idx_ice_offer ON ice_candidates(offer_id);
CREATE INDEX idx_ice_username ON ice_candidates(username);
CREATE INDEX idx_ice_role ON ice_candidates(role);
CREATE INDEX idx_ice_created ON ice_candidates(created_at);

View File

@@ -2,8 +2,8 @@ import { Hono } from 'hono';
import { cors } from 'hono/cors'; import { cors } from 'hono/cors';
import { Storage } from './storage/types.ts'; import { Storage } from './storage/types.ts';
import { Config } from './config.ts'; import { Config } from './config.ts';
import { createAuthMiddleware, getAuthenticatedPeerId } from './middleware/auth.ts'; import { createAuthMiddleware, getAuthenticatedUsername } from './middleware/auth.ts';
import { generatePeerId, encryptPeerId, validateUsernameClaim, validateServicePublish, validateServiceFqn, parseServiceFqn, isVersionCompatible } from './crypto.ts'; import { validateUsernameClaim, validateServicePublish, validateServiceFqn, parseServiceFqn, isVersionCompatible } from './crypto.ts';
import type { Context } from 'hono'; import type { Context } from 'hono';
/** /**
@@ -14,7 +14,7 @@ export function createApp(storage: Storage, config: Config) {
const app = new Hono(); const app = new Hono();
// Create auth middleware // Create auth middleware
const authMiddleware = createAuthMiddleware(config.authSecret); const authMiddleware = createAuthMiddleware(storage);
// Enable CORS // Enable CORS
app.use('/*', cors({ app.use('/*', cors({
@@ -60,24 +60,6 @@ export function createApp(storage: Storage, config: Config) {
}); });
}); });
/**
* POST /register
* Register a new peer
*/
app.post('/register', async (c) => {
try {
const peerId = generatePeerId();
const secret = await encryptPeerId(peerId, config.authSecret);
return c.json({
peerId,
secret
}, 200);
} catch (err) {
console.error('Error registering peer:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
// ===== User Management (RESTful) ===== // ===== User Management (RESTful) =====
@@ -192,7 +174,7 @@ export function createApp(storage: Storage, config: Config) {
// Get available offer from this service // Get available offer from this service
const serviceOffers = await storage.getOffersForService(service.id); const serviceOffers = await storage.getOffersForService(service.id);
const availableOffer = serviceOffers.find(offer => !offer.answererPeerId); const availableOffer = serviceOffers.find(offer => !offer.answererUsername);
if (!availableOffer) { if (!availableOffer) {
return c.json({ return c.json({
@@ -231,7 +213,7 @@ export function createApp(storage: Storage, config: Config) {
const servicesWithOffers = await Promise.all( const servicesWithOffers = await Promise.all(
services.map(async (service) => { services.map(async (service) => {
const offers = await storage.getOffersForService(service.id); const offers = await storage.getOffersForService(service.id);
const availableOffer = offers.find(offer => !offer.answererPeerId); const availableOffer = offers.find(offer => !offer.answererUsername);
return availableOffer ? { return availableOffer ? {
serviceId: service.id, serviceId: service.id,
username: service.username, username: service.username,
@@ -265,7 +247,7 @@ export function createApp(storage: Storage, config: Config) {
// Get available offer // Get available offer
const offers = await storage.getOffersForService(service.id); const offers = await storage.getOffersForService(service.id);
const availableOffer = offers.find(offer => !offer.answererPeerId); const availableOffer = offers.find(offer => !offer.answererUsername);
if (!availableOffer) { if (!availableOffer) {
return c.json({ return c.json({
@@ -351,7 +333,7 @@ export function createApp(storage: Storage, config: Config) {
} }
// Calculate expiry // Calculate expiry
const peerId = getAuthenticatedPeerId(c); const authenticatedUsername = getAuthenticatedUsername(c);
const offerTtl = Math.min( const offerTtl = Math.min(
Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl), Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl),
config.offerMaxTtl config.offerMaxTtl
@@ -360,7 +342,7 @@ export function createApp(storage: Storage, config: Config) {
// Prepare offer requests // Prepare offer requests
const offerRequests = offers.map(offer => ({ const offerRequests = offers.map(offer => ({
peerId, username: authenticatedUsername,
sdp: offer.sdp, sdp: offer.sdp,
expiresAt expiresAt
})); }));
@@ -469,9 +451,9 @@ export function createApp(storage: Storage, config: Config) {
return c.json({ error: 'Offer not found' }, 404); return c.json({ error: 'Offer not found' }, 404);
} }
const answererPeerId = getAuthenticatedPeerId(c); const answererUsername = getAuthenticatedUsername(c);
const result = await storage.answerOffer(offerId, answererPeerId, sdp); const result = await storage.answerOffer(offerId, answererUsername, sdp);
if (!result.success) { if (!result.success) {
return c.json({ error: result.error }, 400); return c.json({ error: result.error }, 400);
@@ -495,7 +477,7 @@ export function createApp(storage: Storage, config: Config) {
try { try {
const serviceFqn = decodeURIComponent(c.req.param('fqn')); const serviceFqn = decodeURIComponent(c.req.param('fqn'));
const offerId = c.req.param('offerId'); const offerId = c.req.param('offerId');
const peerId = getAuthenticatedPeerId(c); const username = getAuthenticatedUsername(c);
// Get the offer // Get the offer
const offer = await storage.getOfferById(offerId); const offer = await storage.getOfferById(offerId);
@@ -504,7 +486,7 @@ export function createApp(storage: Storage, config: Config) {
} }
// Verify ownership // Verify ownership
if (offer.peerId !== peerId) { if (offer.username !== username) {
return c.json({ error: 'Not authorized to access this offer' }, 403); return c.json({ error: 'Not authorized to access this offer' }, 403);
} }
@@ -514,7 +496,7 @@ export function createApp(storage: Storage, config: Config) {
return c.json({ return c.json({
offerId: offer.id, offerId: offer.id,
answererId: offer.answererPeerId, answererId: offer.answererUsername,
sdp: offer.answerSdp, sdp: offer.answerSdp,
answeredAt: offer.answeredAt answeredAt: offer.answeredAt
}, 200); }, 200);
@@ -530,11 +512,11 @@ export function createApp(storage: Storage, config: Config) {
*/ */
app.get('/offers/answered', authMiddleware, async (c) => { app.get('/offers/answered', authMiddleware, async (c) => {
try { try {
const peerId = getAuthenticatedPeerId(c); const username = getAuthenticatedUsername(c);
const since = c.req.query('since'); const since = c.req.query('since');
const sinceTimestamp = since ? parseInt(since, 10) : 0; const sinceTimestamp = since ? parseInt(since, 10) : 0;
const offers = await storage.getAnsweredOffers(peerId); const offers = await storage.getAnsweredOffers(username);
// Filter by timestamp if provided // Filter by timestamp if provided
const filteredOffers = since const filteredOffers = since
@@ -545,7 +527,7 @@ export function createApp(storage: Storage, config: Config) {
offers: filteredOffers.map(offer => ({ offers: filteredOffers.map(offer => ({
offerId: offer.id, offerId: offer.id,
serviceId: offer.serviceId, serviceId: offer.serviceId,
answererId: offer.answererPeerId, answererId: offer.answererUsername,
sdp: offer.answerSdp, sdp: offer.answerSdp,
answeredAt: offer.answeredAt answeredAt: offer.answeredAt
})) }))
@@ -563,18 +545,18 @@ export function createApp(storage: Storage, config: Config) {
*/ */
app.get('/offers/poll', authMiddleware, async (c) => { app.get('/offers/poll', authMiddleware, async (c) => {
try { try {
const peerId = getAuthenticatedPeerId(c); const username = getAuthenticatedUsername(c);
const since = c.req.query('since'); const since = c.req.query('since');
const sinceTimestamp = since ? parseInt(since, 10) : 0; const sinceTimestamp = since ? parseInt(since, 10) : 0;
// Get all answered offers // Get all answered offers
const answeredOffers = await storage.getAnsweredOffers(peerId); const answeredOffers = await storage.getAnsweredOffers(username);
const filteredAnswers = since const filteredAnswers = since
? answeredOffers.filter(offer => offer.answeredAt && offer.answeredAt > sinceTimestamp) ? answeredOffers.filter(offer => offer.answeredAt && offer.answeredAt > sinceTimestamp)
: answeredOffers; : answeredOffers;
// Get all peer's offers // Get all user's offers
const allOffers = await storage.getOffersByPeerId(peerId); const allOffers = await storage.getOffersByUsername(username);
// For each offer, get ICE candidates from both sides // For each offer, get ICE candidates from both sides
const iceCandidatesByOffer: Record<string, any[]> = {}; const iceCandidatesByOffer: Record<string, any[]> = {};
@@ -587,7 +569,7 @@ export function createApp(storage: Storage, config: Config) {
allCandidates.push({ allCandidates.push({
candidate: c.candidate, candidate: c.candidate,
role: 'offerer', role: 'offerer',
peerId: c.peerId, username: c.username,
createdAt: c.createdAt createdAt: c.createdAt
}); });
} }
@@ -598,7 +580,7 @@ export function createApp(storage: Storage, config: Config) {
allCandidates.push({ allCandidates.push({
candidate: c.candidate, candidate: c.candidate,
role: 'answerer', role: 'answerer',
peerId: c.peerId, username: c.username,
createdAt: c.createdAt createdAt: c.createdAt
}); });
} }
@@ -612,7 +594,7 @@ export function createApp(storage: Storage, config: Config) {
answers: filteredAnswers.map(offer => ({ answers: filteredAnswers.map(offer => ({
offerId: offer.id, offerId: offer.id,
serviceId: offer.serviceId, serviceId: offer.serviceId,
answererId: offer.answererPeerId, answererId: offer.answererUsername,
sdp: offer.answerSdp, sdp: offer.answerSdp,
answeredAt: offer.answeredAt answeredAt: offer.answeredAt
})), })),
@@ -639,7 +621,7 @@ export function createApp(storage: Storage, config: Config) {
return c.json({ error: 'Missing or invalid required parameter: candidates' }, 400); return c.json({ error: 'Missing or invalid required parameter: candidates' }, 400);
} }
const peerId = getAuthenticatedPeerId(c); const username = getAuthenticatedUsername(c);
// Get offer to determine role // Get offer to determine role
const offer = await storage.getOfferById(offerId); const offer = await storage.getOfferById(offerId);
@@ -648,9 +630,9 @@ export function createApp(storage: Storage, config: Config) {
} }
// Determine role (offerer or answerer) // Determine role (offerer or answerer)
const role = offer.peerId === peerId ? 'offerer' : 'answerer'; const role = offer.username === username ? 'offerer' : 'answerer';
const count = await storage.addIceCandidates(offerId, peerId, role, candidates); const count = await storage.addIceCandidates(offerId, username, role, candidates);
return c.json({ count, offerId }, 200); return c.json({ count, offerId }, 200);
} catch (err) { } catch (err) {
@@ -668,7 +650,7 @@ export function createApp(storage: Storage, config: Config) {
const serviceFqn = decodeURIComponent(c.req.param('fqn')); const serviceFqn = decodeURIComponent(c.req.param('fqn'));
const offerId = c.req.param('offerId'); const offerId = c.req.param('offerId');
const since = c.req.query('since'); const since = c.req.query('since');
const peerId = getAuthenticatedPeerId(c); const username = getAuthenticatedUsername(c);
// Get offer to determine role // Get offer to determine role
const offer = await storage.getOfferById(offerId); const offer = await storage.getOfferById(offerId);
@@ -677,7 +659,7 @@ export function createApp(storage: Storage, config: Config) {
} }
// Get candidates for opposite role // Get candidates for opposite role
const targetRole = offer.peerId === peerId ? 'answerer' : 'offerer'; const targetRole = offer.username === username ? 'answerer' : 'offerer';
const sinceTimestamp = since ? parseInt(since, 10) : undefined; const sinceTimestamp = since ? parseInt(since, 10) : undefined;
const candidates = await storage.getIceCandidates(offerId, targetRole, sinceTimestamp); const candidates = await storage.getIceCandidates(offerId, targetRole, sinceTimestamp);

View File

@@ -1,5 +1,3 @@
import { generateSecretKey } from './crypto.ts';
/** /**
* Application configuration * Application configuration
* Reads from environment variables with sensible defaults * Reads from environment variables with sensible defaults
@@ -10,7 +8,6 @@ export interface Config {
storagePath: string; storagePath: string;
corsOrigins: string[]; corsOrigins: string[];
version: string; version: string;
authSecret: string;
offerDefaultTtl: number; offerDefaultTtl: number;
offerMaxTtl: number; offerMaxTtl: number;
offerMinTtl: number; offerMinTtl: number;
@@ -22,15 +19,6 @@ export interface Config {
* Loads configuration from environment variables * Loads configuration from environment variables
*/ */
export function loadConfig(): Config { export function loadConfig(): Config {
// Generate or load auth secret
let authSecret = process.env.AUTH_SECRET;
if (!authSecret) {
authSecret = generateSecretKey();
console.warn('WARNING: No AUTH_SECRET provided. Generated temporary secret:', authSecret);
console.warn('All peer credentials will be invalidated on server restart.');
console.warn('Set AUTH_SECRET environment variable to persist credentials across restarts.');
}
return { return {
port: parseInt(process.env.PORT || '3000', 10), port: parseInt(process.env.PORT || '3000', 10),
storageType: (process.env.STORAGE_TYPE || 'sqlite') as 'sqlite' | 'memory', storageType: (process.env.STORAGE_TYPE || 'sqlite') as 'sqlite' | 'memory',
@@ -39,7 +27,6 @@ export function loadConfig(): Config {
? process.env.CORS_ORIGINS.split(',').map(o => o.trim()) ? process.env.CORS_ORIGINS.split(',').map(o => o.trim())
: ['*'], : ['*'],
version: process.env.VERSION || 'unknown', version: process.env.VERSION || 'unknown',
authSecret,
offerDefaultTtl: parseInt(process.env.OFFER_DEFAULT_TTL || '60000', 10), offerDefaultTtl: parseInt(process.env.OFFER_DEFAULT_TTL || '60000', 10),
offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || '86400000', 10), offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || '86400000', 10),
offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || '60000', 10), offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || '60000', 10),

View File

@@ -1,7 +1,7 @@
/** /**
* Crypto utilities for stateless peer authentication * Crypto utilities for Ed25519-based authentication
* Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
* Uses @noble/ed25519 for Ed25519 signature verification * Uses @noble/ed25519 for Ed25519 signature verification
* Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
*/ */
import * as ed25519 from '@noble/ed25519'; import * as ed25519 from '@noble/ed25519';
@@ -12,10 +12,6 @@ ed25519.hashes.sha512Async = async (message: Uint8Array) => {
return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource)); 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 // Username validation
const USERNAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; const USERNAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
const USERNAME_MIN_LENGTH = 3; const USERNAME_MIN_LENGTH = 3;
@@ -25,30 +21,15 @@ const USERNAME_MAX_LENGTH = 32;
const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;
/** /**
* Generates a random peer ID (16 bytes = 32 hex chars) * Generates an anonymous username for users who don't want to claim one
* Format: anon-{timestamp}-{random}
* This reduces collision probability to near-zero
*/ */
export function generatePeerId(): string { export function generateAnonymousUsername(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16)); const timestamp = Date.now().toString(36);
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); const random = crypto.getRandomValues(new Uint8Array(3));
} const hex = Array.from(random).map(b => b.toString(16).padStart(2, '0')).join('');
return `anon-${timestamp}-${hex}`;
/**
* 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;
} }
/** /**
@@ -70,99 +51,40 @@ function base64ToBytes(base64: string): Uint8Array {
} }
/** /**
* Encrypts a peer ID using the server secret key * Validates a generic auth message format
* Returns base64-encoded encrypted data (IV + ciphertext) * Expected format: action:username:params:timestamp
* Validates that the message contains the expected username and has a valid timestamp
*/ */
export async function encryptPeerId(peerId: string, secretKeyHex: string): Promise<string> { export function validateAuthMessage(
const keyBytes = hexToBytes(secretKeyHex); expectedUsername: string,
message: string
): { valid: boolean; error?: string } {
const parts = message.split(':');
if (keyBytes.length !== KEY_LENGTH) { if (parts.length < 3) {
throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`); return { valid: false, error: 'Invalid message format: must have at least action:username:timestamp' };
} }
// Import key // Extract username (second part) and timestamp (last part)
const key = await crypto.subtle.importKey( const messageUsername = parts[1];
'raw', const timestamp = parseInt(parts[parts.length - 1], 10);
keyBytes,
{ name: ALGORITHM, length: 256 },
false,
['encrypt']
);
// Generate random IV // Validate username matches
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); if (messageUsername !== expectedUsername) {
return { valid: false, error: 'Username in message does not match authenticated username' };
// 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');
} }
}
/** // Validate timestamp
* Validates that a peer ID and secret match if (isNaN(timestamp)) {
* Returns true if valid, false otherwise return { valid: false, error: 'Invalid timestamp in message' };
*/
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;
} }
const timestampCheck = validateTimestamp(timestamp);
if (!timestampCheck.valid) {
return timestampCheck;
}
return { valid: true };
} }
// ===== Username and Ed25519 Signature Utilities ===== // ===== Username and Ed25519 Signature Utilities =====

View File

@@ -1,51 +1,84 @@
import { Context, Next } from 'hono'; import { Context, Next } from 'hono';
import { validateCredentials } from '../crypto.ts'; import { verifyEd25519Signature, validateAuthMessage } from '../crypto.ts';
import { Storage } from '../storage/types.ts';
/** /**
* Authentication middleware for Rondevu * Authentication middleware for Rondevu - Ed25519 signature-based
* Validates Bearer token in format: {peerId}:{encryptedSecret} * Verifies username ownership via Ed25519 signatures
*
* For POST requests: Extracts username, signature, message from request body
* For GET requests: Extracts username, signature, message from query params
*/ */
export function createAuthMiddleware(authSecret: string) { export function createAuthMiddleware(storage: Storage) {
return async (c: Context, next: Next) => { return async (c: Context, next: Next) => {
const authHeader = c.req.header('Authorization'); let username: string | undefined;
let signature: string | undefined;
let message: string | undefined;
if (!authHeader) { // Determine if this is a GET or POST request
return c.json({ error: 'Missing Authorization header' }, 401); if (c.req.method === 'GET') {
// Extract from query params
const query = c.req.query();
username = query.username;
signature = query.signature;
message = query.message;
} else {
// Extract from request body
try {
const body = await c.req.json();
username = body.username;
signature = body.signature;
message = body.message;
} catch (err) {
return c.json({ error: 'Invalid JSON body' }, 400);
}
} }
// Expect format: Bearer {peerId}:{secret} // Validate presence of auth fields
const parts = authHeader.split(' '); if (!username || !signature || !message) {
if (parts.length !== 2 || parts[0] !== 'Bearer') { return c.json({
return c.json({ error: 'Invalid Authorization header format. Expected: Bearer {peerId}:{secret}' }, 401); error: 'Missing authentication fields: username, signature, and message are required'
}, 401);
} }
const credentials = parts[1].split(':'); // Get username record to fetch public key
if (credentials.length !== 2) { const usernameRecord = await storage.getUsername(username);
return c.json({ error: 'Invalid credentials format. Expected: {peerId}:{secret}' }, 401); if (!usernameRecord) {
return c.json({
error: `Username "${username}" is not claimed. Please claim username first.`
}, 401);
} }
const [peerId, encryptedSecret] = credentials; // Verify Ed25519 signature
const isValid = await verifyEd25519Signature(
// Validate credentials (async operation) usernameRecord.publicKey,
const isValid = await validateCredentials(peerId, encryptedSecret, authSecret); signature,
message
);
if (!isValid) { if (!isValid) {
return c.json({ error: 'Invalid credentials' }, 401); return c.json({ error: 'Invalid signature' }, 401);
} }
// Attach peer ID to context for use in handlers // Validate message format and timestamp
c.set('peerId', peerId); const validation = validateAuthMessage(username, message);
if (!validation.valid) {
return c.json({ error: validation.error }, 401);
}
// Store authenticated username in context
c.set('username', username);
await next(); await next();
}; };
} }
/** /**
* Helper to get authenticated peer ID from context * Helper to get authenticated username from context
*/ */
export function getAuthenticatedPeerId(c: Context): string { export function getAuthenticatedUsername(c: Context): string {
const peerId = c.get('peerId'); const username = c.get('username');
if (!peerId) { if (!username) {
throw new Error('No authenticated peer ID in context'); throw new Error('No authenticated username in context');
} }
return peerId; return username;
} }

View File

@@ -37,29 +37,28 @@ export class D1Storage implements Storage {
-- WebRTC signaling offers -- WebRTC signaling offers
CREATE TABLE IF NOT EXISTS offers ( CREATE TABLE IF NOT EXISTS offers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
peer_id TEXT NOT NULL, username TEXT NOT NULL,
service_id TEXT, service_id TEXT,
sdp TEXT NOT NULL, sdp TEXT NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL, last_seen INTEGER NOT NULL,
secret TEXT, answerer_username TEXT,
answerer_peer_id TEXT,
answer_sdp TEXT, answer_sdp TEXT,
answered_at INTEGER answered_at INTEGER
); );
CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id); CREATE INDEX IF NOT EXISTS idx_offers_username ON offers(username);
CREATE INDEX IF NOT EXISTS idx_offers_service ON offers(service_id); CREATE INDEX IF NOT EXISTS idx_offers_service ON offers(service_id);
CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at); CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen); CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id); CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_username);
-- ICE candidates table -- ICE candidates table
CREATE TABLE IF NOT EXISTS ice_candidates ( CREATE TABLE IF NOT EXISTS ice_candidates (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
offer_id TEXT NOT NULL, offer_id TEXT NOT NULL,
peer_id TEXT NOT NULL, username TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')), role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
candidate TEXT NOT NULL, candidate TEXT NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
@@ -67,7 +66,7 @@ export class D1Storage implements Storage {
); );
CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id); CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id); CREATE INDEX IF NOT EXISTS idx_ice_username ON ice_candidates(username);
CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at); CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
-- Usernames table -- Usernames table
@@ -115,31 +114,31 @@ export class D1Storage implements Storage {
const now = Date.now(); const now = Date.now();
await this.db.prepare(` await this.db.prepare(`
INSERT INTO offers (id, peer_id, service_id, sdp, created_at, expires_at, last_seen, secret) INSERT INTO offers (id, username, service_id, sdp, created_at, expires_at, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`).bind(id, offer.peerId, offer.serviceId || null, offer.sdp, now, offer.expiresAt, now, offer.secret || null).run(); `).bind(id, offer.username, offer.serviceId || null, offer.sdp, now, offer.expiresAt, now).run();
created.push({ created.push({
id, id,
peerId: offer.peerId, username: offer.username,
serviceId: offer.serviceId, serviceId: offer.serviceId,
serviceFqn: offer.serviceFqn,
sdp: offer.sdp, sdp: offer.sdp,
createdAt: now, createdAt: now,
expiresAt: offer.expiresAt, expiresAt: offer.expiresAt,
lastSeen: now, lastSeen: now,
secret: offer.secret,
}); });
} }
return created; return created;
} }
async getOffersByPeerId(peerId: string): Promise<Offer[]> { async getOffersByUsername(username: string): Promise<Offer[]> {
const result = await this.db.prepare(` const result = await this.db.prepare(`
SELECT * FROM offers SELECT * FROM offers
WHERE peer_id = ? AND expires_at > ? WHERE username = ? AND expires_at > ?
ORDER BY last_seen DESC ORDER BY last_seen DESC
`).bind(peerId, Date.now()).all(); `).bind(username, Date.now()).all();
if (!result.results) { if (!result.results) {
return []; return [];
@@ -161,11 +160,11 @@ export class D1Storage implements Storage {
return this.rowToOffer(result as any); return this.rowToOffer(result as any);
} }
async deleteOffer(offerId: string, ownerPeerId: string): Promise<boolean> { async deleteOffer(offerId: string, ownerUsername: string): Promise<boolean> {
const result = await this.db.prepare(` const result = await this.db.prepare(`
DELETE FROM offers DELETE FROM offers
WHERE id = ? AND peer_id = ? WHERE id = ? AND username = ?
`).bind(offerId, ownerPeerId).run(); `).bind(offerId, ownerUsername).run();
return (result.meta.changes || 0) > 0; return (result.meta.changes || 0) > 0;
} }
@@ -180,9 +179,8 @@ export class D1Storage implements Storage {
async answerOffer( async answerOffer(
offerId: string, offerId: string,
answererPeerId: string, answererUsername: string,
answerSdp: string, answerSdp: string
secret?: string
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
// Check if offer exists and is not expired // Check if offer exists and is not expired
const offer = await this.getOfferById(offerId); const offer = await this.getOfferById(offerId);
@@ -194,16 +192,8 @@ export class D1Storage implements Storage {
}; };
} }
// Verify secret if offer is protected
if (offer.secret && offer.secret !== secret) {
return {
success: false,
error: 'Invalid or missing secret'
};
}
// Check if offer already has an answerer // Check if offer already has an answerer
if (offer.answererPeerId) { if (offer.answererUsername) {
return { return {
success: false, success: false,
error: 'Offer already answered' error: 'Offer already answered'
@@ -213,9 +203,9 @@ export class D1Storage implements Storage {
// Update offer with answer // Update offer with answer
const result = await this.db.prepare(` const result = await this.db.prepare(`
UPDATE offers UPDATE offers
SET answerer_peer_id = ?, answer_sdp = ?, answered_at = ? SET answerer_username = ?, answer_sdp = ?, answered_at = ?
WHERE id = ? AND answerer_peer_id IS NULL WHERE id = ? AND answerer_username IS NULL
`).bind(answererPeerId, answerSdp, Date.now(), offerId).run(); `).bind(answererUsername, answerSdp, Date.now(), offerId).run();
if ((result.meta.changes || 0) === 0) { if ((result.meta.changes || 0) === 0) {
return { return {
@@ -227,12 +217,12 @@ export class D1Storage implements Storage {
return { success: true }; return { success: true };
} }
async getAnsweredOffers(offererPeerId: string): Promise<Offer[]> { async getAnsweredOffers(offererUsername: string): Promise<Offer[]> {
const result = await this.db.prepare(` const result = await this.db.prepare(`
SELECT * FROM offers SELECT * FROM offers
WHERE peer_id = ? AND answerer_peer_id IS NOT NULL AND expires_at > ? WHERE username = ? AND answerer_username IS NOT NULL AND expires_at > ?
ORDER BY answered_at DESC ORDER BY answered_at DESC
`).bind(offererPeerId, Date.now()).all(); `).bind(offererUsername, Date.now()).all();
if (!result.results) { if (!result.results) {
return []; return [];
@@ -245,7 +235,7 @@ export class D1Storage implements Storage {
async addIceCandidates( async addIceCandidates(
offerId: string, offerId: string,
peerId: string, username: string,
role: 'offerer' | 'answerer', role: 'offerer' | 'answerer',
candidates: any[] candidates: any[]
): Promise<number> { ): Promise<number> {
@@ -253,11 +243,11 @@ export class D1Storage implements Storage {
for (let i = 0; i < candidates.length; i++) { for (let i = 0; i < candidates.length; i++) {
const timestamp = Date.now() + i; const timestamp = Date.now() + i;
await this.db.prepare(` await this.db.prepare(`
INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at) INSERT INTO ice_candidates (offer_id, username, role, candidate, created_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).bind( `).bind(
offerId, offerId,
peerId, username,
role, role,
JSON.stringify(candidates[i]), JSON.stringify(candidates[i]),
timestamp timestamp
@@ -295,7 +285,7 @@ export class D1Storage implements Storage {
return result.results.map((row: any) => ({ return result.results.map((row: any) => ({
id: row.id, id: row.id,
offerId: row.offer_id, offerId: row.offer_id,
peerId: row.peer_id, username: row.username,
role: row.role, role: row.role,
candidate: JSON.parse(row.candidate), candidate: JSON.parse(row.candidate),
createdAt: row.created_at, createdAt: row.created_at,
@@ -513,14 +503,14 @@ export class D1Storage implements Storage {
offset: number offset: number
): Promise<Service[]> { ): Promise<Service[]> {
// Query for unique services with available offers // Query for unique services with available offers
// We join with offers and filter for available ones (answerer_peer_id IS NULL) // We join with offers and filter for available ones (answerer_username IS NULL)
const result = await this.db.prepare(` const result = await this.db.prepare(`
SELECT DISTINCT s.* FROM services s SELECT DISTINCT s.* FROM services s
INNER JOIN offers o ON o.service_id = s.id INNER JOIN offers o ON o.service_id = s.id
WHERE s.service_name = ? WHERE s.service_name = ?
AND s.version = ? AND s.version = ?
AND s.expires_at > ? AND s.expires_at > ?
AND o.answerer_peer_id IS NULL AND o.answerer_username IS NULL
AND o.expires_at > ? AND o.expires_at > ?
ORDER BY s.created_at DESC ORDER BY s.created_at DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
@@ -541,7 +531,7 @@ export class D1Storage implements Storage {
WHERE s.service_name = ? WHERE s.service_name = ?
AND s.version = ? AND s.version = ?
AND s.expires_at > ? AND s.expires_at > ?
AND o.answerer_peer_id IS NULL AND o.answerer_username IS NULL
AND o.expires_at > ? AND o.expires_at > ?
ORDER BY RANDOM() ORDER BY RANDOM()
LIMIT 1 LIMIT 1
@@ -584,14 +574,14 @@ export class D1Storage implements Storage {
private rowToOffer(row: any): Offer { private rowToOffer(row: any): Offer {
return { return {
id: row.id, id: row.id,
peerId: row.peer_id, username: row.username,
serviceId: row.service_id || undefined, serviceId: row.service_id || undefined,
serviceFqn: row.service_fqn || undefined,
sdp: row.sdp, sdp: row.sdp,
createdAt: row.created_at, createdAt: row.created_at,
expiresAt: row.expires_at, expiresAt: row.expires_at,
lastSeen: row.last_seen, lastSeen: row.last_seen,
secret: row.secret || undefined, answererUsername: row.answerer_username || undefined,
answererPeerId: row.answerer_peer_id || undefined,
answerSdp: row.answer_sdp || undefined, answerSdp: row.answer_sdp || undefined,
answeredAt: row.answered_at || undefined, answeredAt: row.answered_at || undefined,
}; };

View File

@@ -9,9 +9,9 @@ import {
ClaimUsernameRequest, ClaimUsernameRequest,
Service, Service,
CreateServiceRequest, CreateServiceRequest,
ServiceInfo,
} from './types.ts'; } from './types.ts';
import { generateOfferHash } from './hash-id.ts'; import { generateOfferHash } from './hash-id.ts';
import { parseServiceFqn } from '../crypto.ts';
const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; // 365 days const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; // 365 days
@@ -39,30 +39,29 @@ export class SQLiteStorage implements Storage {
-- WebRTC signaling offers -- WebRTC signaling offers
CREATE TABLE IF NOT EXISTS offers ( CREATE TABLE IF NOT EXISTS offers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
peer_id TEXT NOT NULL, username TEXT NOT NULL,
service_id TEXT, service_id TEXT,
sdp TEXT NOT NULL, sdp TEXT NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL, last_seen INTEGER NOT NULL,
secret TEXT, answerer_username TEXT,
answerer_peer_id TEXT,
answer_sdp TEXT, answer_sdp TEXT,
answered_at INTEGER, answered_at INTEGER,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id); CREATE INDEX IF NOT EXISTS idx_offers_username ON offers(username);
CREATE INDEX IF NOT EXISTS idx_offers_service ON offers(service_id); CREATE INDEX IF NOT EXISTS idx_offers_service ON offers(service_id);
CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at); CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen); CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id); CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_username);
-- ICE candidates table -- ICE candidates table
CREATE TABLE IF NOT EXISTS ice_candidates ( CREATE TABLE IF NOT EXISTS ice_candidates (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
offer_id TEXT NOT NULL, offer_id TEXT NOT NULL,
peer_id TEXT NOT NULL, username TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')), role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
candidate TEXT NOT NULL, candidate TEXT NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
@@ -70,7 +69,7 @@ export class SQLiteStorage implements Storage {
); );
CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id); CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id); CREATE INDEX IF NOT EXISTS idx_ice_username ON ice_candidates(username);
CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at); CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
-- Usernames table -- Usernames table
@@ -87,36 +86,23 @@ export class SQLiteStorage implements Storage {
CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at); CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at);
CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key); CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key);
-- Services table (one service can have multiple offers) -- Services table (new schema with extracted fields for discovery)
CREATE TABLE IF NOT EXISTS services ( CREATE TABLE IF NOT EXISTS services (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL,
service_fqn TEXT NOT NULL, service_fqn TEXT NOT NULL,
service_name TEXT NOT NULL,
version TEXT NOT NULL,
username TEXT NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
is_public INTEGER NOT NULL DEFAULT 0,
metadata TEXT,
FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE, FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE,
UNIQUE(username, service_fqn) UNIQUE(service_fqn)
); );
CREATE INDEX IF NOT EXISTS idx_services_username ON services(username);
CREATE INDEX IF NOT EXISTS idx_services_fqn ON services(service_fqn); CREATE INDEX IF NOT EXISTS idx_services_fqn ON services(service_fqn);
CREATE INDEX IF NOT EXISTS idx_services_discovery ON services(service_name, version);
CREATE INDEX IF NOT EXISTS idx_services_username ON services(username);
CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at); CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at);
-- Service index table (privacy layer)
CREATE TABLE IF NOT EXISTS service_index (
uuid TEXT PRIMARY KEY,
service_id TEXT NOT NULL,
username TEXT NOT NULL,
service_fqn TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_service_index_username ON service_index(username);
CREATE INDEX IF NOT EXISTS idx_service_index_expires ON service_index(expires_at);
`); `);
// Enable foreign keys // Enable foreign keys
@@ -139,8 +125,8 @@ export class SQLiteStorage implements Storage {
// Use transaction for atomic creation // Use transaction for atomic creation
const transaction = this.db.transaction((offersWithIds: (CreateOfferRequest & { id: string })[]) => { const transaction = this.db.transaction((offersWithIds: (CreateOfferRequest & { id: string })[]) => {
const offerStmt = this.db.prepare(` const offerStmt = this.db.prepare(`
INSERT INTO offers (id, peer_id, service_id, sdp, created_at, expires_at, last_seen, secret) INSERT INTO offers (id, username, service_id, sdp, created_at, expires_at, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`); `);
for (const offer of offersWithIds) { for (const offer of offersWithIds) {
@@ -149,24 +135,23 @@ export class SQLiteStorage implements Storage {
// Insert offer // Insert offer
offerStmt.run( offerStmt.run(
offer.id, offer.id,
offer.peerId, offer.username,
offer.serviceId || null, offer.serviceId || null,
offer.sdp, offer.sdp,
now, now,
offer.expiresAt, offer.expiresAt,
now, now
offer.secret || null
); );
created.push({ created.push({
id: offer.id, id: offer.id,
peerId: offer.peerId, username: offer.username,
serviceId: offer.serviceId || undefined, serviceId: offer.serviceId || undefined,
serviceFqn: offer.serviceFqn,
sdp: offer.sdp, sdp: offer.sdp,
createdAt: now, createdAt: now,
expiresAt: offer.expiresAt, expiresAt: offer.expiresAt,
lastSeen: now, lastSeen: now,
secret: offer.secret,
}); });
} }
}); });
@@ -175,14 +160,14 @@ export class SQLiteStorage implements Storage {
return created; return created;
} }
async getOffersByPeerId(peerId: string): Promise<Offer[]> { async getOffersByUsername(username: string): Promise<Offer[]> {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
SELECT * FROM offers SELECT * FROM offers
WHERE peer_id = ? AND expires_at > ? WHERE username = ? AND expires_at > ?
ORDER BY last_seen DESC ORDER BY last_seen DESC
`); `);
const rows = stmt.all(peerId, Date.now()) as any[]; const rows = stmt.all(username, Date.now()) as any[];
return rows.map(row => this.rowToOffer(row)); return rows.map(row => this.rowToOffer(row));
} }
@@ -201,13 +186,13 @@ export class SQLiteStorage implements Storage {
return this.rowToOffer(row); return this.rowToOffer(row);
} }
async deleteOffer(offerId: string, ownerPeerId: string): Promise<boolean> { async deleteOffer(offerId: string, ownerUsername: string): Promise<boolean> {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
DELETE FROM offers DELETE FROM offers
WHERE id = ? AND peer_id = ? WHERE id = ? AND username = ?
`); `);
const result = stmt.run(offerId, ownerPeerId); const result = stmt.run(offerId, ownerUsername);
return result.changes > 0; return result.changes > 0;
} }
@@ -219,9 +204,8 @@ export class SQLiteStorage implements Storage {
async answerOffer( async answerOffer(
offerId: string, offerId: string,
answererPeerId: string, answererUsername: string,
answerSdp: string, answerSdp: string
secret?: string
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
// Check if offer exists and is not expired // Check if offer exists and is not expired
const offer = await this.getOfferById(offerId); const offer = await this.getOfferById(offerId);
@@ -233,16 +217,8 @@ export class SQLiteStorage implements Storage {
}; };
} }
// Verify secret if offer is protected
if (offer.secret && offer.secret !== secret) {
return {
success: false,
error: 'Invalid or missing secret'
};
}
// Check if offer already has an answerer // Check if offer already has an answerer
if (offer.answererPeerId) { if (offer.answererUsername) {
return { return {
success: false, success: false,
error: 'Offer already answered' error: 'Offer already answered'
@@ -252,11 +228,11 @@ export class SQLiteStorage implements Storage {
// Update offer with answer // Update offer with answer
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
UPDATE offers UPDATE offers
SET answerer_peer_id = ?, answer_sdp = ?, answered_at = ? SET answerer_username = ?, answer_sdp = ?, answered_at = ?
WHERE id = ? AND answerer_peer_id IS NULL WHERE id = ? AND answerer_username IS NULL
`); `);
const result = stmt.run(answererPeerId, answerSdp, Date.now(), offerId); const result = stmt.run(answererUsername, answerSdp, Date.now(), offerId);
if (result.changes === 0) { if (result.changes === 0) {
return { return {
@@ -268,14 +244,14 @@ export class SQLiteStorage implements Storage {
return { success: true }; return { success: true };
} }
async getAnsweredOffers(offererPeerId: string): Promise<Offer[]> { async getAnsweredOffers(offererUsername: string): Promise<Offer[]> {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
SELECT * FROM offers SELECT * FROM offers
WHERE peer_id = ? AND answerer_peer_id IS NOT NULL AND expires_at > ? WHERE username = ? AND answerer_username IS NOT NULL AND expires_at > ?
ORDER BY answered_at DESC ORDER BY answered_at DESC
`); `);
const rows = stmt.all(offererPeerId, Date.now()) as any[]; const rows = stmt.all(offererUsername, Date.now()) as any[];
return rows.map(row => this.rowToOffer(row)); return rows.map(row => this.rowToOffer(row));
} }
@@ -283,12 +259,12 @@ export class SQLiteStorage implements Storage {
async addIceCandidates( async addIceCandidates(
offerId: string, offerId: string,
peerId: string, username: string,
role: 'offerer' | 'answerer', role: 'offerer' | 'answerer',
candidates: any[] candidates: any[]
): Promise<number> { ): Promise<number> {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at) INSERT INTO ice_candidates (offer_id, username, role, candidate, created_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`); `);
@@ -297,7 +273,7 @@ export class SQLiteStorage implements Storage {
for (let i = 0; i < candidates.length; i++) { for (let i = 0; i < candidates.length; i++) {
stmt.run( stmt.run(
offerId, offerId,
peerId, username,
role, role,
JSON.stringify(candidates[i]), JSON.stringify(candidates[i]),
baseTimestamp + i baseTimestamp + i
@@ -334,7 +310,7 @@ export class SQLiteStorage implements Storage {
return rows.map(row => ({ return rows.map(row => ({
id: row.id, id: row.id,
offerId: row.offer_id, offerId: row.offer_id,
peerId: row.peer_id, username: row.username,
role: row.role, role: row.role,
candidate: JSON.parse(row.candidate), candidate: JSON.parse(row.candidate),
createdAt: row.created_at, createdAt: row.created_at,
@@ -427,87 +403,96 @@ export class SQLiteStorage implements Storage {
async createService(request: CreateServiceRequest): Promise<{ async createService(request: CreateServiceRequest): Promise<{
service: Service; service: Service;
indexUuid: string;
offers: Offer[]; offers: Offer[];
}> { }> {
const serviceId = randomUUID(); const serviceId = randomUUID();
const indexUuid = randomUUID();
const now = Date.now(); const now = Date.now();
// Create offers with serviceId // Parse FQN to extract components
const offerRequests: CreateOfferRequest[] = request.offers.map(offer => ({ const parsed = parseServiceFqn(request.serviceFqn);
...offer, if (!parsed) {
serviceId, throw new Error(`Invalid service FQN: ${request.serviceFqn}`);
})); }
if (!parsed.username) {
throw new Error(`Service FQN must include username: ${request.serviceFqn}`);
}
const offers = await this.createOffers(offerRequests); const { serviceName, version, username } = parsed;
const transaction = this.db.transaction(() => { const transaction = this.db.transaction(() => {
// Insert service (no offer_id column anymore) // Delete existing service with same (service_name, version, username) and its related offers (upsert behavior)
const serviceStmt = this.db.prepare(` const existingService = this.db.prepare(`
INSERT INTO services (id, username, service_fqn, created_at, expires_at, is_public, metadata) SELECT id FROM services
WHERE service_name = ? AND version = ? AND username = ?
`).get(serviceName, version, username) as any;
if (existingService) {
// Delete related offers first (no FK cascade from offers to services)
this.db.prepare(`
DELETE FROM offers WHERE service_id = ?
`).run(existingService.id);
// Delete the service
this.db.prepare(`
DELETE FROM services WHERE id = ?
`).run(existingService.id);
}
// Insert new service with extracted fields
this.db.prepare(`
INSERT INTO services (id, service_fqn, service_name, version, username, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`); `).run(
serviceStmt.run(
serviceId, serviceId,
request.username,
request.serviceFqn,
now,
request.expiresAt,
request.isPublic ? 1 : 0,
request.metadata || null
);
// Insert service index
const indexStmt = this.db.prepare(`
INSERT INTO service_index (uuid, service_id, username, service_fqn, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
indexStmt.run(
indexUuid,
serviceId,
request.username,
request.serviceFqn, request.serviceFqn,
serviceName,
version,
username,
now, now,
request.expiresAt request.expiresAt
); );
// Touch username to extend expiry // Touch username to extend expiry (inline logic)
this.touchUsername(request.username); const expiresAt = now + YEAR_IN_MS;
this.db.prepare(`
UPDATE usernames
SET last_used = ?, expires_at = ?
WHERE username = ? AND expires_at > ?
`).run(now, expiresAt, username, now);
}); });
transaction(); transaction();
// Create offers with serviceId (after transaction)
const offerRequests = request.offers.map(offer => ({
...offer,
serviceId,
}));
const offers = await this.createOffers(offerRequests);
return { return {
service: { service: {
id: serviceId, id: serviceId,
username: request.username,
serviceFqn: request.serviceFqn, serviceFqn: request.serviceFqn,
serviceName,
version,
username,
createdAt: now, createdAt: now,
expiresAt: request.expiresAt, expiresAt: request.expiresAt,
isPublic: request.isPublic || false,
metadata: request.metadata,
}, },
indexUuid,
offers, offers,
}; };
} }
async batchCreateServices(requests: CreateServiceRequest[]): Promise<Array<{ async getOffersForService(serviceId: string): Promise<Offer[]> {
service: Service; const stmt = this.db.prepare(`
indexUuid: string; SELECT * FROM offers
offers: Offer[]; WHERE service_id = ? AND expires_at > ?
}>> { ORDER BY created_at ASC
const results = []; `);
for (const request of requests) { const rows = stmt.all(serviceId, Date.now()) as any[];
const result = await this.createService(request); return rows.map(row => this.rowToOffer(row));
results.push(result);
}
return results;
} }
async getServiceById(serviceId: string): Promise<Service | null> { async getServiceById(serviceId: string): Promise<Service | null> {
@@ -525,14 +510,13 @@ export class SQLiteStorage implements Storage {
return this.rowToService(row); return this.rowToService(row);
} }
async getServiceByUuid(uuid: string): Promise<Service | null> { async getServiceByFqn(serviceFqn: string): Promise<Service | null> {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
SELECT s.* FROM services s SELECT * FROM services
INNER JOIN service_index si ON s.id = si.service_id WHERE service_fqn = ? AND expires_at > ?
WHERE si.uuid = ? AND s.expires_at > ?
`); `);
const row = stmt.get(uuid, Date.now()) as any; const row = stmt.get(serviceFqn, Date.now()) as any;
if (!row) { if (!row) {
return null; return null;
@@ -541,49 +525,53 @@ export class SQLiteStorage implements Storage {
return this.rowToService(row); return this.rowToService(row);
} }
async listServicesForUsername(username: string): Promise<ServiceInfo[]> { async discoverServices(
serviceName: string,
version: string,
limit: number,
offset: number
): Promise<Service[]> {
// Query for unique services with available offers
// We join with offers and filter for available ones (answerer_username IS NULL)
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
SELECT si.uuid, s.is_public, s.service_fqn, s.metadata SELECT DISTINCT s.* FROM services s
FROM service_index si INNER JOIN offers o ON o.service_id = s.id
INNER JOIN services s ON si.service_id = s.id WHERE s.service_name = ?
WHERE si.username = ? AND si.expires_at > ? AND s.version = ?
AND s.expires_at > ?
AND o.answerer_username IS NULL
AND o.expires_at > ?
ORDER BY s.created_at DESC ORDER BY s.created_at DESC
LIMIT ? OFFSET ?
`); `);
const rows = stmt.all(username, Date.now()) as any[]; const rows = stmt.all(serviceName, version, Date.now(), Date.now(), limit, offset) as any[];
return rows.map(row => ({
uuid: row.uuid,
isPublic: row.is_public === 1,
serviceFqn: row.is_public === 1 ? row.service_fqn : undefined,
metadata: row.is_public === 1 ? row.metadata || undefined : undefined,
}));
}
async queryService(username: string, serviceFqn: string): Promise<string | null> {
const stmt = this.db.prepare(`
SELECT si.uuid FROM service_index si
INNER JOIN services s ON si.service_id = s.id
WHERE si.username = ? AND si.service_fqn = ? AND si.expires_at > ?
`);
const row = stmt.get(username, serviceFqn, Date.now()) as any;
return row ? row.uuid : null;
}
async findServicesByName(username: string, serviceName: string): Promise<Service[]> {
const stmt = this.db.prepare(`
SELECT * FROM services
WHERE username = ? AND service_fqn LIKE ? AND expires_at > ?
ORDER BY created_at DESC
`);
const rows = stmt.all(username, `${serviceName}@%`, Date.now()) as any[];
return rows.map(row => this.rowToService(row)); return rows.map(row => this.rowToService(row));
} }
async getRandomService(serviceName: string, version: string): Promise<Service | null> {
// Get a random service with an available offer
const stmt = this.db.prepare(`
SELECT s.* FROM services s
INNER JOIN offers o ON o.service_id = s.id
WHERE s.service_name = ?
AND s.version = ?
AND s.expires_at > ?
AND o.answerer_username IS NULL
AND o.expires_at > ?
ORDER BY RANDOM()
LIMIT 1
`);
const row = stmt.get(serviceName, version, Date.now(), Date.now()) as any;
if (!row) {
return null;
}
return this.rowToService(row);
}
async deleteService(serviceId: string, username: string): Promise<boolean> { async deleteService(serviceId: string, username: string): Promise<boolean> {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
DELETE FROM services DELETE FROM services
@@ -612,14 +600,14 @@ export class SQLiteStorage implements Storage {
private rowToOffer(row: any): Offer { private rowToOffer(row: any): Offer {
return { return {
id: row.id, id: row.id,
peerId: row.peer_id, username: row.username,
serviceId: row.service_id || undefined, serviceId: row.service_id || undefined,
serviceFqn: row.service_fqn || undefined,
sdp: row.sdp, sdp: row.sdp,
createdAt: row.created_at, createdAt: row.created_at,
expiresAt: row.expires_at, expiresAt: row.expires_at,
lastSeen: row.last_seen, lastSeen: row.last_seen,
secret: row.secret || undefined, answererUsername: row.answerer_username || undefined,
answererPeerId: row.answerer_peer_id || undefined,
answerSdp: row.answer_sdp || undefined, answerSdp: row.answer_sdp || undefined,
answeredAt: row.answered_at || undefined, answeredAt: row.answered_at || undefined,
}; };
@@ -631,26 +619,12 @@ export class SQLiteStorage implements Storage {
private rowToService(row: any): Service { private rowToService(row: any): Service {
return { return {
id: row.id, id: row.id,
username: row.username,
serviceFqn: row.service_fqn, serviceFqn: row.service_fqn,
serviceName: row.service_name,
version: row.version,
username: row.username,
createdAt: row.created_at, createdAt: row.created_at,
expiresAt: row.expires_at, expiresAt: row.expires_at,
isPublic: row.is_public === 1,
metadata: row.metadata || undefined,
}; };
} }
/**
* Get all offers for a service
*/
async getOffersForService(serviceId: string): Promise<Offer[]> {
const stmt = this.db.prepare(`
SELECT * FROM offers
WHERE service_id = ? AND expires_at > ?
ORDER BY created_at ASC
`);
const rows = stmt.all(serviceId, Date.now()) as any[];
return rows.map(row => this.rowToOffer(row));
}
} }

View File

@@ -3,14 +3,14 @@
*/ */
export interface Offer { export interface Offer {
id: string; id: string;
peerId: string; username: string;
serviceId?: string; // Optional link to service (null for standalone offers) serviceId?: string; // Optional link to service (null for standalone offers)
serviceFqn?: string; // Denormalized service FQN for easier queries
sdp: string; sdp: string;
createdAt: number; createdAt: number;
expiresAt: number; expiresAt: number;
lastSeen: number; lastSeen: number;
secret?: string; answererUsername?: string;
answererPeerId?: string;
answerSdp?: string; answerSdp?: string;
answeredAt?: number; answeredAt?: number;
} }
@@ -22,7 +22,7 @@ export interface Offer {
export interface IceCandidate { export interface IceCandidate {
id: number; id: number;
offerId: string; offerId: string;
peerId: string; username: string;
role: 'offerer' | 'answerer'; role: 'offerer' | 'answerer';
candidate: any; // Full candidate object as JSON - don't enforce structure candidate: any; // Full candidate object as JSON - don't enforce structure
createdAt: number; createdAt: number;
@@ -33,11 +33,11 @@ export interface IceCandidate {
*/ */
export interface CreateOfferRequest { export interface CreateOfferRequest {
id?: string; id?: string;
peerId: string; username: string;
serviceId?: string; // Optional link to service serviceId?: string; // Optional link to service
serviceFqn?: string; // Optional service FQN
sdp: string; sdp: string;
expiresAt: number; expiresAt: number;
secret?: string;
} }
/** /**
@@ -100,11 +100,11 @@ export interface Storage {
createOffers(offers: CreateOfferRequest[]): Promise<Offer[]>; createOffers(offers: CreateOfferRequest[]): Promise<Offer[]>;
/** /**
* Retrieves all offers from a specific peer * Retrieves all offers from a specific user
* @param peerId Peer identifier * @param username Username identifier
* @returns Array of offers from the peer * @returns Array of offers from the user
*/ */
getOffersByPeerId(peerId: string): Promise<Offer[]>; getOffersByUsername(username: string): Promise<Offer[]>;
/** /**
* Retrieves a specific offer by ID * Retrieves a specific offer by ID
@@ -116,10 +116,10 @@ export interface Storage {
/** /**
* Deletes an offer (with ownership verification) * Deletes an offer (with ownership verification)
* @param offerId Offer identifier * @param offerId Offer identifier
* @param ownerPeerId Peer ID of the owner (for verification) * @param ownerUsername Username of the owner (for verification)
* @returns true if deleted, false if not found or not owned * @returns true if deleted, false if not found or not owned
*/ */
deleteOffer(offerId: string, ownerPeerId: string): Promise<boolean>; deleteOffer(offerId: string, ownerUsername: string): Promise<boolean>;
/** /**
* Deletes all expired offers * Deletes all expired offers
@@ -131,36 +131,35 @@ export interface Storage {
/** /**
* Answers an offer (locks it to the answerer) * Answers an offer (locks it to the answerer)
* @param offerId Offer identifier * @param offerId Offer identifier
* @param answererPeerId Answerer's peer ID * @param answererUsername Answerer's username
* @param answerSdp WebRTC answer SDP * @param answerSdp WebRTC answer SDP
* @param secret Optional secret for protected offers
* @returns Success status and optional error message * @returns Success status and optional error message
*/ */
answerOffer(offerId: string, answererPeerId: string, answerSdp: string, secret?: string): Promise<{ answerOffer(offerId: string, answererUsername: string, answerSdp: string): Promise<{
success: boolean; success: boolean;
error?: string; error?: string;
}>; }>;
/** /**
* Retrieves all answered offers for a specific offerer * Retrieves all answered offers for a specific offerer
* @param offererPeerId Offerer's peer ID * @param offererUsername Offerer's username
* @returns Array of answered offers * @returns Array of answered offers
*/ */
getAnsweredOffers(offererPeerId: string): Promise<Offer[]>; getAnsweredOffers(offererUsername: string): Promise<Offer[]>;
// ===== ICE Candidate Management ===== // ===== ICE Candidate Management =====
/** /**
* Adds ICE candidates for an offer * Adds ICE candidates for an offer
* @param offerId Offer identifier * @param offerId Offer identifier
* @param peerId Peer ID posting the candidates * @param username Username posting the candidates
* @param role Role of the peer (offerer or answerer) * @param role Role of the user (offerer or answerer)
* @param candidates Array of candidate objects (stored as plain JSON) * @param candidates Array of candidate objects (stored as plain JSON)
* @returns Number of candidates added * @returns Number of candidates added
*/ */
addIceCandidates( addIceCandidates(
offerId: string, offerId: string,
peerId: string, username: string,
role: 'offerer' | 'answerer', role: 'offerer' | 'answerer',
candidates: any[] candidates: any[]
): Promise<number>; ): Promise<number>;