diff --git a/README.md b/README.md index f3cce27..2049c88 100644 --- a/README.md +++ b/README.md @@ -202,9 +202,62 @@ Main class for all Rondevu operations. import { Rondevu } from '@xtr-dev/rondevu-client' const rondevu = new Rondevu({ - apiUrl: string, // Signaling server URL - username?: string, // Optional: your username (auto-generates anonymous if omitted) - keypair?: Keypair // Optional: reuse existing keypair + apiUrl: string, // Signaling server URL + username?: string, // Optional: your username (auto-generates anonymous if omitted) + keypair?: Keypair, // Optional: reuse existing keypair + cryptoAdapter?: CryptoAdapter // Optional: platform-specific crypto (defaults to WebCryptoAdapter) +}) +``` + +#### Platform Support (Browser & Node.js) + +The client supports both browser and Node.js environments using crypto adapters: + +**Browser (default):** +```typescript +import { Rondevu } from '@xtr-dev/rondevu-client' + +// WebCryptoAdapter is used by default - no configuration needed +const rondevu = new Rondevu({ + apiUrl: 'https://api.ronde.vu', + username: 'alice' +}) +``` + +**Node.js (19+ or 18 with --experimental-global-webcrypto):** +```typescript +import { Rondevu, NodeCryptoAdapter } from '@xtr-dev/rondevu-client' + +const rondevu = new Rondevu({ + apiUrl: 'https://api.ronde.vu', + username: 'alice', + cryptoAdapter: new NodeCryptoAdapter() +}) + +await rondevu.initialize() +``` + +**Note:** Node.js support requires: +- Node.js 19+ (crypto.subtle available globally), OR +- Node.js 18 with `--experimental-global-webcrypto` flag +- WebRTC implementation like `wrtc` or `node-webrtc` for RTCPeerConnection + +**Custom Crypto Adapter:** +```typescript +import { CryptoAdapter, Keypair } from '@xtr-dev/rondevu-client' + +class CustomCryptoAdapter implements CryptoAdapter { + async generateKeypair(): Promise { /* ... */ } + async signMessage(message: string, privateKey: string): Promise { /* ... */ } + async verifySignature(message: string, signature: string, publicKey: string): Promise { /* ... */ } + bytesToBase64(bytes: Uint8Array): string { /* ... */ } + base64ToBytes(base64: string): Uint8Array { /* ... */ } + randomBytes(length: number): Uint8Array { /* ... */ } +} + +const rondevu = new Rondevu({ + apiUrl: 'https://api.ronde.vu', + cryptoAdapter: new CustomCryptoAdapter() }) ``` diff --git a/package.json b/package.json index 0eace3d..ca04a30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/rondevu-client", - "version": "0.14.0", + "version": "0.15.0", "description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing", "type": "module", "main": "dist/index.js", diff --git a/src/api.ts b/src/api.ts index 5a2e55b..6af6db4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -2,17 +2,10 @@ * Rondevu API Client - RPC interface */ -import * as ed25519 from '@noble/ed25519' +import { CryptoAdapter, Keypair } from './crypto-adapter.js' +import { WebCryptoAdapter } from './web-crypto-adapter.js' -// Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+) -ed25519.hashes.sha512Async = async (message: Uint8Array) => { - return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource)) -} - -export interface Keypair { - publicKey: string - privateKey: string -} +export type { Keypair } from './crypto-adapter.js' export interface OfferRequest { sdp: string @@ -47,22 +40,6 @@ export interface IceCandidate { createdAt: number } -/** - * Helper: Convert Uint8Array to base64 string - */ -function bytesToBase64(bytes: Uint8Array): string { - const binString = Array.from(bytes, byte => String.fromCodePoint(byte)).join('') - return btoa(binString) -} - -/** - * Helper: Convert base64 string to Uint8Array - */ -function base64ToBytes(base64: string): Uint8Array { - const binString = atob(base64) - return Uint8Array.from(binString, char => char.codePointAt(0)!) -} - /** * RPC request format */ @@ -87,11 +64,17 @@ interface RpcResponse { * RondevuAPI - RPC-based API client for Rondevu signaling server */ export class RondevuAPI { + private crypto: CryptoAdapter + constructor( private baseUrl: string, private username: string, - private keypair: Keypair - ) {} + private keypair: Keypair, + cryptoAdapter?: CryptoAdapter + ) { + // Use WebCryptoAdapter by default (browser environment) + this.crypto = cryptoAdapter || new WebCryptoAdapter() + } /** * Generate authentication parameters for RPC calls @@ -105,7 +88,7 @@ export class RondevuAPI { ? `${method}:${this.username}:${params}:${timestamp}` : `${method}:${this.username}:${timestamp}` - const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey) + const signature = await this.crypto.signMessage(message, this.keypair.privateKey) return { message, signature } } @@ -163,47 +146,38 @@ export class RondevuAPI { /** * Generate an Ed25519 keypair for username claiming and service publishing + * @param cryptoAdapter - Optional crypto adapter (defaults to WebCryptoAdapter) */ - static async generateKeypair(): Promise { - const privateKey = ed25519.utils.randomSecretKey() - const publicKey = await ed25519.getPublicKeyAsync(privateKey) - - return { - publicKey: bytesToBase64(publicKey), - privateKey: bytesToBase64(privateKey), - } + static async generateKeypair(cryptoAdapter?: CryptoAdapter): Promise { + const adapter = cryptoAdapter || new WebCryptoAdapter() + return await adapter.generateKeypair() } /** * Sign a message with an Ed25519 private key + * @param cryptoAdapter - Optional crypto adapter (defaults to WebCryptoAdapter) */ - static async signMessage(message: string, privateKeyBase64: string): Promise { - const privateKey = base64ToBytes(privateKeyBase64) - const encoder = new TextEncoder() - const messageBytes = encoder.encode(message) - const signature = await ed25519.signAsync(messageBytes, privateKey) - - return bytesToBase64(signature) + static async signMessage( + message: string, + privateKeyBase64: string, + cryptoAdapter?: CryptoAdapter + ): Promise { + const adapter = cryptoAdapter || new WebCryptoAdapter() + return await adapter.signMessage(message, privateKeyBase64) } /** * Verify an Ed25519 signature + * @param cryptoAdapter - Optional crypto adapter (defaults to WebCryptoAdapter) */ static async verifySignature( message: string, signatureBase64: string, - publicKeyBase64: string + publicKeyBase64: string, + cryptoAdapter?: CryptoAdapter ): Promise { - try { - const signature = base64ToBytes(signatureBase64) - const publicKey = base64ToBytes(publicKeyBase64) - const encoder = new TextEncoder() - const messageBytes = encoder.encode(message) - - return await ed25519.verifyAsync(signature, messageBytes, publicKey) - } catch { - return false - } + const adapter = cryptoAdapter || new WebCryptoAdapter() + return await adapter.verifySignature(message, signatureBase64, publicKeyBase64) } // ============================================ diff --git a/src/crypto-adapter.ts b/src/crypto-adapter.ts new file mode 100644 index 0000000..01f2455 --- /dev/null +++ b/src/crypto-adapter.ts @@ -0,0 +1,48 @@ +/** + * Crypto adapter interface for platform-independent cryptographic operations + */ + +export interface Keypair { + publicKey: string + privateKey: string +} + +/** + * Platform-independent crypto adapter interface + * Implementations provide platform-specific crypto operations + */ +export interface CryptoAdapter { + /** + * Generate an Ed25519 keypair + */ + generateKeypair(): Promise + + /** + * Sign a message with an Ed25519 private key + */ + signMessage(message: string, privateKeyBase64: string): Promise + + /** + * Verify an Ed25519 signature + */ + verifySignature( + message: string, + signatureBase64: string, + publicKeyBase64: string + ): Promise + + /** + * Convert Uint8Array to base64 string + */ + bytesToBase64(bytes: Uint8Array): string + + /** + * Convert base64 string to Uint8Array + */ + base64ToBytes(base64: string): Uint8Array + + /** + * Generate random bytes + */ + randomBytes(length: number): Uint8Array +} diff --git a/src/index.ts b/src/index.ts index 115803f..66d7191 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,10 @@ export { Rondevu } from './rondevu.js' export { RondevuAPI } from './api.js' export { RondevuSignaler } from './rondevu-signaler.js' +// Export crypto adapters +export { WebCryptoAdapter } from './web-crypto-adapter.js' +export { NodeCryptoAdapter } from './node-crypto-adapter.js' + // Export types export type { Signaler, @@ -26,3 +30,5 @@ export type { RondevuOptions, PublishServiceOptions } from './rondevu.js' export type { PollingConfig } from './rondevu-signaler.js' +export type { CryptoAdapter } from './crypto-adapter.js' + diff --git a/src/node-crypto-adapter.ts b/src/node-crypto-adapter.ts new file mode 100644 index 0000000..4c018ca --- /dev/null +++ b/src/node-crypto-adapter.ts @@ -0,0 +1,98 @@ +/** + * Node.js Crypto adapter for Node.js environments + * Requires Node.js 19+ or Node.js 18 with --experimental-global-webcrypto flag + */ + +import * as ed25519 from '@noble/ed25519' +import { CryptoAdapter, Keypair } from './crypto-adapter.js' + +/** + * Node.js Crypto implementation using Node.js built-in APIs + * Uses Buffer for base64 encoding and crypto.randomBytes for random generation + * + * Requirements: + * - Node.js 19+ (crypto.subtle available globally) + * - OR Node.js 18 with --experimental-global-webcrypto flag + * + * @example + * ```typescript + * import { RondevuAPI } from '@xtr-dev/rondevu-client' + * import { NodeCryptoAdapter } from '@xtr-dev/rondevu-client/node' + * + * const api = new RondevuAPI( + * 'https://signal.example.com', + * 'alice', + * keypair, + * new NodeCryptoAdapter() + * ) + * ``` + */ +export class NodeCryptoAdapter implements CryptoAdapter { + constructor() { + // Set SHA-512 hash function for ed25519 using Node's crypto.subtle + if (typeof crypto === 'undefined' || !crypto.subtle) { + throw new Error( + 'crypto.subtle is not available. ' + + 'Node.js 19+ is required, or Node.js 18 with --experimental-global-webcrypto flag' + ) + } + + ed25519.hashes.sha512Async = async (message: Uint8Array) => { + const hash = await crypto.subtle.digest('SHA-512', message as BufferSource) + return new Uint8Array(hash) + } + } + + async generateKeypair(): Promise { + const privateKey = ed25519.utils.randomSecretKey() + const publicKey = await ed25519.getPublicKeyAsync(privateKey) + + return { + publicKey: this.bytesToBase64(publicKey), + privateKey: this.bytesToBase64(privateKey), + } + } + + async signMessage(message: string, privateKeyBase64: string): Promise { + const privateKey = this.base64ToBytes(privateKeyBase64) + const encoder = new TextEncoder() + const messageBytes = encoder.encode(message) + const signature = await ed25519.signAsync(messageBytes, privateKey) + + return this.bytesToBase64(signature) + } + + async verifySignature( + message: string, + signatureBase64: string, + publicKeyBase64: string + ): Promise { + try { + const signature = this.base64ToBytes(signatureBase64) + const publicKey = this.base64ToBytes(publicKeyBase64) + const encoder = new TextEncoder() + const messageBytes = encoder.encode(message) + + return await ed25519.verifyAsync(signature, messageBytes, publicKey) + } catch { + return false + } + } + + bytesToBase64(bytes: Uint8Array): string { + // Node.js Buffer provides native base64 encoding + // @ts-expect-error - Buffer is available in Node.js but not in browser TypeScript definitions + return Buffer.from(bytes).toString('base64') + } + + base64ToBytes(base64: string): Uint8Array { + // Node.js Buffer provides native base64 decoding + // @ts-expect-error - Buffer is available in Node.js but not in browser TypeScript definitions + return new Uint8Array(Buffer.from(base64, 'base64')) + } + + randomBytes(length: number): Uint8Array { + // Use Web Crypto API's getRandomValues (available in Node 19+) + return crypto.getRandomValues(new Uint8Array(length)) + } +} diff --git a/src/rondevu.ts b/src/rondevu.ts index 1a5dabf..28c4eda 100644 --- a/src/rondevu.ts +++ b/src/rondevu.ts @@ -1,9 +1,11 @@ import { RondevuAPI, Keypair, Service, ServiceRequest, IceCandidate } from './api.js' +import { CryptoAdapter } from './crypto-adapter.js' export interface RondevuOptions { apiUrl: string username?: string // Optional, will generate anonymous if not provided keypair?: Keypair // Optional, will generate if not provided + cryptoAdapter?: CryptoAdapter // Optional, defaults to WebCryptoAdapter } export interface PublishServiceOptions { @@ -52,11 +54,13 @@ export class Rondevu { private username: string private keypair: Keypair | null = null private usernameClaimed = false + private cryptoAdapter?: CryptoAdapter constructor(options: RondevuOptions) { this.apiUrl = options.apiUrl this.username = options.username || this.generateAnonymousUsername() this.keypair = options.keypair || null + this.cryptoAdapter = options.cryptoAdapter console.log('[Rondevu] Constructor called:', { username: this.username, @@ -89,14 +93,14 @@ export class Rondevu { // Generate keypair if not provided if (!this.keypair) { console.log('[Rondevu] Generating new keypair...') - this.keypair = await RondevuAPI.generateKeypair() + this.keypair = await RondevuAPI.generateKeypair(this.cryptoAdapter) console.log('[Rondevu] Generated keypair, publicKey:', this.keypair.publicKey) } else { console.log('[Rondevu] Using existing keypair, publicKey:', this.keypair.publicKey) } - // Create API instance with username and keypair - this.api = new RondevuAPI(this.apiUrl, this.username, this.keypair) + // Create API instance with username, keypair, and crypto adapter + this.api = new RondevuAPI(this.apiUrl, this.username, this.keypair, this.cryptoAdapter) console.log('[Rondevu] Created API instance with username:', this.username) } diff --git a/src/web-crypto-adapter.ts b/src/web-crypto-adapter.ts new file mode 100644 index 0000000..aa8175b --- /dev/null +++ b/src/web-crypto-adapter.ts @@ -0,0 +1,67 @@ +/** + * Web Crypto adapter for browser environments + */ + +import * as ed25519 from '@noble/ed25519' +import { CryptoAdapter, Keypair } from './crypto-adapter.js' + +// Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+) +ed25519.hashes.sha512Async = async (message: Uint8Array) => { + return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource)) +} + +/** + * Web Crypto implementation using browser APIs + * Uses btoa/atob for base64 encoding and crypto.getRandomValues for random bytes + */ +export class WebCryptoAdapter implements CryptoAdapter { + async generateKeypair(): Promise { + const privateKey = ed25519.utils.randomSecretKey() + const publicKey = await ed25519.getPublicKeyAsync(privateKey) + + return { + publicKey: this.bytesToBase64(publicKey), + privateKey: this.bytesToBase64(privateKey), + } + } + + async signMessage(message: string, privateKeyBase64: string): Promise { + const privateKey = this.base64ToBytes(privateKeyBase64) + const encoder = new TextEncoder() + const messageBytes = encoder.encode(message) + const signature = await ed25519.signAsync(messageBytes, privateKey) + + return this.bytesToBase64(signature) + } + + async verifySignature( + message: string, + signatureBase64: string, + publicKeyBase64: string + ): Promise { + try { + const signature = this.base64ToBytes(signatureBase64) + const publicKey = this.base64ToBytes(publicKeyBase64) + const encoder = new TextEncoder() + const messageBytes = encoder.encode(message) + + return await ed25519.verifyAsync(signature, messageBytes, publicKey) + } catch { + return false + } + } + + bytesToBase64(bytes: Uint8Array): string { + const binString = Array.from(bytes, byte => String.fromCodePoint(byte)).join('') + return btoa(binString) + } + + base64ToBytes(base64: string): Uint8Array { + const binString = atob(base64) + return Uint8Array.from(binString, char => char.codePointAt(0)!) + } + + randomBytes(length: number): Uint8Array { + return crypto.getRandomValues(new Uint8Array(length)) + } +}