Implement crypto adapter pattern for platform independence

Adds CryptoAdapter interface with WebCryptoAdapter (browser) and
NodeCryptoAdapter (Node.js 19+) implementations.

Changes:
- Created crypto-adapter.ts interface
- Created web-crypto-adapter.ts for browser environments
- Created node-crypto-adapter.ts for Node.js environments
- Updated RondevuAPI to accept optional CryptoAdapter
- Updated Rondevu class to pass crypto adapter through
- Exported adapters and types in index.ts
- Updated README with platform support documentation
- Bumped version to 0.15.0

This allows the client library to work in both browser and Node.js
environments by providing platform-specific crypto implementations.

Example usage in Node.js:
  import { Rondevu, NodeCryptoAdapter } from '@xtr-dev/rondevu-client'

  const rondevu = new Rondevu({
    apiUrl: 'https://api.ronde.vu',
    cryptoAdapter: new NodeCryptoAdapter()
  })

🤖 Generated with Claude Code
https://claude.com/claude-code

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-12 20:34:27 +01:00
parent 4ce5217135
commit d55abf2b63
8 changed files with 312 additions and 62 deletions

View File

@@ -202,9 +202,62 @@ Main class for all Rondevu operations.
import { Rondevu } from '@xtr-dev/rondevu-client' import { Rondevu } from '@xtr-dev/rondevu-client'
const rondevu = new Rondevu({ const rondevu = new Rondevu({
apiUrl: string, // Signaling server URL apiUrl: string, // Signaling server URL
username?: string, // Optional: your username (auto-generates anonymous if omitted) username?: string, // Optional: your username (auto-generates anonymous if omitted)
keypair?: Keypair // Optional: reuse existing keypair 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<Keypair> { /* ... */ }
async signMessage(message: string, privateKey: string): Promise<string> { /* ... */ }
async verifySignature(message: string, signature: string, publicKey: string): Promise<boolean> { /* ... */ }
bytesToBase64(bytes: Uint8Array): string { /* ... */ }
base64ToBytes(base64: string): Uint8Array { /* ... */ }
randomBytes(length: number): Uint8Array { /* ... */ }
}
const rondevu = new Rondevu({
apiUrl: 'https://api.ronde.vu',
cryptoAdapter: new CustomCryptoAdapter()
}) })
``` ```

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/rondevu-client", "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", "description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -2,17 +2,10 @@
* Rondevu API Client - RPC interface * 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+) export type { Keypair } from './crypto-adapter.js'
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 interface OfferRequest { export interface OfferRequest {
sdp: string sdp: string
@@ -47,22 +40,6 @@ export interface IceCandidate {
createdAt: number 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 * RPC request format
*/ */
@@ -87,11 +64,17 @@ interface RpcResponse {
* RondevuAPI - RPC-based API client for Rondevu signaling server * RondevuAPI - RPC-based API client for Rondevu signaling server
*/ */
export class RondevuAPI { export class RondevuAPI {
private crypto: CryptoAdapter
constructor( constructor(
private baseUrl: string, private baseUrl: string,
private username: 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 * Generate authentication parameters for RPC calls
@@ -105,7 +88,7 @@ export class RondevuAPI {
? `${method}:${this.username}:${params}:${timestamp}` ? `${method}:${this.username}:${params}:${timestamp}`
: `${method}:${this.username}:${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 } return { message, signature }
} }
@@ -163,47 +146,38 @@ export class RondevuAPI {
/** /**
* Generate an Ed25519 keypair for username claiming and service publishing * Generate an Ed25519 keypair for username claiming and service publishing
* @param cryptoAdapter - Optional crypto adapter (defaults to WebCryptoAdapter)
*/ */
static async generateKeypair(): Promise<Keypair> { static async generateKeypair(cryptoAdapter?: CryptoAdapter): Promise<Keypair> {
const privateKey = ed25519.utils.randomSecretKey() const adapter = cryptoAdapter || new WebCryptoAdapter()
const publicKey = await ed25519.getPublicKeyAsync(privateKey) return await adapter.generateKeypair()
return {
publicKey: bytesToBase64(publicKey),
privateKey: bytesToBase64(privateKey),
}
} }
/** /**
* Sign a message with an Ed25519 private key * Sign a message with an Ed25519 private key
* @param cryptoAdapter - Optional crypto adapter (defaults to WebCryptoAdapter)
*/ */
static async signMessage(message: string, privateKeyBase64: string): Promise<string> { static async signMessage(
const privateKey = base64ToBytes(privateKeyBase64) message: string,
const encoder = new TextEncoder() privateKeyBase64: string,
const messageBytes = encoder.encode(message) cryptoAdapter?: CryptoAdapter
const signature = await ed25519.signAsync(messageBytes, privateKey) ): Promise<string> {
const adapter = cryptoAdapter || new WebCryptoAdapter()
return bytesToBase64(signature) return await adapter.signMessage(message, privateKeyBase64)
} }
/** /**
* Verify an Ed25519 signature * Verify an Ed25519 signature
* @param cryptoAdapter - Optional crypto adapter (defaults to WebCryptoAdapter)
*/ */
static async verifySignature( static async verifySignature(
message: string, message: string,
signatureBase64: string, signatureBase64: string,
publicKeyBase64: string publicKeyBase64: string,
cryptoAdapter?: CryptoAdapter
): Promise<boolean> { ): Promise<boolean> {
try { const adapter = cryptoAdapter || new WebCryptoAdapter()
const signature = base64ToBytes(signatureBase64) return await adapter.verifySignature(message, signatureBase64, publicKeyBase64)
const publicKey = base64ToBytes(publicKeyBase64)
const encoder = new TextEncoder()
const messageBytes = encoder.encode(message)
return await ed25519.verifyAsync(signature, messageBytes, publicKey)
} catch {
return false
}
} }
// ============================================ // ============================================

48
src/crypto-adapter.ts Normal file
View File

@@ -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<Keypair>
/**
* Sign a message with an Ed25519 private key
*/
signMessage(message: string, privateKeyBase64: string): Promise<string>
/**
* Verify an Ed25519 signature
*/
verifySignature(
message: string,
signatureBase64: string,
publicKeyBase64: string
): Promise<boolean>
/**
* 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
}

View File

@@ -7,6 +7,10 @@ export { Rondevu } from './rondevu.js'
export { RondevuAPI } from './api.js' export { RondevuAPI } from './api.js'
export { RondevuSignaler } from './rondevu-signaler.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 types
export type { export type {
Signaler, Signaler,
@@ -26,3 +30,5 @@ export type { RondevuOptions, PublishServiceOptions } from './rondevu.js'
export type { PollingConfig } from './rondevu-signaler.js' export type { PollingConfig } from './rondevu-signaler.js'
export type { CryptoAdapter } from './crypto-adapter.js'

View File

@@ -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<Keypair> {
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<string> {
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<boolean> {
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))
}
}

View File

@@ -1,9 +1,11 @@
import { RondevuAPI, Keypair, Service, ServiceRequest, IceCandidate } from './api.js' import { RondevuAPI, Keypair, Service, ServiceRequest, IceCandidate } from './api.js'
import { CryptoAdapter } from './crypto-adapter.js'
export interface RondevuOptions { export interface RondevuOptions {
apiUrl: string apiUrl: string
username?: string // Optional, will generate anonymous if not provided username?: string // Optional, will generate anonymous if not provided
keypair?: Keypair // Optional, will generate if not provided keypair?: Keypair // Optional, will generate if not provided
cryptoAdapter?: CryptoAdapter // Optional, defaults to WebCryptoAdapter
} }
export interface PublishServiceOptions { export interface PublishServiceOptions {
@@ -52,11 +54,13 @@ export class Rondevu {
private username: string private username: string
private keypair: Keypair | null = null private keypair: Keypair | null = null
private usernameClaimed = false private usernameClaimed = false
private cryptoAdapter?: CryptoAdapter
constructor(options: RondevuOptions) { constructor(options: RondevuOptions) {
this.apiUrl = options.apiUrl this.apiUrl = options.apiUrl
this.username = options.username || this.generateAnonymousUsername() this.username = options.username || this.generateAnonymousUsername()
this.keypair = options.keypair || null this.keypair = options.keypair || null
this.cryptoAdapter = options.cryptoAdapter
console.log('[Rondevu] Constructor called:', { console.log('[Rondevu] Constructor called:', {
username: this.username, username: this.username,
@@ -89,14 +93,14 @@ export class Rondevu {
// Generate keypair if not provided // Generate keypair if not provided
if (!this.keypair) { if (!this.keypair) {
console.log('[Rondevu] Generating new 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) console.log('[Rondevu] Generated keypair, publicKey:', this.keypair.publicKey)
} else { } else {
console.log('[Rondevu] Using existing keypair, publicKey:', this.keypair.publicKey) console.log('[Rondevu] Using existing keypair, publicKey:', this.keypair.publicKey)
} }
// Create API instance with username and keypair // Create API instance with username, keypair, and crypto adapter
this.api = new RondevuAPI(this.apiUrl, this.username, this.keypair) this.api = new RondevuAPI(this.apiUrl, this.username, this.keypair, this.cryptoAdapter)
console.log('[Rondevu] Created API instance with username:', this.username) console.log('[Rondevu] Created API instance with username:', this.username)
} }

67
src/web-crypto-adapter.ts Normal file
View File

@@ -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<Keypair> {
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<string> {
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<boolean> {
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))
}
}