@droponair/sdk-js 0.3.0

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 ADDED
@@ -0,0 +1,865 @@
1
+ # DropOnAir JS SDK
2
+
3
+ **End-to-End Encrypted Real-Time Messaging SDK**
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.
6
+
7
+ ---
8
+
9
+ ## 🔐 Security Architecture
10
+
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
36
+
37
+ ```bash
38
+ npm install @droponair/sdk-js
39
+ ```
40
+
41
+ ---
42
+
43
+ ## 🚀 Quick Start
44
+
45
+ ### Basic Usage (TypeScript)
46
+
47
+ ```typescript
48
+ import { initialize } from '@droponair/sdk-js';
49
+
50
+ // Initialize SDK with backend integration
51
+ 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
56
+ getUserJwt: async () => {
57
+ const response = await fetch('/api/auth/me', { credentials: 'include' });
58
+ const data = await response.json();
59
+ return data.jwt;
60
+ },
61
+
62
+ // Key directory endpoint (your backend serves public keys)
63
+ keyDirectoryEndpoint: '/api/messaging/keys',
64
+
65
+ // Token exchange endpoint (your backend → DropOnAir JWT)
66
+ 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
+ });
75
+
76
+ // Listen for incoming messages (receives decrypted plaintext)
77
+ client.onMessage(({ messageId, fromUserId, toUserId, timestamp, plaintext }) => {
78
+ console.log(`Message from ${fromUserId}: ${plaintext}`);
79
+ });
80
+
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();
96
+ ```
97
+
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.
122
+
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`)
134
+
135
+ **Returns:** `Promise<DropOnAirClient>`
136
+
137
+ ---
138
+
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
+ ```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
834
+
835
+ MIT
836
+
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
+ ```
864
+
865
+ **🔒 End-to-End Encryption is FULLY FUNCTIONAL**