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)
This commit is contained in:
2025-12-10 22:07:07 +01:00
parent 239563ac5c
commit c9a5e0eae6
4 changed files with 168 additions and 117 deletions

View File

@@ -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<string, string> {
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<Credentials> {
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<Offer[]> {
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<Offer> {
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<Offer[]> {
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<Service> {
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,

View File

@@ -14,7 +14,6 @@ export type {
} from './types.js'
export type {
Credentials,
Keypair,
OfferRequest,
Offer,

View File

@@ -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

View File

@@ -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<void> {
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
}
}