From c9a5e0eae63b578dd4e172857fa111ee94df9a62 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Wed, 10 Dec 2025 22:07:07 +0100 Subject: [PATCH] Unified Ed25519 authentication - remove credentials system BREAKING CHANGE: Remove credential-based authentication - Remove Credentials interface and all credential-related code - Remove register() method from RondevuAPI - Remove setCredentials() and getAuthHeader() methods RondevuAPI changes: - Constructor now requires username and keypair (not credentials) - Add generateAuthParams() helper for automatic signature generation - All API methods now include {username, signature, message} auth - POST requests: auth in body - GET requests: auth in query params - Remove Authorization header from all fetch calls Rondevu class changes: - Make username optional in RondevuOptions (auto-generates anon username) - Make keypair optional (auto-generates if not provided) - Add generateAnonymousUsername() method (anon-{timestamp}-{random}) - Update initialize() to create API with username+keypair (no register call) - Auto-claim username for anonymous users during initialize() - Add lazy getAPI() to ensure initialization Message format for auth: - Format: action:username:params:timestamp - Examples: publishService:alice:chat:1.0.0@alice:1234567890 - Each request generates unique signature with timestamp Index exports: - Remove Credentials export (no longer exists) --- src/api.ts | 174 +++++++++++++++++++++------------------- src/index.ts | 1 - src/rondevu-signaler.ts | 8 +- src/rondevu.ts | 102 ++++++++++++++++------- 4 files changed, 168 insertions(+), 117 deletions(-) diff --git a/src/api.ts b/src/api.ts index 5ea66a3..95f1936 100644 --- a/src/api.ts +++ b/src/api.ts @@ -9,11 +9,6 @@ ed25519.hashes.sha512Async = async (message: Uint8Array) => { return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource)) } -export interface Credentials { - peerId: string - secret: string -} - export interface Keypair { publicKey: string privateKey: string @@ -92,26 +87,26 @@ function base64ToBytes(base64: string): Uint8Array { export class RondevuAPI { constructor( private baseUrl: string, - private credentials?: Credentials + private username: string, + private keypair: Keypair ) {} /** - * Set credentials for authentication + * Generate authentication parameters (username, signature, message) for API calls */ - setCredentials(credentials: Credentials): void { - this.credentials = credentials - } + private async generateAuthParams(action: string, params: string = ''): Promise<{ + username: string; + signature: string; + message: string; + }> { + const timestamp = Date.now(); + const message = params + ? `${action}:${this.username}:${params}:${timestamp}` + : `${action}:${this.username}:${timestamp}`; - /** - * Authentication header - */ - private getAuthHeader(): Record { - if (!this.credentials) { - return {} - } - return { - Authorization: `Bearer ${this.credentials.peerId}:${this.credentials.secret}`, - } + const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey); + + return { username: this.username, signature, message }; } // ============================================ @@ -159,27 +154,6 @@ export class RondevuAPI { return await ed25519.verifyAsync(signature, messageBytes, publicKey) } - // ============================================ - // Authentication - // ============================================ - - /** - * Register a new peer and get credentials - */ - async register(): Promise { - const response = await fetch(`${this.baseUrl}/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })) - throw new Error(`Registration failed: ${error.error || response.statusText}`) - } - - return await response.json() - } - // ============================================ // Offers // ============================================ @@ -188,13 +162,14 @@ export class RondevuAPI { * Create one or more offers */ async createOffers(offers: OfferRequest[]): Promise { + const auth = await this.generateAuthParams('createOffers'); + const response = await fetch(`${this.baseUrl}/offers`, { method: 'POST', headers: { 'Content-Type': 'application/json', - ...this.getAuthHeader(), }, - body: JSON.stringify({ offers }), + body: JSON.stringify({ offers, ...auth }), }) if (!response.ok) { @@ -209,9 +184,13 @@ export class RondevuAPI { * Get offer by ID */ async getOffer(offerId: string): Promise { - const response = await fetch(`${this.baseUrl}/offers/${offerId}`, { - headers: this.getAuthHeader(), - }) + const auth = await this.generateAuthParams('getOffer', offerId); + const url = new URL(`${this.baseUrl}/offers/${offerId}`); + url.searchParams.set('username', auth.username); + url.searchParams.set('signature', auth.signature); + url.searchParams.set('message', auth.message); + + const response = await fetch(url.toString()) if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Unknown error' })) @@ -225,13 +204,14 @@ export class RondevuAPI { * Answer a specific offer from a service */ async postOfferAnswer(serviceFqn: string, offerId: string, sdp: string): Promise<{ success: boolean; offerId: string }> { + const auth = await this.generateAuthParams('answerOffer', `${serviceFqn}:${offerId}`); + const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/answer`, { method: 'POST', headers: { 'Content-Type': 'application/json', - ...this.getAuthHeader(), }, - body: JSON.stringify({ sdp }), + body: JSON.stringify({ sdp, ...auth }), }) if (!response.ok) { @@ -254,13 +234,17 @@ export class RondevuAPI { answeredAt: number; }>; }> { - const url = since - ? `${this.baseUrl}/offers/answered?since=${since}` - : `${this.baseUrl}/offers/answered`; + const auth = await this.generateAuthParams('getAnsweredOffers', since?.toString() || ''); + const url = new URL(`${this.baseUrl}/offers/answered`); - const response = await fetch(url, { - headers: this.getAuthHeader(), - }) + if (since) { + url.searchParams.set('since', since.toString()); + } + url.searchParams.set('username', auth.username); + url.searchParams.set('signature', auth.signature); + url.searchParams.set('message', auth.message); + + const response = await fetch(url.toString()) if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Unknown error' })) @@ -289,13 +273,17 @@ export class RondevuAPI { createdAt: number; }>>; }> { - const url = since - ? `${this.baseUrl}/offers/poll?since=${since}` - : `${this.baseUrl}/offers/poll`; + const auth = await this.generateAuthParams('pollOffers', since?.toString() || ''); + const url = new URL(`${this.baseUrl}/offers/poll`); - const response = await fetch(url, { - headers: this.getAuthHeader(), - }) + if (since) { + url.searchParams.set('since', since.toString()); + } + url.searchParams.set('username', auth.username); + url.searchParams.set('signature', auth.signature); + url.searchParams.set('message', auth.message); + + const response = await fetch(url.toString()) if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Unknown error' })) @@ -309,9 +297,13 @@ export class RondevuAPI { * Get answer for a specific offer (offerer polls this) */ async getOfferAnswer(serviceFqn: string, offerId: string): Promise<{ sdp: string; offerId: string; answererId: string; answeredAt: number } | null> { - const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/answer`, { - headers: this.getAuthHeader(), - }) + const auth = await this.generateAuthParams('getOfferAnswer', `${serviceFqn}:${offerId}`); + const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/answer`); + url.searchParams.set('username', auth.username); + url.searchParams.set('signature', auth.signature); + url.searchParams.set('message', auth.message); + + const response = await fetch(url.toString()) if (!response.ok) { // 404 means not yet answered @@ -329,9 +321,14 @@ export class RondevuAPI { * Search offers by topic */ async searchOffers(topic: string): Promise { - const response = await fetch(`${this.baseUrl}/offers?topic=${encodeURIComponent(topic)}`, { - headers: this.getAuthHeader(), - }) + const auth = await this.generateAuthParams('searchOffers', topic); + const url = new URL(`${this.baseUrl}/offers`); + url.searchParams.set('topic', topic); + url.searchParams.set('username', auth.username); + url.searchParams.set('signature', auth.signature); + url.searchParams.set('message', auth.message); + + const response = await fetch(url.toString()) if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Unknown error' })) @@ -349,13 +346,14 @@ export class RondevuAPI { * Add ICE candidates to a specific offer */ async addOfferIceCandidates(serviceFqn: string, offerId: string, candidates: RTCIceCandidateInit[]): Promise<{ count: number; offerId: string }> { + const auth = await this.generateAuthParams('addIceCandidates', `${serviceFqn}:${offerId}`); + const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/ice-candidates`, { method: 'POST', headers: { 'Content-Type': 'application/json', - ...this.getAuthHeader(), }, - body: JSON.stringify({ candidates }), + body: JSON.stringify({ candidates, ...auth }), }) if (!response.ok) { @@ -370,10 +368,14 @@ export class RondevuAPI { * Get ICE candidates for a specific offer (with polling support) */ async getOfferIceCandidates(serviceFqn: string, offerId: string, since: number = 0): Promise<{ candidates: IceCandidate[]; offerId: string }> { + const auth = await this.generateAuthParams('getIceCandidates', `${serviceFqn}:${offerId}:${since}`); const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/ice-candidates`) url.searchParams.set('since', since.toString()) + url.searchParams.set('username', auth.username); + url.searchParams.set('signature', auth.signature); + url.searchParams.set('message', auth.message); - const response = await fetch(url.toString(), { headers: this.getAuthHeader() }) + const response = await fetch(url.toString()) if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Unknown error' })) @@ -396,13 +398,14 @@ export class RondevuAPI { * Service FQN must include username: service:version@username */ async publishService(service: ServiceRequest): Promise { + const auth = await this.generateAuthParams('publishService', service.serviceFqn); + const response = await fetch(`${this.baseUrl}/services`, { method: 'POST', headers: { 'Content-Type': 'application/json', - ...this.getAuthHeader(), }, - body: JSON.stringify(service), + body: JSON.stringify({ ...service, username: auth.username }), }) if (!response.ok) { @@ -418,9 +421,13 @@ export class RondevuAPI { * Example: chat:1.0.0@alice */ async getService(serviceFqn: string): Promise<{ serviceId: string; username: string; serviceFqn: string; offerId: string; sdp: string; createdAt: number; expiresAt: number }> { - const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}`, { - headers: this.getAuthHeader(), - }) + const auth = await this.generateAuthParams('getService', serviceFqn); + const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}`); + url.searchParams.set('username', auth.username); + url.searchParams.set('signature', auth.signature); + url.searchParams.set('message', auth.message); + + const response = await fetch(url.toString()) if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Unknown error' })) @@ -435,9 +442,13 @@ export class RondevuAPI { * Example: chat:1.0.0 (without @username) */ async discoverService(serviceVersion: string): Promise<{ serviceId: string; username: string; serviceFqn: string; offerId: string; sdp: string; createdAt: number; expiresAt: number }> { - const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceVersion)}`, { - headers: this.getAuthHeader(), - }) + const auth = await this.generateAuthParams('discoverService', serviceVersion); + const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceVersion)}`); + url.searchParams.set('username', auth.username); + url.searchParams.set('signature', auth.signature); + url.searchParams.set('message', auth.message); + + const response = await fetch(url.toString()) if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Unknown error' })) @@ -452,13 +463,15 @@ export class RondevuAPI { * Example: chat:1.0.0 (without @username) */ async discoverServices(serviceVersion: string, limit: number = 10, offset: number = 0): Promise<{ services: Array<{ serviceId: string; username: string; serviceFqn: string; offerId: string; sdp: string; createdAt: number; expiresAt: number }>; count: number; limit: number; offset: number }> { + const auth = await this.generateAuthParams('discoverServices', `${serviceVersion}:${limit}:${offset}`); const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceVersion)}`) url.searchParams.set('limit', limit.toString()) url.searchParams.set('offset', offset.toString()) + url.searchParams.set('username', auth.username); + url.searchParams.set('signature', auth.signature); + url.searchParams.set('message', auth.message); - const response = await fetch(url.toString(), { - headers: this.getAuthHeader(), - }) + const response = await fetch(url.toString()) if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Unknown error' })) @@ -502,7 +515,6 @@ export class RondevuAPI { method: 'POST', headers: { 'Content-Type': 'application/json', - ...this.getAuthHeader(), }, body: JSON.stringify({ publicKey, diff --git a/src/index.ts b/src/index.ts index b2d8005..dc23fc3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,6 @@ export type { } from './types.js' export type { - Credentials, Keypair, OfferRequest, Offer, diff --git a/src/rondevu-signaler.ts b/src/rondevu-signaler.ts index 59fd9f8..04f8a53 100644 --- a/src/rondevu-signaler.ts +++ b/src/rondevu-signaler.ts @@ -111,7 +111,7 @@ export class RondevuSignaler implements Signaler { } // Send answer to the service - const result = await this.rondevu.getAPI().postOfferAnswer(this.serviceFqn, this.offerId, answer.sdp) + const result = await this.rondevu.getAPIPublic().postOfferAnswer(this.serviceFqn, this.offerId, answer.sdp) this.offerId = result.offerId this.isOfferer = false @@ -173,7 +173,7 @@ export class RondevuSignaler implements Signaler { } try { - await this.rondevu.getAPI().addOfferIceCandidates( + await this.rondevu.getAPIPublic().addOfferIceCandidates( this.serviceFqn, this.offerId, [candidateData] @@ -212,7 +212,7 @@ export class RondevuSignaler implements Signaler { try { // Get service by FQN (service should include @username) const serviceFqn = `${this.service}@${this.host}` - const serviceData = await this.rondevu.getAPI().getService(serviceFqn) + const serviceData = await this.rondevu.getAPIPublic().getService(serviceFqn) if (!serviceData) { console.warn(`No service found for ${serviceFqn}`) @@ -390,7 +390,7 @@ export class RondevuSignaler implements Signaler { try { const result = await this.rondevu - .getAPI() + .getAPIPublic() .getOfferIceCandidates(this.serviceFqn, this.offerId, this.lastPollTimestamp) let foundCandidates = false diff --git a/src/rondevu.ts b/src/rondevu.ts index d694c91..5ab2829 100644 --- a/src/rondevu.ts +++ b/src/rondevu.ts @@ -1,10 +1,9 @@ -import { RondevuAPI, Credentials, Keypair, Service, ServiceRequest, IceCandidate } from './api.js' +import { RondevuAPI, Keypair, Service, ServiceRequest, IceCandidate } from './api.js' export interface RondevuOptions { apiUrl: string - username: string - keypair?: Keypair - credentials?: Credentials + username?: string // Optional, will generate anonymous if not provided + keypair?: Keypair // Optional, will generate if not provided } export interface PublishServiceOptions { @@ -22,19 +21,24 @@ export interface PublishServiceOptions { * - Service discovery (direct, random, paginated) * - WebRTC signaling (offer/answer exchange, ICE relay) * - Keypair management + * - Anonymous usage (auto-generates username and keypair) * * @example * ```typescript - * // Initialize (generates keypair automatically) + * // Option 1: Named user (manually claim username) * const rondevu = new Rondevu({ * apiUrl: 'https://signal.example.com', * username: 'alice', * }) - * * await rondevu.initialize() + * await rondevu.claimUsername() // Claim username once * - * // Claim username (one time) - * await rondevu.claimUsername() + * // Option 2: Anonymous user (auto-claims generated username) + * const rondevu = new Rondevu({ + * apiUrl: 'https://signal.example.com', + * // username omitted - will generate 'anon-xxxxx' + * }) + * await rondevu.initialize() // Auto-claims anonymous username * * // Publish a service * const publishedService = await rondevu.publishService({ @@ -51,15 +55,16 @@ export interface PublishServiceOptions { * ``` */ export class Rondevu { - private readonly api: RondevuAPI - private readonly username: string + private api: RondevuAPI | null = null + private readonly apiUrl: string + private username: string private keypair: Keypair | null = null private usernameClaimed = false constructor(options: RondevuOptions) { - this.username = options.username + this.apiUrl = options.apiUrl + this.username = options.username || this.generateAnonymousUsername() this.keypair = options.keypair || null - this.api = new RondevuAPI(options.apiUrl, options.credentials) console.log('[Rondevu] Constructor called:', { username: this.username, @@ -68,17 +73,29 @@ export class Rondevu { }) } + /** + * Generate an anonymous username with timestamp and random component + */ + private generateAnonymousUsername(): string { + const timestamp = Date.now().toString(36) + const random = Array.from(crypto.getRandomValues(new Uint8Array(3))) + .map(b => b.toString(16).padStart(2, '0')).join('') + return `anon-${timestamp}-${random}` + } + // ============================================ // Initialization // ============================================ /** - * Initialize the service - generates keypair if not provided + * Initialize the service - generates keypair if not provided and creates API instance + * Auto-claims username for anonymous users * Call this before using other methods */ async initialize(): Promise { console.log('[Rondevu] Initialize called, hasKeypair:', !!this.keypair) + // Generate keypair if not provided if (!this.keypair) { console.log('[Rondevu] Generating new keypair...') this.keypair = await RondevuAPI.generateKeypair() @@ -87,10 +104,20 @@ export class Rondevu { console.log('[Rondevu] Using existing keypair, publicKey:', this.keypair.publicKey) } - // Register with API if no credentials provided - if (!this.api['credentials']) { - const credentials = await this.api.register() - this.api.setCredentials(credentials) + // Create API instance with username and keypair + this.api = new RondevuAPI(this.apiUrl, this.username, this.keypair) + console.log('[Rondevu] Created API instance with username:', this.username) + + // Auto-claim username for anonymous users + if (this.username.startsWith('anon-')) { + console.log('[Rondevu] Auto-claiming anonymous username:', this.username) + try { + await this.claimUsername() + console.log('[Rondevu] Successfully claimed anonymous username') + } catch (error) { + console.error('[Rondevu] Failed to claim anonymous username:', error) + // Don't throw - allow the user to continue, they just won't be able to publish services + } } } @@ -108,7 +135,7 @@ export class Rondevu { } // Check if username is already claimed - const check = await this.api.checkUsername(this.username) + const check = await this.getAPI().checkUsername(this.username) if (!check.available) { // Verify it's claimed by us if (check.publicKey === this.keypair.publicKey) { @@ -123,10 +150,20 @@ export class Rondevu { const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey) // Claim the username - await this.api.claimUsername(this.username, this.keypair.publicKey, signature, message) + await this.getAPI().claimUsername(this.username, this.keypair.publicKey, signature, message) this.usernameClaimed = true } + /** + * Get API instance (creates lazily if needed) + */ + private getAPI(): RondevuAPI { + if (!this.api) { + throw new Error('Not initialized. Call initialize() first.') + } + return this.api + } + /** * Check if username has been claimed (checks with server) */ @@ -136,7 +173,7 @@ export class Rondevu { } try { - const check = await this.api.checkUsername(this.username) + const check = await this.getAPI().checkUsername(this.username) // Debug logging console.log('[Rondevu] Username check:', { @@ -194,7 +231,7 @@ export class Rondevu { } // Publish to server - return await this.api.publishService(serviceRequest) + return await this.getAPI().publishService(serviceRequest) } // ============================================ @@ -214,7 +251,7 @@ export class Rondevu { createdAt: number expiresAt: number }> { - return await this.api.getService(serviceFqn) + return await this.getAPI().getService(serviceFqn) } /** @@ -230,7 +267,7 @@ export class Rondevu { createdAt: number expiresAt: number }> { - return await this.api.discoverService(serviceVersion) + return await this.getAPI().discoverService(serviceVersion) } /** @@ -251,7 +288,7 @@ export class Rondevu { limit: number offset: number }> { - return await this.api.discoverServices(serviceVersion, limit, offset) + return await this.getAPI().discoverServices(serviceVersion, limit, offset) } // ============================================ @@ -265,7 +302,7 @@ export class Rondevu { success: boolean offerId: string }> { - return await this.api.postOfferAnswer(serviceFqn, offerId, sdp) + return await this.getAPI().postOfferAnswer(serviceFqn, offerId, sdp) } /** @@ -277,7 +314,7 @@ export class Rondevu { answererId: string answeredAt: number } | null> { - return await this.api.getOfferAnswer(serviceFqn, offerId) + return await this.getAPI().getOfferAnswer(serviceFqn, offerId) } /** @@ -293,7 +330,7 @@ export class Rondevu { answeredAt: number }> }> { - return await this.api.getAnsweredOffers(since) + return await this.getAPI().getAnsweredOffers(since) } /** @@ -315,7 +352,7 @@ export class Rondevu { createdAt: number }>> }> { - return await this.api.pollOffers(since) + return await this.getAPI().pollOffers(since) } /** @@ -325,7 +362,7 @@ export class Rondevu { count: number offerId: string }> { - return await this.api.addOfferIceCandidates(serviceFqn, offerId, candidates) + return await this.getAPI().addOfferIceCandidates(serviceFqn, offerId, candidates) } /** @@ -335,7 +372,7 @@ export class Rondevu { candidates: IceCandidate[] offerId: string }> { - return await this.api.getOfferIceCandidates(serviceFqn, offerId, since) + return await this.getAPI().getOfferIceCandidates(serviceFqn, offerId, since) } // ============================================ @@ -367,7 +404,10 @@ export class Rondevu { * Access to underlying API for advanced operations * @deprecated Use direct methods on Rondevu instance instead */ - getAPI(): RondevuAPI { + getAPIPublic(): RondevuAPI { + if (!this.api) { + throw new Error('Not initialized. Call initialize() first.') + } return this.api } }