@droponair/sdk-js 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,865 +1,160 @@
1
1
  # DropOnAir JS SDK
2
2
 
3
- **End-to-End Encrypted Real-Time Messaging SDK**
3
+ End-to-end encrypted messaging, group chat, voice/video calls, and broadcast channels for any app.
4
4
 
5
- The DropOnAir SDK provides seamless client-side end-to-end encryption for real-time messaging, group messaging, and voice/video calls. DropOnAir operates as a **blind encrypted relay** - it never sees, stores, or manages your encryption keys or message plaintext.
5
+ DropOnAir operates as a **blind encrypted relay** - your users' encryption keys and message content never leave their devices.
6
6
 
7
- ---
7
+ ## Getting Started
8
8
 
9
- ## 🔐 Security Architecture
9
+ 1. Create a free account at [panel.droponair.com](https://panel.droponair.com)
10
+ 2. Create an app in the dashboard to get your **App ID** and **Public API Key**
11
+ 3. Install the SDK:
10
12
 
11
- ### Key Ownership Model
12
-
13
- - **Client-Side:** SDK generates X25519 keypairs locally, stores private keys in IndexedDB/SecureStorage
14
- - **Your Backend:** Serves as public key directory (users publish public keys via `/api/messaging/keys/me`)
15
- - **DropOnAir Service:** **NEVER** stores, validates, or accesses user keys - operates as blind relay
16
-
17
- ### Encryption Flow
18
-
19
- 1. **Identity Generation:** SDK generates X25519 keypair on first use, stores in local secure storage
20
- 2. **Key Exchange:** SDK fetches recipient's public key from key directory (not DropOnAir)
21
- 3. **Shared Secret Derivation:** SDK derives shared secret via X25519 ECDH, caches in-memory per peer
22
- 4. **Message Encryption:** SDK encrypts message with AES-256-GCM using derived shared secret
23
- 5. **Wire Format:** Binary protobuf Envelope with opaque `encryptedPayload` bytes
24
- 6. **Decryption:** Recipient SDK derives same shared secret, decrypts payload client-side
25
-
26
- ### Cryptographic Primitives
27
-
28
- - **X25519 (Curve25519 ECDH):** Key agreement for deriving shared secrets
29
- - **HKDF-SHA256:** Shared secret → AES-256 symmetric key derivation
30
- - **AES-256-GCM:** Authenticated encryption with 96-bit nonce, 128-bit auth tag
31
- - **Additional Authenticated Data (AAD):** Binds messageId, senderId, recipientId, timestamp to ciphertext
32
-
33
- ---
34
-
35
- ## 📦 Installation
13
+ ## Installation
36
14
 
37
15
  ```bash
38
16
  npm install @droponair/sdk-js
39
17
  ```
40
18
 
41
- ---
42
-
43
- ## 🚀 Quick Start
44
-
45
- ### Basic Usage (TypeScript)
19
+ ## Quick Start
46
20
 
47
21
  ```typescript
48
22
  import { initialize } from '@droponair/sdk-js';
49
23
 
50
- // Initialize SDK with backend integration
51
24
  const client = await initialize({
52
- appId: 'your-app-id',
53
- publicApiKey: 'your-public-api-key',
54
-
55
- // JWT callback: fetch fresh JWT from your backend
25
+ appId: 'your-app-id', // From the DropOnAir dashboard
26
+ publicApiKey: 'your-public-api-key', // From the DropOnAir dashboard
56
27
  getUserJwt: async () => {
57
- const response = await fetch('/api/auth/me', { credentials: 'include' });
58
- const data = await response.json();
59
- return data.jwt;
28
+ const res = await fetch('/api/auth/me', { credentials: 'include' });
29
+ const { jwt } = await res.json();
30
+ return jwt;
60
31
  },
61
-
62
- // Key directory endpoint (your backend serves public keys)
63
32
  keyDirectoryEndpoint: '/api/messaging/keys',
64
-
65
- // Token exchange endpoint (your backend → DropOnAir JWT)
66
33
  tokenExchangeEndpoint: '/api/messaging/token-exchange',
67
-
68
- // Optional: custom WebSocket/HTTP endpoints
69
- messagingWsUrl: 'wss://sdk.droponair.com/ws/messages',
70
- messagingHttpUrl: 'https://sdk.droponair.com',
71
-
72
- // Auto-connect on initialization (default: true)
73
- autoConnect: true
74
34
  });
75
35
 
76
- // Listen for incoming messages (receives decrypted plaintext)
77
- client.onMessage(({ messageId, fromUserId, toUserId, timestamp, plaintext }) => {
78
- console.log(`Message from ${fromUserId}: ${plaintext}`);
36
+ // Listen for incoming messages (already decrypted)
37
+ client.onMessage(({ fromUserId, plaintext, timestamp }) => {
38
+ console.log(`${fromUserId}: ${plaintext}`);
79
39
  });
80
40
 
81
- // Listen for system events
82
- client.onEvent(({ type, reason, metadata }) => {
83
- if (type === 'LIMIT_REACHED') {
84
- console.error('Rate limit reached');
85
- } else if (type === 'ERROR') {
86
- console.error('Error:', reason);
87
- }
88
- });
89
-
90
- // Send encrypted message (SDK handles encryption automatically)
91
- const { messageId } = await client.sendMessage('recipient-user-id', 'Hello from Alice!');
92
- console.log('Message sent:', messageId);
93
-
94
- // Disconnect when done
95
- client.disconnect();
41
+ // Send an E2EE message
42
+ await client.sendMessage('recipient-user-id', 'Hello!');
96
43
  ```
97
44
 
98
- ### Manual Connection Control
99
-
100
- ```typescript
101
- const client = await initialize({
102
- appId: 'your-app-id',
103
- publicApiKey: 'your-public-api-key',
104
- getUserJwt: async () => fetchJwt(),
105
- autoConnect: false // Don't connect immediately
106
- });
107
-
108
- // Connect manually when ready
109
- await client.connect();
110
-
111
- // Disconnect and prevent auto-reconnect
112
- client.disconnect();
113
- ```
114
-
115
- ---
116
-
117
- ## 🔧 API Reference
118
-
119
- ### `initialize(options)`
120
-
121
- Initializes the DropOnAir client with end-to-end encryption.
45
+ ## Features
122
46
 
123
- **Options:**
124
- - `appId` (string, required): Your your backend app ID
125
- - `publicApiKey` (string, required): Your DropOnAir public API key
126
- - `getUserJwt` (function, required): Async function returning fresh user JWT
127
- - `keyDirectoryEndpoint` (string, optional): public key directory endpoint (default: `/api/messaging/keys`)
128
- - `tokenExchangeEndpoint` (string, optional): token exchange endpoint (default: `/api/messaging/token-exchange`)
129
- - `messagingWsUrl` (string, optional): DropOnAir WebSocket URL (default: `wss://sdk.droponair.com/ws/messages`)
130
- - `messagingHttpUrl` (string, optional): DropOnAir HTTP URL (default: `https://sdk.droponair.com`)
131
- - `autoConnect` (boolean, optional): Auto-connect on initialization (default: `true`)
132
- - `storage` (KeyStorageAdapter, optional): Custom storage adapter (default: IndexedDB with in-memory fallback)
133
- - `fetchFn` (function, optional): Custom fetch implementation (default: `globalThis.fetch`)
47
+ - **E2EE Messaging** - X25519 key agreement, AES-256-GCM encryption, multi-device support
48
+ - **Cleartext Messaging** - Lightweight messages without E2EE overhead
49
+ - **Broadcast Channels** - Pub/sub for announcements and notifications
50
+ - **Group Messaging** - Server-managed groups with member roles
51
+ - **Voice & Video Calls** - 1-to-1 WebRTC call signaling with TURN support
52
+ - **Group Calls** - Mesh WebRTC group calls with per-participant signaling
53
+ - **Offline Delivery** - Messages queued and delivered when recipients reconnect
54
+ - **Multi-Device** - Per-device encryption with automatic self-sync
134
55
 
135
- **Returns:** `Promise<DropOnAirClient>`
56
+ ## API Reference
136
57
 
137
- ---
58
+ ### Initialization
138
59
 
139
- ### `client.connect()`
140
-
141
- Connects to DropOnAir messaging service. Automatically:
142
- - Generates or retrieves X25519 identity keypair from secure storage
143
- - Publishes public key to key directory (PUT `/api/messaging/keys/me`)
144
- - Exchanges user JWT for DropOnAir JWT
145
- - Opens WebSocket connection with JWT authentication
146
- - Fetches and decrypts offline messages
147
-
148
- **Returns:** `Promise<void>`
149
-
150
- ---
151
-
152
- ### `client.disconnect()`
153
-
154
- Disconnects from DropOnAir and stops auto-reconnect.
155
-
156
- **Returns:** `void`
157
-
158
- ---
159
-
160
- ### `client.sendMessage(toUserId, plaintext)`
161
-
162
- Encrypts and sends a message to a recipient.
163
-
164
- **Parameters:**
165
- - `toUserId` (string): Recipient's user ID (must match JWT subject in their their identity)
166
- - `plaintext` (string): Message content (will be encrypted client-side)
167
-
168
- **Flow:**
169
- 1. Fetch recipient's public key from key directory (if not cached)
170
- 2. Derive shared secret via X25519 ECDH
171
- 3. Encrypt plaintext with AES-256-GCM
172
- 4. Build protobuf Envelope with opaque encrypted payload
173
- 5. Send binary WebSocket frame to DropOnAir
174
-
175
- **Returns:** `Promise<{ messageId: string }>`
176
-
177
- **Throws:**
178
- - WebSocket not connected
179
- - Sender identity missing
180
- - Rate limit reached (`LIMIT_REACHED` event)
181
-
182
- ---
183
-
184
- ### `client.onMessage(callback)`
185
-
186
- Registers a callback for incoming decrypted messages.
187
-
188
- **Callback signature:**
189
60
  ```typescript
190
- (message: DecryptedMessage) => void
191
-
192
- interface DecryptedMessage {
193
- messageId: string;
194
- fromUserId: string;
195
- toUserId: string;
196
- timestamp: number;
197
- plaintext: string; // Already decrypted by SDK
198
- }
199
- ```
200
-
201
- **Returns:** Unsubscribe function `() => void`
202
-
203
- ---
204
-
205
- ### `client.onEvent(callback)`
206
-
207
- Registers a callback for system events.
208
-
209
- **Event Types:**
210
- - `CONNECTED`: WebSocket connected
211
- - `DISCONNECTED`: WebSocket disconnected
212
- - `RECONNECTING`: Auto-reconnect in progress
213
- - `SERVER_RECEIVED`: DropOnAir received message
214
- - `DELIVERED`: Message delivered to recipient's WebSocket
215
- - `PROCESSED`: Recipient acknowledged message
216
- - `LIMIT_REACHED`: Rate limit exceeded (sending blocked until reset)
217
- - `IMPERSONATION_DETECTED`: Sender tried to spoof fromUserId
218
- - `ERROR`: Decryption failed or other error
219
-
220
- **Callback signature:**
221
- ```typescript
222
- (event: DropOnAirEvent) => void
223
-
224
- interface DropOnAirEvent {
225
- type: string;
226
- reason?: string;
227
- metadata?: string;
228
- }
229
- ```
230
-
231
- **Returns:** Unsubscribe function `() => void`
232
-
233
- ---
234
-
235
- ### `client.ack(messageId)`
236
-
237
- Acknowledges a received message (sends `PROCESSED` ACK to DropOnAir).
238
-
239
- **Parameters:**
240
- - `messageId` (string): Message ID to acknowledge
241
-
242
- **Returns:** `Promise<void>`
243
-
244
- **Note:** SDK automatically sends `PROCESSED` ACK after successfully decrypting incoming messages. You typically don't need to call this method unless implementing custom acknowledgment logic.
245
-
246
- ---
247
-
248
- ## 📞 Voice & Video Calls
249
-
250
- DropOnAir includes built-in WebRTC call signaling. The SDK handles the call lifecycle (invite → ring → accept/reject → signal exchange → end) over the same encrypted WebSocket used for messaging. Your app is responsible for the WebRTC peer connection itself; the SDK provides the signaling transport.
251
-
252
- ### Quick Example
253
-
254
- ```typescript
255
- import { initialize, CallEvent } from '@droponair/sdk-js';
256
-
257
- const client = await initialize({ ... });
258
-
259
- // ── Listen for incoming & status events ──────────────────────────────────────
260
- client.onCallEvent(async (event: CallEvent) => {
261
- switch (event.type) {
262
-
263
- case 'CALL_INVITE':
264
- // Incoming call, show answer/reject UI
265
- showIncomingCallUI(event.callId!, event.targetUserId!);
266
- break;
267
-
268
- case 'CALL_RINGING':
269
- // Callee is ringing, show "ringing" state in UI
270
- console.log('Ringing:', event.callId);
271
- break;
272
-
273
- case 'CALL_ACCEPTED':
274
- // Create RTCPeerConnection and send SDP offer
275
- await startWebRtcOffer(event.callId!);
276
- break;
277
-
278
- case 'CALL_SDP_OFFER':
279
- // Received SDP offer from caller, create answer
280
- await handleSdpOffer(event.callId!, event.payload!);
281
- break;
282
-
283
- case 'CALL_SDP_ANSWER':
284
- await handleSdpAnswer(event.callId!, event.payload!);
285
- break;
286
-
287
- case 'CALL_ICE_CANDIDATE':
288
- await handleIceCandidate(event.callId!, event.payload!);
289
- break;
290
-
291
- case 'CALL_VIDEO_TOGGLE':
292
- handleVideoToggle(event.callId!, event.payload!);
293
- break;
294
-
295
- case 'CALL_REJECTED':
296
- case 'CALL_ENDED':
297
- closeCall(event.callId!);
298
- break;
299
-
300
- case 'CALL_DENIED_LIMIT_REACHED':
301
- showError('Call limit reached. Upgrade your plan to continue.');
302
- break;
303
- }
304
- });
305
-
306
- // ── TURN credentials (required for calls behind NAT/firewall) ────────────────
307
- const turn = await client.fetchTurnCredentials();
308
- const pc = new RTCPeerConnection({
309
- iceServers: [{ urls: turn.uri, username: turn.username, credential: turn.password }]
310
- });
311
-
312
- // ── Initiate an outgoing call ────────────────────────────────────────────────
313
- const callId = await client.startCall('recipient-user-id');
314
-
315
- // ── Accept / reject incoming call ────────────────────────────────────────────
316
- await client.acceptCall(callId);
317
- await client.rejectCall(callId);
318
-
319
- // ── Send WebRTC signals through DropOnAir relay ──────────────────────────────
320
- pc.onicecandidate = ({ candidate }) => {
321
- if (candidate) {
322
- client.sendCallSignal('CALL_ICE_CANDIDATE', callId, JSON.stringify(candidate));
323
- }
324
- };
325
-
326
- const offer = await pc.createOffer();
327
- await pc.setLocalDescription(offer);
328
- client.sendCallSignal('CALL_SDP_OFFER', callId, JSON.stringify(offer));
329
-
330
- // ── Toggle video on/off during a call ────────────────────────────────────────
331
- client.toggleVideo(callId, true); // Enable video
332
- client.toggleVideo(callId, false); // Disable video (voice-only)
333
-
334
- // ── End call ─────────────────────────────────────────────────────────────────
335
- await client.endCall(callId);
336
- ```
337
-
338
- ---
339
-
340
- ### Call API Reference
341
-
342
- #### `client.startCall(targetUserId)`
343
-
344
- Initiates an outgoing call to a user. Resolves with the server-assigned `callId` once the remote party starts ringing.
345
-
346
- | Parameter | Type | Description |
347
- |-----------|------|-------------|
348
- | `targetUserId` | string | Recipient's DropOnAir user ID |
349
-
350
- **Returns:** `Promise<string>`, the `callId`
351
-
352
- ---
353
-
354
- #### `client.acceptCall(callId)`
355
-
356
- Accepts an incoming call. After resolving, initiate WebRTC negotiation via `sendCallSignal`.
357
-
358
- **Returns:** `Promise<void>`
359
-
360
- ---
361
-
362
- #### `client.rejectCall(callId)`
363
-
364
- Rejects an incoming call invitation.
365
-
366
- **Returns:** `Promise<void>`
367
-
368
- ---
369
-
370
- #### `client.endCall(callId)`
371
-
372
- Ends an active call or cancels an unanswered outgoing invite.
373
-
374
- **Returns:** `Promise<void>`
375
-
376
- ---
377
-
378
- #### `client.toggleVideo(callId, enabled)`
379
-
380
- Toggles the local video track on or off. Sends a `CALL_VIDEO_TOGGLE` signal to the peer so their UI can reflect the change.
381
-
382
- | Parameter | Type | Description |
383
- |-----------|------|-------------|
384
- | `callId` | string | Active call ID |
385
- | `enabled` | boolean | `true` = video on, `false` = voice-only |
386
-
387
- **Returns:** `void`
388
-
389
- ---
390
-
391
- #### `client.sendCallSignal(type, callId, payload)`
392
-
393
- Sends a raw WebRTC signaling frame to the peer over the DropOnAir relay.
394
-
395
- | Parameter | Type | Description |
396
- |-----------|------|-------------|
397
- | `type` | `'CALL_SDP_OFFER'` \| `'CALL_SDP_ANSWER'` \| `'CALL_ICE_CANDIDATE'` | Signal type |
398
- | `callId` | string | Active call ID |
399
- | `payload` | string | JSON-serialized SDP or ICE candidate |
400
-
401
- **Returns:** `void`
402
-
403
- ---
404
-
405
- #### `client.onCallEvent(callback)`
406
-
407
- Registers a listener for all call lifecycle and signaling events.
408
-
409
- **Callback signature:**
410
- ```typescript
411
- (event: CallEvent) => void
412
-
413
- interface CallEvent {
414
- type: CallEventType | string;
415
- callId?: string;
416
- targetUserId?: string;
417
- payload?: string; // JSON, SDP, ICE candidate, or metadata
418
- }
419
-
420
- type CallEventType =
421
- | 'CALL_INVITE' // Incoming call from another user
422
- | 'CALL_RINGING' // Remote party is ringing
423
- | 'CALL_ACCEPTED' // Call accepted, begin WebRTC negotiation
424
- | 'CALL_REJECTED' // Call rejected by remote party
425
- | 'CALL_ENDED' // Call ended (by either party)
426
- | 'CALL_SDP_OFFER' // WebRTC SDP offer received
427
- | 'CALL_SDP_ANSWER' // WebRTC SDP answer received
428
- | 'CALL_ICE_CANDIDATE' // ICE candidate received
429
- | 'CALL_VIDEO_TOGGLE' // Remote peer toggled video
430
- | 'CALL_DENIED_LIMIT_REACHED'; // Call rejected, plan limit reached
431
- ```
432
-
433
- **Returns:** Unsubscribe function `() => void`
434
-
435
- ---
436
-
437
- #### `client.fetchTurnCredentials()`
438
-
439
- Fetches short-lived TURN server credentials from DropOnAir for ICE negotiation. Always call this before creating an `RTCPeerConnection` to ensure NAT traversal works reliably.
440
-
441
- **Returns:** `Promise<TurnCredentials>`
442
-
443
- ```typescript
444
- interface TurnCredentials {
445
- username: string;
446
- password: string;
447
- uri: string; // e.g. "turn:turn.droponair.com:3478"
448
- ttlSeconds: number;
449
- }
450
- ```
451
-
452
- ---
453
-
454
- ## 👥 Group Messaging & Calls
455
-
456
- Groups support both E2EE and cleartext messaging, plus mesh WebRTC group calls.
457
-
458
- ### Group Management
459
-
460
- ```typescript
461
- // Create a group
462
- const group = await client.createGroup('Project Chat', ['user-1', 'user-2']);
463
-
464
- // List your groups
465
- const groups = await client.listGroups();
466
-
467
- // Manage members
468
- await client.addGroupMembers(group.groupId, ['user-3']);
469
- await client.removeGroupMembers(group.groupId, ['user-1']);
470
-
471
- // Get group details
472
- const members = await client.getGroupMembers(group.groupId);
473
-
474
- // Delete a group (owner only)
475
- await client.deleteGroup(group.groupId);
476
- ```
477
-
478
- ### Sending Group Messages
479
-
480
- ```typescript
481
- // E2EE group message (sender-side fan-out, encrypts per member per device)
482
- await client.sendGroupMessage(groupId, 'Hello team!');
483
-
484
- // Cleartext group message (no crypto overhead)
485
- await client.sendGroupMessage(groupId, 'Public announcement', { cleartext: true });
486
- ```
487
-
488
- ### Receiving Group Messages
489
-
490
- ```typescript
491
- client.onGroupMessage((msg) => {
492
- console.log(`[${msg.groupId}] ${msg.senderId}: ${msg.plaintext}`);
493
- });
494
-
495
- // Remove listener
496
- client.offGroupMessage(handler);
497
- ```
498
-
499
- ### Group Calls (Mesh WebRTC)
500
-
501
- ```typescript
502
- // Start a group call
503
- await client.startGroupCall(groupId, 'video');
504
-
505
- // Join an existing group call
506
- await client.joinGroupCall(groupId);
507
-
508
- // Listen for group call events (OFFER, ANSWER, ICE, JOIN, LEAVE, END)
509
- client.onGroupCallEvent((event) => {
510
- switch (event.type) {
511
- case 'OFFER':
512
- // Handle incoming SDP offer from a participant
513
- break;
514
- case 'ANSWER':
515
- // Handle SDP answer
516
- break;
517
- case 'ICE':
518
- // Handle ICE candidate
519
- break;
520
- case 'JOIN':
521
- // Participant joined
522
- break;
523
- case 'LEAVE':
524
- // Participant left
525
- break;
526
- }
527
- });
528
-
529
- // Leave a group call
530
- await client.leaveGroupCall(groupId);
531
- ```
532
-
533
- ### Group API Reference
534
-
535
- | Method | Description |
536
- |--------|-------------|
537
- | `createGroup(name, memberIds)` | Create group with initial members |
538
- | `listGroups()` | List groups the user belongs to |
539
- | `getGroupMembers(groupId)` | Get group member list |
540
- | `addGroupMembers(groupId, userIds)` | Add members (owner/admin) |
541
- | `removeGroupMembers(groupId, userIds)` | Remove members (owner/admin) |
542
- | `deleteGroup(groupId)` | Delete group (owner only) |
543
- | `sendGroupMessage(groupId, text, opts?)` | Send E2EE or cleartext group message |
544
- | `onGroupMessage(callback)` | Listen for inbound group messages |
545
- | `offGroupMessage(callback)` | Remove group message listener |
546
- | `startGroupCall(groupId, type)` | Start a group voice/video call |
547
- | `joinGroupCall(groupId)` | Join an existing group call |
548
- | `leaveGroupCall(groupId)` | Leave a group call |
549
- | `endGroupCall(groupId)` | End a group call (initiator) |
550
- | `sendGroupCallSignal(type, callId, target, payload)` | Send SDP/ICE to a peer |
551
- | `onGroupCallEvent(callback)` | Listen for group call events |
552
- | `offGroupCallEvent(callback)` | Remove group call event listener |
553
-
554
- ### Per-Plan Group Limits
555
-
556
- | Limit | FREE | PRO | GROWTH | PAYG | ENTERPRISE |
557
- |-------|------|-----|--------|------|------------|
558
- | Groups / app | 1 | 20 | 100 | ∞ | ∞ |
559
- | Members / group | 5 | 50 | 100 | ∞ | ∞ |
560
- | Group messages / month | 500 | 10,000 | 50,000 | ∞ | ∞ |
561
- | Group call minutes / month | 50 | 1,000 | 5,000 | ∞ | ∞ |
562
- | Group call participants | 4 | 8 | 16 | 32 | Custom |
563
-
564
- ---
565
-
566
- ## 🏗️ Architecture
567
-
568
- ### Module Structure
569
-
570
- ```
571
- droponair-sdk-js/
572
- ├── src/
573
- │ ├── index.ts # Public API exports
574
- │ ├── version.ts # SDK_VERSION, PROTOCOL_VERSION, PAYLOAD_FORMAT_VERSION
575
- │ ├── core/
576
- │ │ ├── messaging-client.ts # WebSocket client + message routing
577
- │ │ ├── session-manager.ts # Shared secret caching
578
- │ │ ├── types.ts # TypeScript interfaces
579
- │ │ └── bytes.ts # Encoding utilities
580
- │ ├── crypto/
581
- │ │ ├── crypto-service.ts # X25519 + AES-256-GCM encryption
582
- │ │ └── payload-format.ts # Binary payload packing
583
- │ ├── transport/
584
- │ │ └── protobuf-codec.ts # Envelope/Ack/Event encoding
585
- │ └── storage/
586
- │ ├── indexeddb-key-storage.ts # Browser key storage
587
- │ └── memory-key-storage.ts # In-memory fallback
588
- └── test-e2ee.js # E2EE validation test
589
- ```
590
-
591
- ### Versioning
592
-
593
- The SDK uses three distinct version numbers to ensure backward compatibility:
594
-
595
- | Version | Location | Purpose |
596
- |---------|----------|---------|
597
- | **SDK_VERSION** (`0.2.0`) | `src/version.ts`, `package.json` | Semver of the JS/TS SDK package itself. Sent as `X-SDK-Version` header on token exchange and `sdkVersion` query param on WebSocket handshake. |
598
- | **PROTOCOL_VERSION** (`1`) | `src/version.ts` | Wire protocol version, sent as `protocolVersion` query param on WebSocket connect. Increment when adding required proto fields or changing frame semantics. |
599
- | **PAYLOAD_FORMAT_VERSION** (`1`) | `src/version.ts` | First byte of every encrypted payload. Receivers check this before decrypting. Increment when changing the ciphertext binary layout. |
600
-
601
- **Version negotiation:** The server exposes `GET /api/info` (no auth required) which returns `protocolVersion`, `minSdkVersion`, and supported `features`. Clients can call this at startup to validate compatibility.
602
-
603
- **Backward compatibility rules:**
604
- - New proto fields are always additive (proto3 silently ignores unknown fields)
605
- - The `encryptedPayload` (field 6) legacy path is preserved alongside multi-device `devicePayloads` (field 8)
606
- - Encrypted payload version byte is checked on decrypt; unknown versions throw a clear error
607
- - Bumping `SDK_VERSION` or `PROTOCOL_VERSION` must update this README and `CHANGELOG.md`
608
-
609
- ### Multi-Device Support
610
-
611
- The SDK supports per-device encryption for multi-device messaging:
612
-
613
- **How it works:**
614
- 1. Each device generates its own X25519 identity keypair on first use
615
- 2. Device public keys are published to the key directory (your backend) with a unique `deviceId`
616
- 3. When sending a message, the SDK fetches ALL device keys for the recipient and encrypts once per device
617
- 4. The sender also encrypts for their OWN other devices (self-sync)
618
- 5. Each `Envelope` carries `devicePayloads[]`, one entry per device with device-specific ciphertext
619
- 6. The receiver picks the `DeviceEncryptedPayload` matching their `deviceId` and decrypts
620
-
621
- **Wire format (Envelope proto):**
622
- ```protobuf
623
- message Envelope {
624
- string messageId = 1;
625
- string appId = 2;
626
- string fromUserId = 3;
627
- string toUserId = 4;
628
- int64 timestamp = 5;
629
- bytes encryptedPayload = 6; // legacy single-device (empty when devicePayloads used)
630
- string clientMessageId = 7;
631
- repeated DeviceEncryptedPayload devicePayloads = 8; // multi-device
632
- string senderDeviceId = 9;
633
- }
634
- ```
635
-
636
- **Backward compatibility:**
637
- - If the recipient has no device keys (old SDK), the sender falls back to the legacy single `encryptedPayload` path
638
- - Incoming envelopes with `devicePayloads.length > 0` use the multi-device decryption path; otherwise the legacy path is used
639
-
640
- ### Encrypted Payload Format
641
-
642
- ```
643
- +--------+--------+--------+--------+----------+-------------+-------+------------+
644
- | Version| Algor | Nonce | Flags | Cipher | Signature | Nonce | Ciphertext |
645
- | (1B) | (1B) | Len(1B)| (1B) | Len(4B) | Len(4B) | (12B) | (variable) |
646
- +--------+--------+--------+--------+----------+-------------+-------+------------+
647
- ```
648
-
649
- - **Version:** Payload format version (1)
650
- - **Algorithm:** Encryption algorithm (1 = AES-256-GCM)
651
- - **Nonce Length:** GCM nonce length (12 bytes)
652
- - **Flags:** Optional signature flag (0 = no signature, 1 = Ed25519 signature)
653
- - **Cipher Length:** Ciphertext length (big-endian uint32)
654
- - **Signature Length:** Signature length if present (big-endian uint32)
655
- - **Nonce:** 96-bit GCM nonce (random per message)
656
- - **Ciphertext:** AES-256-GCM encrypted plaintext + 128-bit auth tag
657
-
658
- **Forward Compatibility:** Future versions may add Ed25519 signatures for sender authenticity.
659
-
660
- ---
661
-
662
- ## 🔒 Security Considerations
663
-
664
- ### ✅ What DropOnAir SDK Protects
665
-
666
- - **Confidentiality:** All message content encrypted with AES-256-GCM before leaving device
667
- - **Integrity:** GCM authentication tag prevents tampering
668
- - **Forward Secrecy:** Shared secrets derived per peer, cleared on disconnect
669
- - **Context Binding:** AAD prevents message replay/substitution attacks
670
- - **Key Isolation:** Private keys never leave device, never sent to DropOnAir
671
-
672
- ### ⚠️ Security Responsibilities
673
-
674
- 1. **JWT Security:** Protect user JWT in secure HTTP-only cookies (never localStorage)
675
- 2. **HTTPS Required:** Use HTTPS for your backend to protect JWT during token exchange
676
- 3. **CSP Headers:** Enable Content Security Policy to prevent XSS attacks on IndexedDB
677
- 4. **Key Backup:** SDK does NOT support key backup - users who lose device lose message access
678
- 5. **Public Key Authenticity:** your backend must verify user identity before accepting public key uploads
679
-
680
- ### 🚨 Known Limitations
681
-
682
- - **No Forward Secrecy Between Messages:** Uses static X25519 keys (no ratcheting like Signal Protocol)
683
- - **No Sender Authentication:** Recipient cannot verify sender identity (consider adding Ed25519 signatures)
684
- - **Metadata Leakage:** DropOnAir sees fromUserId, toUserId, timestamp, message size
685
- - **IndexedDB Vulnerability:** Browser storage vulnerable to XSS (use secure CSP)
686
- - **No Multi-Device Key Sharing:** Each device generates a separate identity; the SDK encrypts per-device but does not synchronize private keys across devices
687
-
688
- ---
689
-
690
- ## 🧪 Testing
691
-
692
- Run the E2EE validation test:
693
-
694
- ```bash
695
- npm run build
696
- node test-e2ee.js
697
- ```
698
-
699
- **Test Coverage:**
700
- - ✅ Local identity generation (X25519 keypairs)
701
- - ✅ ECDH shared secret derivation
702
- - ✅ AES-256-GCM encryption/decryption
703
- - ✅ Binary payload format validation
704
- - ✅ Tampering detection (GCM auth tag)
705
- - ✅ AAD binding (prevents context manipulation)
706
- - ✅ Session caching (performance optimization)
707
-
708
- ---
709
-
710
- ## 📚 Integration Examples
711
-
712
- ### Angular/Ionic Example
713
-
714
- ```typescript
715
- import { initialize, DropOnAirClient } from '@droponair/sdk-js';
716
- import { Injectable } from '@angular/core';
717
- import { HttpClient } from '@angular/common/http';
718
- import { firstValueFrom } from 'rxjs';
719
-
720
- @Injectable({ providedIn: 'root' })
721
- export class MessagingService {
722
- private client?: DropOnAirClient;
723
-
724
- constructor(private http: HttpClient) {}
725
-
726
- async connect() {
727
- this.client = await initialize({
728
- appId: environment.appId,
729
- publicApiKey: environment.droponairApiKey,
730
- getUserJwt: async () => {
731
- const { jwt } = await firstValueFrom(this.http.get<{ jwt: string }>('/api/auth/me'));
732
- return jwt;
733
- },
734
- keyDirectoryEndpoint: '/api/messaging/keys',
735
- tokenExchangeEndpoint: '/api/messaging/token-exchange'
736
- });
737
-
738
- this.client.onMessage(({ fromUserId, plaintext, timestamp }) => {
739
- this.handleIncomingMessage(fromUserId, plaintext, timestamp);
740
- });
741
-
742
- this.client.onEvent(({ type, reason }) => {
743
- if (type === 'LIMIT_REACHED') {
744
- this.showRateLimitWarning();
745
- }
746
- });
747
- }
748
-
749
- async sendMessage(recipientId: string, message: string) {
750
- if (!this.client) throw new Error('Not connected');
751
- return this.client.sendMessage(recipientId, message);
752
- }
753
-
754
- disconnect() {
755
- this.client?.disconnect();
756
- }
757
-
758
- private handleIncomingMessage(fromUserId: string, plaintext: string, timestamp: number) {
759
- // Update UI, store in local DB, etc.
760
- }
761
-
762
- private showRateLimitWarning() {
763
- // Show user notification
764
- }
765
- }
766
- ```
767
-
768
- ### React Example
769
-
770
- ```typescript
771
- import { initialize, DropOnAirClient } from '@droponair/sdk-js';
772
- import { useEffect, useState } from 'react';
773
-
774
- export function useMessaging() {
775
- const [client, setClient] = useState<DropOnAirClient | null>(null);
776
- const [messages, setMessages] = useState<any[]>([]);
777
-
778
- useEffect(() => {
779
- initialize({
780
- appId: process.env.REACT_APP_APP_ID!,
781
- publicApiKey: process.env.REACT_APP_DROPONAIR_API_KEY!,
782
- getUserJwt: async () => {
783
- const res = await fetch('/api/auth/me', { credentials: 'include' });
784
- const { jwt } = await res.json();
785
- return jwt;
786
- },
787
- keyDirectoryEndpoint: '/api/messaging/keys',
788
- tokenExchangeEndpoint: '/api/messaging/token-exchange'
789
- }).then((c) => {
790
- c.onMessage((msg) => {
791
- setMessages((prev) => [...prev, msg]);
792
- });
793
- setClient(c);
794
- });
795
-
796
- return () => client?.disconnect();
797
- }, []);
798
-
799
- const sendMessage = async (toUserId: string, text: string) => {
800
- if (!client) throw new Error('Not connected');
801
- await client.sendMessage(toUserId, text);
802
- };
803
-
804
- return { messages, sendMessage, connected: !!client };
805
- }
806
- ```
807
-
808
- ---
809
-
810
- ## 🛠️ Development
811
-
812
- ### Build
813
-
814
- ```bash
815
- npm run build
816
- ```
817
-
818
- ### Run Tests
819
-
820
- ```bash
821
- npm run build
822
- node test-e2ee.js
823
- ```
824
-
825
- ### Lint
826
-
827
- ```bash
828
- npm run lint
829
- ```
830
-
831
- ---
832
-
833
- ## 📄 License
61
+ const client = await initialize(options);
62
+ ```
63
+
64
+ | Option | Type | Required | Description |
65
+ |--------|------|----------|-------------|
66
+ | `appId` | string | Yes | Your DropOnAir app ID |
67
+ | `publicApiKey` | string | Yes | Your DropOnAir public API key |
68
+ | `getUserJwt` | `() => Promise<string>` | Yes | Returns a fresh user JWT from your backend |
69
+ | `keyDirectoryEndpoint` | string | No | Public key directory URL (default: `/api/messaging/keys`) |
70
+ | `tokenExchangeEndpoint` | string | No | Token exchange URL (default: `/api/messaging/token-exchange`) |
71
+ | `messagingWsUrl` | string | No | WebSocket URL (default: `wss://sdk.droponair.com/ws/messages`) |
72
+ | `messagingHttpUrl` | string | No | HTTP URL (default: `https://sdk.droponair.com`) |
73
+ | `autoConnect` | boolean | No | Connect on init (default: `true`) |
74
+ | `storage` | KeyStorageAdapter | No | Custom key storage (default: IndexedDB) |
75
+ | `debug` | boolean | No | Enable debug logging (default: `false`) |
76
+
77
+ ### Connection
78
+
79
+ | Method | Returns | Description |
80
+ |--------|---------|-------------|
81
+ | `connect()` | `Promise<void>` | Connect to DropOnAir (generates keys, exchanges JWT, opens WebSocket) |
82
+ | `disconnect()` | `void` | Disconnect and stop auto-reconnect |
83
+
84
+ ### E2EE Messaging
85
+
86
+ | Method | Returns | Description |
87
+ |--------|---------|-------------|
88
+ | `sendMessage(toUserId, plaintext)` | `Promise<{ messageId }>` | Send an encrypted message |
89
+ | `sendCleartextMessage(toUserId, plaintext)` | `Promise<{ messageId }>` | Send a cleartext message (no E2EE) |
90
+ | `onMessage(callback)` | `() => void` | Listen for incoming messages. Returns unsubscribe function |
91
+ | `onEvent(callback)` | `() => void` | Listen for system events (CONNECTED, DELIVERED, ERROR, etc.) |
92
+ | `ack(messageId)` | `Promise<void>` | Manually acknowledge a message |
93
+
94
+ ### Broadcast Channels
95
+
96
+ | Method | Returns | Description |
97
+ |--------|---------|-------------|
98
+ | `subscribeBroadcast(channelId)` | `Promise<void>` | Subscribe to a channel |
99
+ | `unsubscribeBroadcast(channelId)` | `Promise<void>` | Unsubscribe from a channel |
100
+ | `publishBroadcast(channelId, plaintext)` | `Promise<{ broadcastId }>` | Publish to a channel |
101
+ | `onBroadcast(callback)` | `() => void` | Listen for broadcast messages |
102
+
103
+ ### Groups
104
+
105
+ | Method | Returns | Description |
106
+ |--------|---------|-------------|
107
+ | `createGroup(name, memberUserIds?)` | `Promise<GroupInfo>` | Create a group with optional members |
108
+ | `listGroups()` | `Promise<GroupInfo[]>` | List your groups |
109
+ | `getGroup(groupId)` | `Promise<GroupInfo>` | Get group details |
110
+ | `addGroupMembers(groupId, userIds)` | `Promise<GroupInfo>` | Add members (owner/admin) |
111
+ | `removeGroupMember(groupId, userId)` | `Promise<GroupInfo>` | Remove a member (owner/admin) |
112
+ | `deleteGroup(groupId)` | `Promise<void>` | Delete a group (owner only) |
113
+ | `sendGroupMessage(groupId, plaintext)` | `Promise<{ messageId }>` | Send a group message |
114
+ | `onGroupMessage(callback)` | `() => void` | Listen for group messages |
115
+
116
+ ### 1-to-1 Calls
117
+
118
+ | Method | Returns | Description |
119
+ |--------|---------|-------------|
120
+ | `startCall(targetUserId)` | `Promise<string>` | Start a call, returns callId |
121
+ | `acceptCall(callId)` | `Promise<void>` | Accept incoming call |
122
+ | `rejectCall(callId)` | `Promise<void>` | Reject incoming call |
123
+ | `endCall(callId)` | `Promise<void>` | End or cancel a call |
124
+ | `toggleVideo(callId, enabled)` | `void` | Toggle video on/off |
125
+ | `sendCallSignal(type, callId, payload)` | `void` | Send SDP/ICE signaling data |
126
+ | `onCallEvent(callback)` | `() => void` | Listen for call events |
127
+ | `fetchTurnCredentials()` | `Promise<TurnCredentials>` | Get TURN server credentials for NAT traversal |
128
+
129
+ ### Group Calls
130
+
131
+ | Method | Returns | Description |
132
+ |--------|---------|-------------|
133
+ | `startGroupCall(groupId)` | `Promise<string>` | Start a group call, returns callId |
134
+ | `joinGroupCall(callId, groupId)` | `Promise<void>` | Join an active group call |
135
+ | `leaveGroupCall(callId)` | `Promise<void>` | Leave a group call |
136
+ | `endGroupCall(callId)` | `Promise<void>` | End a group call for all |
137
+ | `sendGroupCallSignal(type, callId, groupId, targetUserId, payload)` | `void` | Send SDP/ICE to a peer |
138
+ | `onGroupCallEvent(callback)` | `() => void` | Listen for group call events |
139
+
140
+ ## Security
141
+
142
+ - **X25519 ECDH** key agreement for shared secrets
143
+ - **HKDF-SHA256** key derivation
144
+ - **AES-256-GCM** authenticated encryption with AAD binding (messageId, senderId, recipientId, timestamp)
145
+ - Private keys generated and stored locally, never sent to DropOnAir
146
+ - Multi-device: each device has its own keypair, messages encrypted per-device
147
+
148
+ ## Requirements
149
+
150
+ - Node.js >= 18.0.0
151
+ - Browser with Web Crypto API support (all modern browsers)
152
+
153
+ ## License
834
154
 
835
155
  MIT
836
156
 
837
- ---
838
-
839
- ## 🤝 Support
840
-
841
- For issues or questions:
842
- - **GitHub Issues:** https://github.com/droponair/droponair-sdk-js
843
- - **Documentation:** https://docs.droponair.com
844
- - **Email:** support@droponair.com
845
-
846
- ---
847
-
848
- ## 🎯 Final Security Validation Checklist
849
-
850
- Validation test results:
851
-
852
- ```
853
- ✅ SDK generates identity keys locally (X25519)
854
- ✅ DropOnAir never stores user keys (server refactored)
855
- ✅ Encryption fully client-side (AES-256-GCM)
856
- ✅ Payload opaque over wire (binary protobuf)
857
- ✅ Two users exchange encrypted messages
858
- ✅ Recipient decrypts correctly
859
- ✅ DropOnAir never accesses plaintext (blind relay)
860
- ✅ Tampering detection (GCM auth tag)
861
- ✅ AAD binding prevents context attacks
862
- ✅ Session caching for performance
863
- ```
157
+ ## Support
864
158
 
865
- **🔒 End-to-End Encryption is FULLY FUNCTIONAL**
159
+ - **Website:** [droponair.com](https://droponair.com)
160
+ - **Email:** info@droponair.com
package/dist/version.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * MINOR, additive feature (e.g. multi-device payloads, new call event type)
8
8
  * PATCH, bug-fix / perf improvement with no wire or API change
9
9
  */
10
- export declare const SDK_VERSION = "0.3.0";
10
+ export declare const SDK_VERSION = "0.3.1";
11
11
  /**
12
12
  * Binary encrypted-payload format version.
13
13
  * Included as the first byte of every encrypted payload so receivers can
package/dist/version.js CHANGED
@@ -10,7 +10,7 @@ exports.PROTOCOL_VERSION = exports.PAYLOAD_FORMAT_VERSION = exports.SDK_VERSION
10
10
  * MINOR, additive feature (e.g. multi-device payloads, new call event type)
11
11
  * PATCH, bug-fix / perf improvement with no wire or API change
12
12
  */
13
- exports.SDK_VERSION = '0.3.0';
13
+ exports.SDK_VERSION = '0.3.1';
14
14
  /**
15
15
  * Binary encrypted-payload format version.
16
16
  * Included as the first byte of every encrypted payload so receivers can
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@droponair/sdk-js",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "DropOnAir SDK for end-to-end encrypted messaging",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",