@droponair/sdk-js 0.24.2 → 0.25.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/CHANGELOG.md +41 -0
- package/README.md +45 -0
- package/dist/core/messaging-client.d.ts +8 -1
- package/dist/core/messaging-client.js +9 -0
- package/dist/core/types.d.ts +81 -0
- package/dist/crypto/crypto-service.d.ts +10 -4
- package/dist/crypto/crypto-service.js +41 -44
- package/dist/crypto/nacl-identity-provider.d.ts +25 -0
- package/dist/crypto/nacl-identity-provider.js +60 -0
- package/dist/crypto/webcrypto-identity-provider.d.ts +116 -0
- package/dist/crypto/webcrypto-identity-provider.js +265 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +46 -2
- package/dist/transport/protobuf-codec.js +8 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +27 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.25.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`keyCustody` on the client**, reporting how the identity private key is held:
|
|
8
|
+
`'non-extractable'` when the key exists only inside the platform's crypto agent and cannot
|
|
9
|
+
be read by script, `'software'` when it is bytes the process can read. Every release before
|
|
10
|
+
this one behaved as `'software'`, and still does unless the new option below is used. Read it
|
|
11
|
+
rather than assuming: a consumer that believes it has the stronger custody and does not is
|
|
12
|
+
worse off than one that knows.
|
|
13
|
+
- **`createSecureIdentity()`** and **`InitializeOptions.identity`**, an opt-in identity whose
|
|
14
|
+
private key script cannot read. The key is generated non-extractable and used through the
|
|
15
|
+
agent for the key agreement, so an attacker who runs script on the origin can ask it to
|
|
16
|
+
derive while the page is open, and cannot steal the key for later or elsewhere.
|
|
17
|
+
- **`WebCryptoIdentityProvider`**, **`IndexedDbIdentityRecordStore`**,
|
|
18
|
+
**`MemoryIdentityRecordStore`** and the **`IdentityRecordStore`** interface are exported, so
|
|
19
|
+
custody can be implemented against something else entirely.
|
|
20
|
+
|
|
21
|
+
### Notes
|
|
22
|
+
|
|
23
|
+
- **Opt-in, and unchanged by default.** Passing no `identity` keeps exactly the previous
|
|
24
|
+
behaviour. The stronger path is offered, not imposed.
|
|
25
|
+
- **`createSecureIdentity()` returns `null` where the platform cannot deliver it**, rather than
|
|
26
|
+
throwing or quietly substituting something weaker. Two conditions are checked: whether the
|
|
27
|
+
primitive exists, and whether a key survives being stored and read back.
|
|
28
|
+
- **Availability differs by engine.** The protected path requires an X25519 key that persists.
|
|
29
|
+
Chromium provides that. WebKit generates and derives correctly and then loses the key when it
|
|
30
|
+
is stored, reporting the write as successful, so `createSecureIdentity()` returns `null`
|
|
31
|
+
there and the ordinary path is used. This is checked at runtime rather than assumed from a
|
|
32
|
+
user agent.
|
|
33
|
+
- **Existing identities can be carried over** with `migrateFromKeyStorage()`, which imports the
|
|
34
|
+
stored key, verifies it, and only then removes the readable copy. The public key is unchanged,
|
|
35
|
+
so peers still address the same identity and existing conversations keep decrypting. Nothing
|
|
36
|
+
is re-keyed and no history is lost.
|
|
37
|
+
- The wire format is untouched. Both paths perform the same X25519 agreement and derive the
|
|
38
|
+
same message key, which is covered by tests in both directions.
|
|
39
|
+
|
|
40
|
+
## 0.24.3
|
|
41
|
+
|
|
42
|
+
- Fix: broadcasts (and events/acks) misrouted. Inbound Ack/GroupAck discriminators now require a real SCREAMING_SNAKE ack token, so a BroadcastNotification (publisherId in the type slot) is no longer decoded as a GroupAck before reaching the broadcast branch. Completes the frame-classifier fix.
|
|
43
|
+
|
|
3
44
|
## 0.24.2
|
|
4
45
|
|
|
5
46
|
- Fix: E2EE group message decryption failed because the decrypt AAD bound `recipientId` to the group id instead of the receiving user's own id (which is what the sender encrypts against). Now matches the sender and the other SDKs. Requires sdk-be that preserves the sender timestamp on group notifications.
|
package/README.md
CHANGED
|
@@ -107,6 +107,51 @@ const client = await initialize(options);
|
|
|
107
107
|
| `syncDraft(conversationId, draftText)` | `void` | Push a conversation draft to the user's other devices (opt-in, cleartext). |
|
|
108
108
|
| `onDraftSync(callback)` | `() => void` | Listen for draft syncs from the user's other devices. |
|
|
109
109
|
|
|
110
|
+
### Key custody
|
|
111
|
+
|
|
112
|
+
Every client reports how it is holding the identity private key:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
const client = await initialize({ /* ... */ });
|
|
116
|
+
console.log(client.keyCustody); // 'software' | 'non-extractable'
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`'software'` means the key is bytes the process can read. That is what every release before
|
|
120
|
+
0.25.0 did, and what this one still does unless you opt in below.
|
|
121
|
+
|
|
122
|
+
`'non-extractable'` means the key exists only inside the browser's crypto agent: your code can
|
|
123
|
+
ask it to derive a shared secret, and cannot read the key itself. Script that runs on your
|
|
124
|
+
origin can therefore use the identity while the page is open, and cannot copy it and decrypt
|
|
125
|
+
elsewhere or later.
|
|
126
|
+
|
|
127
|
+
Opt in by supplying an identity:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import { initialize, createSecureIdentity } from '@droponair/sdk-js';
|
|
131
|
+
|
|
132
|
+
const identity = await createSecureIdentity();
|
|
133
|
+
if (identity) {
|
|
134
|
+
// Optional, once: carry an existing key over so the identity and its history survive.
|
|
135
|
+
await identity.migrateFromKeyStorage(myStorage);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const client = await initialize({
|
|
139
|
+
appId, publicApiKey, getUserJwt,
|
|
140
|
+
identity: identity ?? undefined, // null means this browser cannot provide it
|
|
141
|
+
});
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`createSecureIdentity()` returns `null` where the platform cannot deliver the guarantee, rather
|
|
145
|
+
than throwing or quietly giving you something weaker. It checks two things: whether the
|
|
146
|
+
primitive exists, and whether a key survives being stored and read back. That second check
|
|
147
|
+
matters, because one major engine generates and derives with the key correctly and then loses
|
|
148
|
+
it on write while reporting success. Read `client.keyCustody` afterwards to see what you
|
|
149
|
+
actually got.
|
|
150
|
+
|
|
151
|
+
Migration is yours to trigger, not automatic, because it removes the readable copy of the key.
|
|
152
|
+
It imports the existing key, verifies it, and only then deletes the original. The public key is
|
|
153
|
+
unchanged, so peers still address the same identity and existing conversations keep decrypting.
|
|
154
|
+
|
|
110
155
|
### Cross-device read receipts
|
|
111
156
|
|
|
112
157
|
Available since SDK `0.11.0`. **Your app decides when a message is read** — the platform never infers it. Call `markRead()` at that moment; the receipt syncs to the user's *other* devices so they can clear their unread UI. It is not sent to the message's sender. The app owner can switch read receipts off entirely from the dashboard.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CryptoService } from '../crypto/crypto-service';
|
|
2
2
|
import { SessionManager } from './session-manager';
|
|
3
|
-
import { BroadcastCallback, CallEventCallback, DropOnAirClient, EventCallback, CreateRoomOptions, GroupCallEventCallback, GroupInfo, GroupMessageCallback, Room, UpdateRoomOptions, SfuToken, SfuRecording, InitializeOptions, MessageCallback, MessageDeleteCallback, MessageEditCallback, TurnCredentials, DeviceInfo, ReadReceiptCallback, NotificationClearCallback, DraftSyncCallback } from './types';
|
|
3
|
+
import { BroadcastCallback, CallEventCallback, DropOnAirClient, EventCallback, CreateRoomOptions, GroupCallEventCallback, GroupInfo, GroupMessageCallback, Room, UpdateRoomOptions, SfuToken, SfuRecording, InitializeOptions, MessageCallback, MessageDeleteCallback, MessageEditCallback, TurnCredentials, DeviceInfo, ReadReceiptCallback, NotificationClearCallback, DraftSyncCallback, KeyCustody } from './types';
|
|
4
4
|
import { AttachmentRef, CreateUploadSessionOptions, DownloadedAttachment, PrepareAttachmentOptions, UploadSession } from '../attachment/attachment-types';
|
|
5
5
|
export declare class MessagingClient implements DropOnAirClient {
|
|
6
6
|
private readonly options;
|
|
@@ -100,6 +100,13 @@ export declare class MessagingClient implements DropOnAirClient {
|
|
|
100
100
|
finalizeAttachment(attachmentId: string, sha256: string): Promise<void>;
|
|
101
101
|
downloadAttachment(ref: AttachmentRef): Promise<DownloadedAttachment>;
|
|
102
102
|
revokeAttachment(attachment: string | AttachmentRef): Promise<void>;
|
|
103
|
+
/**
|
|
104
|
+
* How the identity private key is held. See {@link KeyCustody}.
|
|
105
|
+
*
|
|
106
|
+
* Read from the crypto service rather than remembered at construction, so it
|
|
107
|
+
* reflects what is actually in use rather than what was requested.
|
|
108
|
+
*/
|
|
109
|
+
get keyCustody(): KeyCustody;
|
|
103
110
|
connect(): Promise<void>;
|
|
104
111
|
disconnect(): void;
|
|
105
112
|
sendMessage(toUserId: string, plaintextMessage: string, options?: {
|
|
@@ -313,6 +313,15 @@ class MessagingClient {
|
|
|
313
313
|
await this.attachmentClient.revokeAttachment(attachment.thumbnailAttachmentId);
|
|
314
314
|
}
|
|
315
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* How the identity private key is held. See {@link KeyCustody}.
|
|
318
|
+
*
|
|
319
|
+
* Read from the crypto service rather than remembered at construction, so it
|
|
320
|
+
* reflects what is actually in use rather than what was requested.
|
|
321
|
+
*/
|
|
322
|
+
get keyCustody() {
|
|
323
|
+
return this.cryptoService.keyCustody;
|
|
324
|
+
}
|
|
316
325
|
async connect() {
|
|
317
326
|
this.log('connect_start');
|
|
318
327
|
this.shouldReconnect = true;
|
package/dist/core/types.d.ts
CHANGED
|
@@ -251,6 +251,57 @@ export interface KeyStorageAdapter {
|
|
|
251
251
|
set(key: string, value: string): Promise<void>;
|
|
252
252
|
remove(key: string): Promise<void>;
|
|
253
253
|
}
|
|
254
|
+
/**
|
|
255
|
+
* How the identity private key is held.
|
|
256
|
+
*
|
|
257
|
+
* - `non-extractable`: the key lives in the agent as a CryptoKey that script
|
|
258
|
+
* cannot export. An attacker running script on the origin can still use it
|
|
259
|
+
* while the page is open, which is unavoidable, but cannot copy it out and use
|
|
260
|
+
* it later or elsewhere.
|
|
261
|
+
* - `software`: the key exists as bytes reachable from script, so anything that
|
|
262
|
+
* can run script on the origin can read it once and decrypt that user's
|
|
263
|
+
* conversations from anywhere, indefinitely.
|
|
264
|
+
*
|
|
265
|
+
* Exposed so an application can tell its users the truth rather than assume.
|
|
266
|
+
*/
|
|
267
|
+
export type KeyCustody = 'non-extractable' | 'software';
|
|
268
|
+
/**
|
|
269
|
+
* Owns the identity keypair and the key agreement that uses it.
|
|
270
|
+
*
|
|
271
|
+
* This exists because a storage interface alone cannot make key material
|
|
272
|
+
* XSS-resistant. The strongest browsers offer is a non-extractable CryptoKey,
|
|
273
|
+
* and by definition its bytes cannot be handed back to a caller, so any design
|
|
274
|
+
* shaped as "give me the private key and I will do the maths" forces the key to
|
|
275
|
+
* be extractable. The agreement has to happen behind the same boundary that
|
|
276
|
+
* holds the key, which is what this interface is.
|
|
277
|
+
*
|
|
278
|
+
* Implement it to hold keys somewhere the SDK cannot reach, for example an OS
|
|
279
|
+
* keychain or a hardware token. The SDK ships two implementations and picks the
|
|
280
|
+
* conservative one unless asked otherwise; it never silently upgrades you.
|
|
281
|
+
*/
|
|
282
|
+
export interface SecureIdentityProvider {
|
|
283
|
+
/** Base64 X25519 public key for this device, created on first use. */
|
|
284
|
+
getPublicKey(): Promise<string>;
|
|
285
|
+
/**
|
|
286
|
+
* Perform X25519 with the peer's public key, then HKDF-SHA256 to an AES-GCM
|
|
287
|
+
* key, and return it.
|
|
288
|
+
*
|
|
289
|
+
* Returns the derived key rather than the shared secret so the secret need
|
|
290
|
+
* never exist outside the implementation. The returned key must be
|
|
291
|
+
* non-extractable: it is used for encrypt and decrypt and nothing else.
|
|
292
|
+
*
|
|
293
|
+
* `salt` and `info` are supplied by the SDK and are part of the wire format.
|
|
294
|
+
* An implementation that changes them will produce keys that no other
|
|
295
|
+
* participant can reproduce.
|
|
296
|
+
*/
|
|
297
|
+
deriveMessageKey(params: {
|
|
298
|
+
peerPublicKey: string;
|
|
299
|
+
salt: Uint8Array;
|
|
300
|
+
info: Uint8Array;
|
|
301
|
+
}): Promise<CryptoKey>;
|
|
302
|
+
/** Whether the private key can be read by script. See {@link KeyCustody}. */
|
|
303
|
+
readonly keyCustody: KeyCustody;
|
|
304
|
+
}
|
|
254
305
|
export interface InitializeOptions {
|
|
255
306
|
appId: string;
|
|
256
307
|
publicApiKey: string;
|
|
@@ -262,6 +313,22 @@ export interface InitializeOptions {
|
|
|
262
313
|
keyDirectoryEndpoint?: string;
|
|
263
314
|
fetchFn?: typeof fetch;
|
|
264
315
|
storage?: KeyStorageAdapter;
|
|
316
|
+
/**
|
|
317
|
+
* Owns the identity keypair instead of {@link storage}, when the consumer wants
|
|
318
|
+
* a private key that script cannot read.
|
|
319
|
+
*
|
|
320
|
+
* Optional and off by default, deliberately. Passing nothing keeps exactly the
|
|
321
|
+
* behaviour every existing release has: the identity is a raw key held through
|
|
322
|
+
* {@link storage}. Switching that silently would change key custody under
|
|
323
|
+
* applications that never asked for it, and on engines where the stronger path
|
|
324
|
+
* cannot persist it would do so without delivering anything.
|
|
325
|
+
*
|
|
326
|
+
* Build one with `createSecureIdentity()`, which returns `null` where the
|
|
327
|
+
* platform cannot support it so the choice is explicit rather than accidental.
|
|
328
|
+
* Read {@link DropOnAirClient.keyCustody} afterwards to see what was actually
|
|
329
|
+
* obtained.
|
|
330
|
+
*/
|
|
331
|
+
identity?: SecureIdentityProvider;
|
|
265
332
|
/** When true, verbose diagnostic logs are emitted to console for all SDK operations. */
|
|
266
333
|
debug?: boolean;
|
|
267
334
|
/**
|
|
@@ -299,6 +366,20 @@ export interface DeviceInfo {
|
|
|
299
366
|
revokedBy?: 'END_USER' | 'APP_OWNER' | 'SYSTEM';
|
|
300
367
|
}
|
|
301
368
|
export interface DropOnAirClient {
|
|
369
|
+
/**
|
|
370
|
+
* How this client is holding the identity private key.
|
|
371
|
+
*
|
|
372
|
+
* `'non-extractable'` means the key exists only inside the platform's crypto
|
|
373
|
+
* agent: script may ask it to derive, and cannot read it, so storage taken from
|
|
374
|
+
* the device yields nothing usable. `'software'` means the key is bytes the
|
|
375
|
+
* process can read, which is what every release before this one did and what
|
|
376
|
+
* this one still does unless {@link InitializeOptions.identity} is supplied.
|
|
377
|
+
*
|
|
378
|
+
* Exposed because a consumer that believes it has the stronger custody and does
|
|
379
|
+
* not is worse off than one that knows. Log it, show it, or gate a feature on
|
|
380
|
+
* it, but do not assume it.
|
|
381
|
+
*/
|
|
382
|
+
readonly keyCustody: KeyCustody;
|
|
302
383
|
connect(): Promise<void>;
|
|
303
384
|
disconnect(): void;
|
|
304
385
|
/**
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { SessionManager } from '../core/session-manager';
|
|
2
|
-
import { KeyStorageAdapter } from '../core/types';
|
|
2
|
+
import { KeyCustody, KeyStorageAdapter, SecureIdentityProvider } from '../core/types';
|
|
3
3
|
export declare class CryptoService {
|
|
4
|
-
private readonly storage;
|
|
5
4
|
private readonly sessionManager;
|
|
6
|
-
|
|
5
|
+
private readonly identity;
|
|
6
|
+
/**
|
|
7
|
+
* Accepts either an identity provider or, for compatibility, the storage
|
|
8
|
+
* adapter it used to take. Passing storage keeps the previous behaviour
|
|
9
|
+
* exactly, by wrapping it in the tweetnacl provider.
|
|
10
|
+
*/
|
|
11
|
+
constructor(storageOrIdentity: KeyStorageAdapter | SecureIdentityProvider, sessionManager: SessionManager);
|
|
12
|
+
/** How the identity private key is held. See {@link KeyCustody}. */
|
|
13
|
+
get keyCustody(): KeyCustody;
|
|
7
14
|
generateIdentity(): Promise<{
|
|
8
15
|
publicKey: string;
|
|
9
16
|
}>;
|
|
@@ -23,6 +30,5 @@ export declare class CryptoService {
|
|
|
23
30
|
recipientId: string;
|
|
24
31
|
timestamp: number;
|
|
25
32
|
}): Promise<string>;
|
|
26
|
-
private deriveAesKey;
|
|
27
33
|
private buildAad;
|
|
28
34
|
}
|
|
@@ -1,35 +1,50 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
3
|
exports.CryptoService = void 0;
|
|
7
|
-
const tweetnacl_1 = __importDefault(require("tweetnacl"));
|
|
8
4
|
const bytes_1 = require("../core/bytes");
|
|
5
|
+
const nacl_identity_provider_1 = require("./nacl-identity-provider");
|
|
9
6
|
const payload_format_1 = require("./payload-format");
|
|
10
|
-
const STORAGE_PRIVATE = 'droponair.identity.privateKey.v1';
|
|
11
|
-
const STORAGE_PUBLIC = 'droponair.identity.publicKey.v1';
|
|
12
7
|
function asBufferSource(data) {
|
|
13
8
|
return new Uint8Array(data);
|
|
14
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* HKDF inputs, unchanged from the original implementation and deliberately
|
|
12
|
+
* defined once. Both are part of the wire format: two participants must derive
|
|
13
|
+
* the same key from the same shared secret, so these strings and the user-id
|
|
14
|
+
* ordering cannot vary by provider, platform or SDK version without breaking
|
|
15
|
+
* every conversation that spans the change.
|
|
16
|
+
*/
|
|
17
|
+
function hkdfSalt() {
|
|
18
|
+
return (0, bytes_1.utf8Encode)('droponair-e2ee-hkdf-salt-v1');
|
|
19
|
+
}
|
|
20
|
+
function hkdfInfo(localUserId, peerUserId) {
|
|
21
|
+
const ordering = [localUserId, peerUserId].sort().join(':');
|
|
22
|
+
return (0, bytes_1.utf8Encode)(`droponair-e2ee-v1:${ordering}`);
|
|
23
|
+
}
|
|
24
|
+
function isIdentityProvider(value) {
|
|
25
|
+
return typeof value.deriveMessageKey === 'function';
|
|
26
|
+
}
|
|
15
27
|
class CryptoService {
|
|
16
|
-
|
|
17
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Accepts either an identity provider or, for compatibility, the storage
|
|
30
|
+
* adapter it used to take. Passing storage keeps the previous behaviour
|
|
31
|
+
* exactly, by wrapping it in the tweetnacl provider.
|
|
32
|
+
*/
|
|
33
|
+
constructor(storageOrIdentity, sessionManager) {
|
|
18
34
|
this.sessionManager = sessionManager;
|
|
35
|
+
this.identity = isIdentityProvider(storageOrIdentity)
|
|
36
|
+
? storageOrIdentity
|
|
37
|
+
: new nacl_identity_provider_1.NaclIdentityProvider(storageOrIdentity);
|
|
38
|
+
}
|
|
39
|
+
/** How the identity private key is held. See {@link KeyCustody}. */
|
|
40
|
+
get keyCustody() {
|
|
41
|
+
return this.identity.keyCustody;
|
|
19
42
|
}
|
|
20
43
|
async generateIdentity() {
|
|
21
|
-
|
|
22
|
-
await this.storage.set(STORAGE_PRIVATE, (0, bytes_1.toBase64)(keyPair.secretKey));
|
|
23
|
-
await this.storage.set(STORAGE_PUBLIC, (0, bytes_1.toBase64)(keyPair.publicKey));
|
|
24
|
-
return { publicKey: (0, bytes_1.toBase64)(keyPair.publicKey) };
|
|
44
|
+
return { publicKey: await this.identity.getPublicKey() };
|
|
25
45
|
}
|
|
26
46
|
async getOrCreateIdentity() {
|
|
27
|
-
|
|
28
|
-
const existingPrivate = await this.storage.get(STORAGE_PRIVATE);
|
|
29
|
-
if (existingPublic && existingPrivate) {
|
|
30
|
-
return { publicKey: existingPublic };
|
|
31
|
-
}
|
|
32
|
-
return this.generateIdentity();
|
|
47
|
+
return { publicKey: await this.identity.getPublicKey() };
|
|
33
48
|
}
|
|
34
49
|
async deriveSharedSecret(peerUserId, peerPublicKeyBase64, localUserId) {
|
|
35
50
|
// Cache by public key (not userId) to support multi-device, same user may have
|
|
@@ -39,17 +54,14 @@ class CryptoService {
|
|
|
39
54
|
if (cached) {
|
|
40
55
|
return cached;
|
|
41
56
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
const sharedSecret = tweetnacl_1.default.scalarMult(privateKey, peerPublicKey);
|
|
52
|
-
const symmetricKey = await this.deriveAesKey(sharedSecret, localUserId, peerUserId);
|
|
57
|
+
// salt and info are part of the wire format, so they are computed here and
|
|
58
|
+
// handed to the provider rather than left to it. A provider that invented
|
|
59
|
+
// its own would derive keys nobody else could reproduce.
|
|
60
|
+
const symmetricKey = await this.identity.deriveMessageKey({
|
|
61
|
+
peerPublicKey: peerPublicKeyBase64,
|
|
62
|
+
salt: hkdfSalt(),
|
|
63
|
+
info: hkdfInfo(localUserId, peerUserId)
|
|
64
|
+
});
|
|
53
65
|
this.sessionManager.set(cacheKey, symmetricKey);
|
|
54
66
|
return symmetricKey;
|
|
55
67
|
}
|
|
@@ -78,21 +90,6 @@ class CryptoService {
|
|
|
78
90
|
}, symmetricKey, asBufferSource(unpacked.ciphertext));
|
|
79
91
|
return (0, bytes_1.utf8Decode)(new Uint8Array(plainBuffer));
|
|
80
92
|
}
|
|
81
|
-
async deriveAesKey(sharedSecret, localUserId, peerUserId) {
|
|
82
|
-
const ordering = [localUserId, peerUserId].sort().join(':');
|
|
83
|
-
const info = (0, bytes_1.utf8Encode)(`droponair-e2ee-v1:${ordering}`);
|
|
84
|
-
const salt = (0, bytes_1.utf8Encode)('droponair-e2ee-hkdf-salt-v1');
|
|
85
|
-
const ikm = await crypto.subtle.importKey('raw', asBufferSource(sharedSecret), 'HKDF', false, ['deriveKey']);
|
|
86
|
-
return crypto.subtle.deriveKey({
|
|
87
|
-
name: 'HKDF',
|
|
88
|
-
hash: 'SHA-256',
|
|
89
|
-
salt: asBufferSource(salt),
|
|
90
|
-
info: asBufferSource(info)
|
|
91
|
-
}, ikm, {
|
|
92
|
-
name: 'AES-GCM',
|
|
93
|
-
length: 256
|
|
94
|
-
}, false, ['encrypt', 'decrypt']);
|
|
95
|
-
}
|
|
96
93
|
buildAad(context) {
|
|
97
94
|
return (0, bytes_1.utf8Encode)(`${context.messageId}|${context.senderId}|${context.recipientId}|${context.timestamp}`);
|
|
98
95
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { KeyCustody, KeyStorageAdapter, SecureIdentityProvider } from '../core/types';
|
|
2
|
+
/**
|
|
3
|
+
* The original identity path, unchanged in behaviour and kept as the default.
|
|
4
|
+
*
|
|
5
|
+
* tweetnacl needs the private key as bytes, so this reports `software` custody:
|
|
6
|
+
* whatever the configured {@link KeyStorageAdapter} does, the key is reachable
|
|
7
|
+
* from script at derive time. That is not a flaw in this class, it is what using
|
|
8
|
+
* tweetnacl means, and saying so plainly is the point of `keyCustody`.
|
|
9
|
+
*
|
|
10
|
+
* It stays the default because every existing consumer already has keys in this
|
|
11
|
+
* format, and silently moving them would change which messages they can read.
|
|
12
|
+
* Opting into {@link WebCryptoIdentityProvider} is a decision an application
|
|
13
|
+
* makes, not one the SDK makes for it.
|
|
14
|
+
*/
|
|
15
|
+
export declare class NaclIdentityProvider implements SecureIdentityProvider {
|
|
16
|
+
private readonly storage;
|
|
17
|
+
readonly keyCustody: KeyCustody;
|
|
18
|
+
constructor(storage: KeyStorageAdapter);
|
|
19
|
+
getPublicKey(): Promise<string>;
|
|
20
|
+
deriveMessageKey(params: {
|
|
21
|
+
peerPublicKey: string;
|
|
22
|
+
salt: Uint8Array;
|
|
23
|
+
info: Uint8Array;
|
|
24
|
+
}): Promise<CryptoKey>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.NaclIdentityProvider = void 0;
|
|
7
|
+
const tweetnacl_1 = __importDefault(require("tweetnacl"));
|
|
8
|
+
const bytes_1 = require("../core/bytes");
|
|
9
|
+
const STORAGE_PRIVATE = 'droponair.identity.privateKey.v1';
|
|
10
|
+
const STORAGE_PUBLIC = 'droponair.identity.publicKey.v1';
|
|
11
|
+
/**
|
|
12
|
+
* The original identity path, unchanged in behaviour and kept as the default.
|
|
13
|
+
*
|
|
14
|
+
* tweetnacl needs the private key as bytes, so this reports `software` custody:
|
|
15
|
+
* whatever the configured {@link KeyStorageAdapter} does, the key is reachable
|
|
16
|
+
* from script at derive time. That is not a flaw in this class, it is what using
|
|
17
|
+
* tweetnacl means, and saying so plainly is the point of `keyCustody`.
|
|
18
|
+
*
|
|
19
|
+
* It stays the default because every existing consumer already has keys in this
|
|
20
|
+
* format, and silently moving them would change which messages they can read.
|
|
21
|
+
* Opting into {@link WebCryptoIdentityProvider} is a decision an application
|
|
22
|
+
* makes, not one the SDK makes for it.
|
|
23
|
+
*/
|
|
24
|
+
class NaclIdentityProvider {
|
|
25
|
+
constructor(storage) {
|
|
26
|
+
this.storage = storage;
|
|
27
|
+
this.keyCustody = 'software';
|
|
28
|
+
}
|
|
29
|
+
async getPublicKey() {
|
|
30
|
+
const existingPublic = await this.storage.get(STORAGE_PUBLIC);
|
|
31
|
+
const existingPrivate = await this.storage.get(STORAGE_PRIVATE);
|
|
32
|
+
if (existingPublic && existingPrivate) {
|
|
33
|
+
return existingPublic;
|
|
34
|
+
}
|
|
35
|
+
const keyPair = tweetnacl_1.default.box.keyPair();
|
|
36
|
+
await this.storage.set(STORAGE_PRIVATE, (0, bytes_1.toBase64)(keyPair.secretKey));
|
|
37
|
+
await this.storage.set(STORAGE_PUBLIC, (0, bytes_1.toBase64)(keyPair.publicKey));
|
|
38
|
+
return (0, bytes_1.toBase64)(keyPair.publicKey);
|
|
39
|
+
}
|
|
40
|
+
async deriveMessageKey(params) {
|
|
41
|
+
const privateBase64 = await this.storage.get(STORAGE_PRIVATE);
|
|
42
|
+
if (!privateBase64) {
|
|
43
|
+
throw new Error('Local identity keypair is missing');
|
|
44
|
+
}
|
|
45
|
+
const privateKey = (0, bytes_1.fromBase64)(privateBase64);
|
|
46
|
+
const peerPublicKey = (0, bytes_1.fromBase64)(params.peerPublicKey);
|
|
47
|
+
if (peerPublicKey.length !== 32 || privateKey.length !== 32) {
|
|
48
|
+
throw new Error('Invalid X25519 key length');
|
|
49
|
+
}
|
|
50
|
+
const sharedSecret = tweetnacl_1.default.scalarMult(privateKey, peerPublicKey);
|
|
51
|
+
const ikm = await crypto.subtle.importKey('raw', new Uint8Array(sharedSecret), 'HKDF', false, ['deriveKey']);
|
|
52
|
+
return crypto.subtle.deriveKey({
|
|
53
|
+
name: 'HKDF',
|
|
54
|
+
hash: 'SHA-256',
|
|
55
|
+
salt: params.salt,
|
|
56
|
+
info: params.info
|
|
57
|
+
}, ikm, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
exports.NaclIdentityProvider = NaclIdentityProvider;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { KeyCustody, KeyStorageAdapter, SecureIdentityProvider } from '../core/types';
|
|
2
|
+
interface StoredIdentity {
|
|
3
|
+
privateKey: CryptoKey;
|
|
4
|
+
publicKeyBase64: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Identity provider backed by a non-extractable WebCrypto X25519 key.
|
|
8
|
+
*
|
|
9
|
+
* The private key is generated with `extractable: false` and stored in IndexedDB
|
|
10
|
+
* as a CryptoKey rather than as bytes. CryptoKey is structured-cloneable, so it
|
|
11
|
+
* survives the round trip, and `crypto.subtle.exportKey` refuses on the way out.
|
|
12
|
+
* Script on the origin can therefore ask this key to derive, but cannot copy it.
|
|
13
|
+
*
|
|
14
|
+
* This is the same curve tweetnacl's `box` uses, and `deriveBits` computes the
|
|
15
|
+
* same X25519 shared secret, so a message encrypted through this provider is
|
|
16
|
+
* indistinguishable on the wire from one encrypted through the legacy path. That
|
|
17
|
+
* property is what makes adoption safe, and it is asserted by tests rather than
|
|
18
|
+
* assumed.
|
|
19
|
+
*
|
|
20
|
+
* Availability is not universal. Call {@link isSupported} before choosing this;
|
|
21
|
+
* the SDK does, and falls back rather than throwing at send time.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Where the identity record is kept between sessions.
|
|
25
|
+
*
|
|
26
|
+
* Extracted so persistence is not welded to IndexedDB. It lets the equivalence
|
|
27
|
+
* tests run somewhere without IndexedDB, which is the only way to verify wire
|
|
28
|
+
* compatibility outside a browser, and it lets a consumer persist the record
|
|
29
|
+
* somewhere of their own choosing.
|
|
30
|
+
*
|
|
31
|
+
* Whatever implements it must preserve the CryptoKey as an object. Serialising
|
|
32
|
+
* the record to JSON destroys the key silently and leaves a record that looks
|
|
33
|
+
* present and is unusable.
|
|
34
|
+
*/
|
|
35
|
+
export interface IdentityRecordStore {
|
|
36
|
+
read(): Promise<StoredIdentity | null>;
|
|
37
|
+
write(record: StoredIdentity): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
/** Keeps the identity for the lifetime of the process only. Used by tests. */
|
|
40
|
+
export declare class MemoryIdentityRecordStore implements IdentityRecordStore {
|
|
41
|
+
private record;
|
|
42
|
+
read(): Promise<StoredIdentity | null>;
|
|
43
|
+
write(record: StoredIdentity): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Wrap a raw 32 byte X25519 scalar so WebCrypto will import it.
|
|
47
|
+
*
|
|
48
|
+
* This is what makes migration possible at all: an existing tweetnacl identity is
|
|
49
|
+
* 32 raw bytes, and without this there is no way to hand it to WebCrypto, which
|
|
50
|
+
* would mean every existing user had to be re-keyed and would lose the ability to
|
|
51
|
+
* read their own history.
|
|
52
|
+
*/
|
|
53
|
+
export declare function rawX25519ToPkcs8(raw: Uint8Array): Uint8Array;
|
|
54
|
+
export declare class WebCryptoIdentityProvider implements SecureIdentityProvider {
|
|
55
|
+
readonly keyCustody: KeyCustody;
|
|
56
|
+
private cached;
|
|
57
|
+
private readonly store;
|
|
58
|
+
constructor(store?: IdentityRecordStore);
|
|
59
|
+
/**
|
|
60
|
+
* Whether this environment can actually do non-extractable X25519.
|
|
61
|
+
*
|
|
62
|
+
* Probes by generating a throwaway key rather than sniffing user agents,
|
|
63
|
+
* because support has arrived at different times in different engines and a
|
|
64
|
+
* feature test is the only answer that stays true.
|
|
65
|
+
*/
|
|
66
|
+
static isSupported(): Promise<boolean>;
|
|
67
|
+
/**
|
|
68
|
+
* Whether an X25519 key actually survives being stored and read back here.
|
|
69
|
+
*
|
|
70
|
+
* Separate from {@link isSupported} because the two are genuinely different
|
|
71
|
+
* questions and one engine answers them differently. **WebKit can generate and
|
|
72
|
+
* derive with a non-extractable X25519 key, and then loses it through
|
|
73
|
+
* structured clone**: the IndexedDB write reports success, and after a reload
|
|
74
|
+
* the record is simply absent. Measured 2026-08-15; AES-GCM, ECDH P-256 and
|
|
75
|
+
* ECDSA P-256 all survive there, X25519 alone does not.
|
|
76
|
+
*
|
|
77
|
+
* That failure is silent, which is what makes it dangerous. An identity that
|
|
78
|
+
* regenerates every load means every previous conversation becomes
|
|
79
|
+
* undecryptable, with no error raised anywhere to explain it, so this must be
|
|
80
|
+
* checked rather than assumed.
|
|
81
|
+
*
|
|
82
|
+
* Costs one write and one read of a throwaway key, so call it once at startup
|
|
83
|
+
* rather than per message.
|
|
84
|
+
*/
|
|
85
|
+
static canPersist(store: IdentityRecordStore): Promise<boolean>;
|
|
86
|
+
/**
|
|
87
|
+
* Adopt an existing tweetnacl identity, so a user keeps their conversations.
|
|
88
|
+
*
|
|
89
|
+
* The scalar is imported as a non-extractable key and the raw copy is then
|
|
90
|
+
* removed from the {@link KeyStorageAdapter}, which is the whole point: after
|
|
91
|
+
* this, the same identity exists but script can no longer read it.
|
|
92
|
+
*
|
|
93
|
+
* Ordering matters and is deliberate. The import and the write happen first,
|
|
94
|
+
* and only a successful write is followed by deleting the raw copy. Interrupted
|
|
95
|
+
* at any point, the user still has a usable identity somewhere: either the old
|
|
96
|
+
* raw key, or the new protected one, never neither.
|
|
97
|
+
*
|
|
98
|
+
* Returns false when there is nothing to migrate, so a caller can run it
|
|
99
|
+
* unconditionally on startup.
|
|
100
|
+
*/
|
|
101
|
+
migrateFromKeyStorage(storage: KeyStorageAdapter): Promise<boolean>;
|
|
102
|
+
getPublicKey(): Promise<string>;
|
|
103
|
+
deriveMessageKey(params: {
|
|
104
|
+
peerPublicKey: string;
|
|
105
|
+
salt: Uint8Array;
|
|
106
|
+
info: Uint8Array;
|
|
107
|
+
}): Promise<CryptoKey>;
|
|
108
|
+
private getOrCreate;
|
|
109
|
+
}
|
|
110
|
+
/** The default: an IndexedDB object store, which preserves CryptoKey objects. */
|
|
111
|
+
export declare class IndexedDbIdentityRecordStore implements IdentityRecordStore {
|
|
112
|
+
private openDb;
|
|
113
|
+
read(): Promise<StoredIdentity | null>;
|
|
114
|
+
write(record: StoredIdentity): Promise<void>;
|
|
115
|
+
}
|
|
116
|
+
export {};
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.IndexedDbIdentityRecordStore = exports.WebCryptoIdentityProvider = exports.MemoryIdentityRecordStore = void 0;
|
|
4
|
+
exports.rawX25519ToPkcs8 = rawX25519ToPkcs8;
|
|
5
|
+
const bytes_1 = require("../core/bytes");
|
|
6
|
+
/**
|
|
7
|
+
* A database of its own, not a second store inside `droponair_sdk`.
|
|
8
|
+
*
|
|
9
|
+
* IndexedDbKeyStorage already owns `droponair_sdk` at version 1 with a
|
|
10
|
+
* `secure_keys` store. Adding another store to the same name and version means
|
|
11
|
+
* whichever opens second finds no upgrade needed, its store is never created,
|
|
12
|
+
* and every read fails. Both are used together in a real application, so this
|
|
13
|
+
* was not hypothetical: it made the identity vanish across a reload under WebKit
|
|
14
|
+
* while appearing to work in Chromium, purely on open ordering.
|
|
15
|
+
*
|
|
16
|
+
* Coordinating versions between two independent components is the other way to
|
|
17
|
+
* solve it and a worse one, because it couples their release cycles forever.
|
|
18
|
+
*/
|
|
19
|
+
const DB_NAME = 'droponair_identity';
|
|
20
|
+
const STORE_NAME = 'identity_v2';
|
|
21
|
+
const DB_VERSION = 1;
|
|
22
|
+
const RECORD_KEY = 'x25519';
|
|
23
|
+
/** Where the tweetnacl path keeps its identity. Must match NaclIdentityProvider. */
|
|
24
|
+
const LEGACY_STORAGE_PRIVATE = 'droponair.identity.privateKey.v1';
|
|
25
|
+
const LEGACY_STORAGE_PUBLIC = 'droponair.identity.publicKey.v1';
|
|
26
|
+
/** Keeps the identity for the lifetime of the process only. Used by tests. */
|
|
27
|
+
class MemoryIdentityRecordStore {
|
|
28
|
+
constructor() {
|
|
29
|
+
this.record = null;
|
|
30
|
+
}
|
|
31
|
+
async read() {
|
|
32
|
+
return this.record;
|
|
33
|
+
}
|
|
34
|
+
async write(record) {
|
|
35
|
+
this.record = record;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
exports.MemoryIdentityRecordStore = MemoryIdentityRecordStore;
|
|
39
|
+
/**
|
|
40
|
+
* DER prefix for a PKCS#8 X25519 private key, per RFC 8410.
|
|
41
|
+
*
|
|
42
|
+
* WebCrypto will not import a bare 32 byte scalar, but it will import PKCS#8,
|
|
43
|
+
* and for X25519 the encoding is fixed: everything before the key is constant,
|
|
44
|
+
* so the wrapper is a concatenation rather than a DER encoder.
|
|
45
|
+
*
|
|
46
|
+
* 30 2e SEQUENCE, 46 bytes
|
|
47
|
+
* 02 01 00 INTEGER 0, the version
|
|
48
|
+
* 30 05 SEQUENCE, 5 bytes
|
|
49
|
+
* 06 03 2b 65 6e OID 1.3.101.110, X25519
|
|
50
|
+
* 04 22 OCTET STRING, 34 bytes
|
|
51
|
+
* 04 20 OCTET STRING, 32 bytes, the scalar follows
|
|
52
|
+
*/
|
|
53
|
+
const PKCS8_X25519_PREFIX = new Uint8Array([
|
|
54
|
+
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x04, 0x22, 0x04, 0x20
|
|
55
|
+
]);
|
|
56
|
+
/**
|
|
57
|
+
* Wrap a raw 32 byte X25519 scalar so WebCrypto will import it.
|
|
58
|
+
*
|
|
59
|
+
* This is what makes migration possible at all: an existing tweetnacl identity is
|
|
60
|
+
* 32 raw bytes, and without this there is no way to hand it to WebCrypto, which
|
|
61
|
+
* would mean every existing user had to be re-keyed and would lose the ability to
|
|
62
|
+
* read their own history.
|
|
63
|
+
*/
|
|
64
|
+
function rawX25519ToPkcs8(raw) {
|
|
65
|
+
if (raw.length !== 32) {
|
|
66
|
+
throw new Error('Invalid X25519 key length');
|
|
67
|
+
}
|
|
68
|
+
const out = new Uint8Array(PKCS8_X25519_PREFIX.length + 32);
|
|
69
|
+
out.set(PKCS8_X25519_PREFIX, 0);
|
|
70
|
+
out.set(raw, PKCS8_X25519_PREFIX.length);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
class WebCryptoIdentityProvider {
|
|
74
|
+
constructor(store) {
|
|
75
|
+
this.keyCustody = 'non-extractable';
|
|
76
|
+
this.cached = null;
|
|
77
|
+
this.store = store ?? new IndexedDbIdentityRecordStore();
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether this environment can actually do non-extractable X25519.
|
|
81
|
+
*
|
|
82
|
+
* Probes by generating a throwaway key rather than sniffing user agents,
|
|
83
|
+
* because support has arrived at different times in different engines and a
|
|
84
|
+
* feature test is the only answer that stays true.
|
|
85
|
+
*/
|
|
86
|
+
static async isSupported() {
|
|
87
|
+
try {
|
|
88
|
+
// Deliberately does not require IndexedDB. Persistence is pluggable, and
|
|
89
|
+
// conflating the two meant this reported "unsupported" in any environment
|
|
90
|
+
// without IndexedDB even where X25519 worked perfectly, which is exactly
|
|
91
|
+
// what hid the equivalence test from ever running.
|
|
92
|
+
if (typeof crypto === 'undefined' || !crypto.subtle)
|
|
93
|
+
return false;
|
|
94
|
+
const pair = (await crypto.subtle.generateKey({ name: 'X25519' }, false, [
|
|
95
|
+
'deriveBits'
|
|
96
|
+
]));
|
|
97
|
+
await crypto.subtle.deriveBits({ name: 'X25519', public: pair.publicKey }, pair.privateKey, 256);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Whether an X25519 key actually survives being stored and read back here.
|
|
106
|
+
*
|
|
107
|
+
* Separate from {@link isSupported} because the two are genuinely different
|
|
108
|
+
* questions and one engine answers them differently. **WebKit can generate and
|
|
109
|
+
* derive with a non-extractable X25519 key, and then loses it through
|
|
110
|
+
* structured clone**: the IndexedDB write reports success, and after a reload
|
|
111
|
+
* the record is simply absent. Measured 2026-08-15; AES-GCM, ECDH P-256 and
|
|
112
|
+
* ECDSA P-256 all survive there, X25519 alone does not.
|
|
113
|
+
*
|
|
114
|
+
* That failure is silent, which is what makes it dangerous. An identity that
|
|
115
|
+
* regenerates every load means every previous conversation becomes
|
|
116
|
+
* undecryptable, with no error raised anywhere to explain it, so this must be
|
|
117
|
+
* checked rather than assumed.
|
|
118
|
+
*
|
|
119
|
+
* Costs one write and one read of a throwaway key, so call it once at startup
|
|
120
|
+
* rather than per message.
|
|
121
|
+
*/
|
|
122
|
+
static async canPersist(store) {
|
|
123
|
+
try {
|
|
124
|
+
const pair = (await crypto.subtle.generateKey({ name: 'X25519' }, false, [
|
|
125
|
+
'deriveBits'
|
|
126
|
+
]));
|
|
127
|
+
const rawPublic = await crypto.subtle.exportKey('raw', pair.publicKey);
|
|
128
|
+
const probe = {
|
|
129
|
+
privateKey: pair.privateKey,
|
|
130
|
+
publicKeyBase64: (0, bytes_1.toBase64)(new Uint8Array(rawPublic))
|
|
131
|
+
};
|
|
132
|
+
await store.write(probe);
|
|
133
|
+
const back = await store.read();
|
|
134
|
+
// The write above will have overwritten any real identity, so a caller must
|
|
135
|
+
// only use a store dedicated to probing. The SDK does exactly that.
|
|
136
|
+
return !!back?.privateKey;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Adopt an existing tweetnacl identity, so a user keeps their conversations.
|
|
144
|
+
*
|
|
145
|
+
* The scalar is imported as a non-extractable key and the raw copy is then
|
|
146
|
+
* removed from the {@link KeyStorageAdapter}, which is the whole point: after
|
|
147
|
+
* this, the same identity exists but script can no longer read it.
|
|
148
|
+
*
|
|
149
|
+
* Ordering matters and is deliberate. The import and the write happen first,
|
|
150
|
+
* and only a successful write is followed by deleting the raw copy. Interrupted
|
|
151
|
+
* at any point, the user still has a usable identity somewhere: either the old
|
|
152
|
+
* raw key, or the new protected one, never neither.
|
|
153
|
+
*
|
|
154
|
+
* Returns false when there is nothing to migrate, so a caller can run it
|
|
155
|
+
* unconditionally on startup.
|
|
156
|
+
*/
|
|
157
|
+
async migrateFromKeyStorage(storage) {
|
|
158
|
+
const privateBase64 = await storage.get(LEGACY_STORAGE_PRIVATE);
|
|
159
|
+
const publicBase64 = await storage.get(LEGACY_STORAGE_PUBLIC);
|
|
160
|
+
if (!privateBase64 || !publicBase64) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
const raw = (0, bytes_1.fromBase64)(privateBase64);
|
|
164
|
+
const privateKey = await crypto.subtle.importKey('pkcs8', rawX25519ToPkcs8(raw), { name: 'X25519' },
|
|
165
|
+
// Non-extractable from this moment on. The bytes still exist in the
|
|
166
|
+
// adapter until the delete below, which is why that delete is not optional.
|
|
167
|
+
false, ['deriveBits']);
|
|
168
|
+
const record = { privateKey, publicKeyBase64: publicBase64 };
|
|
169
|
+
await this.store.write(record);
|
|
170
|
+
this.cached = record;
|
|
171
|
+
await storage.remove(LEGACY_STORAGE_PRIVATE);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
async getPublicKey() {
|
|
175
|
+
const identity = await this.getOrCreate();
|
|
176
|
+
return identity.publicKeyBase64;
|
|
177
|
+
}
|
|
178
|
+
async deriveMessageKey(params) {
|
|
179
|
+
const identity = await this.getOrCreate();
|
|
180
|
+
const peerRaw = (0, bytes_1.fromBase64)(params.peerPublicKey);
|
|
181
|
+
if (peerRaw.length !== 32) {
|
|
182
|
+
throw new Error('Invalid X25519 key length');
|
|
183
|
+
}
|
|
184
|
+
const peerKey = await crypto.subtle.importKey('raw', peerRaw, { name: 'X25519' }, false, []);
|
|
185
|
+
// deriveBits, not deriveKey: HKDF needs the shared secret as key material,
|
|
186
|
+
// and this is the only point where it exists. It is never returned to a
|
|
187
|
+
// caller and never stored.
|
|
188
|
+
const sharedSecret = await crypto.subtle.deriveBits({ name: 'X25519', public: peerKey }, identity.privateKey, 256);
|
|
189
|
+
const ikm = await crypto.subtle.importKey('raw', sharedSecret, 'HKDF', false, ['deriveKey']);
|
|
190
|
+
return crypto.subtle.deriveKey({
|
|
191
|
+
name: 'HKDF',
|
|
192
|
+
hash: 'SHA-256',
|
|
193
|
+
salt: params.salt,
|
|
194
|
+
info: params.info
|
|
195
|
+
}, ikm, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
196
|
+
}
|
|
197
|
+
async getOrCreate() {
|
|
198
|
+
if (this.cached)
|
|
199
|
+
return this.cached;
|
|
200
|
+
const existing = await this.store.read();
|
|
201
|
+
if (existing) {
|
|
202
|
+
this.cached = existing;
|
|
203
|
+
return existing;
|
|
204
|
+
}
|
|
205
|
+
const pair = (await crypto.subtle.generateKey({ name: 'X25519' }, false, [
|
|
206
|
+
'deriveBits'
|
|
207
|
+
]));
|
|
208
|
+
// The public half is exported once and kept as base64, because it is
|
|
209
|
+
// published to peers anyway. Only the private half is protected.
|
|
210
|
+
const rawPublic = await crypto.subtle.exportKey('raw', pair.publicKey);
|
|
211
|
+
const record = {
|
|
212
|
+
privateKey: pair.privateKey,
|
|
213
|
+
publicKeyBase64: (0, bytes_1.toBase64)(new Uint8Array(rawPublic))
|
|
214
|
+
};
|
|
215
|
+
await this.store.write(record);
|
|
216
|
+
this.cached = record;
|
|
217
|
+
return record;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
exports.WebCryptoIdentityProvider = WebCryptoIdentityProvider;
|
|
221
|
+
/** The default: an IndexedDB object store, which preserves CryptoKey objects. */
|
|
222
|
+
class IndexedDbIdentityRecordStore {
|
|
223
|
+
async openDb() {
|
|
224
|
+
return new Promise((resolve, reject) => {
|
|
225
|
+
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
226
|
+
request.onupgradeneeded = () => {
|
|
227
|
+
const db = request.result;
|
|
228
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
229
|
+
db.createObjectStore(STORE_NAME);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
request.onsuccess = () => resolve(request.result);
|
|
233
|
+
request.onerror = () => reject(request.error);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
async read() {
|
|
237
|
+
const db = await this.openDb();
|
|
238
|
+
return new Promise((resolve, reject) => {
|
|
239
|
+
const tx = db.transaction(STORE_NAME, 'readonly');
|
|
240
|
+
const request = tx.objectStore(STORE_NAME).get(RECORD_KEY);
|
|
241
|
+
request.onsuccess = () => {
|
|
242
|
+
const value = request.result;
|
|
243
|
+
// A record written by an older or broken build could be missing the
|
|
244
|
+
// CryptoKey. Treating that as absent regenerates rather than throwing on
|
|
245
|
+
// every send.
|
|
246
|
+
if (!value || !value.privateKey || !value.publicKeyBase64) {
|
|
247
|
+
resolve(null);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
resolve(value);
|
|
251
|
+
};
|
|
252
|
+
request.onerror = () => reject(request.error);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
async write(record) {
|
|
256
|
+
const db = await this.openDb();
|
|
257
|
+
await new Promise((resolve, reject) => {
|
|
258
|
+
const tx = db.transaction(STORE_NAME, 'readwrite');
|
|
259
|
+
tx.objectStore(STORE_NAME).put(record, RECORD_KEY);
|
|
260
|
+
tx.oncomplete = () => resolve();
|
|
261
|
+
tx.onerror = () => reject(tx.error);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
exports.IndexedDbIdentityRecordStore = IndexedDbIdentityRecordStore;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,40 @@
|
|
|
1
|
+
import { IdentityRecordStore, WebCryptoIdentityProvider } from './crypto/webcrypto-identity-provider';
|
|
1
2
|
import { InitializeOptions, DropOnAirClient } from './core/types';
|
|
2
3
|
export { SDK_VERSION, PROTOCOL_VERSION, PAYLOAD_FORMAT_VERSION } from './version';
|
|
4
|
+
/**
|
|
5
|
+
* An identity whose private key script cannot read, or `null` where the platform
|
|
6
|
+
* cannot provide one.
|
|
7
|
+
*
|
|
8
|
+
* Returns `null` rather than throwing, and rather than quietly handing back a
|
|
9
|
+
* weaker implementation, because those are the two ways a caller ends up
|
|
10
|
+
* believing it has a guarantee it does not have. A `null` here means: this engine
|
|
11
|
+
* cannot do it, carry on with the ordinary path and know that you did.
|
|
12
|
+
*
|
|
13
|
+
* Two separate conditions are checked, and both matter. `isSupported` asks
|
|
14
|
+
* whether the primitive exists. `canPersist` asks whether a key survives being
|
|
15
|
+
* stored and read back, which WebKit fails for X25519 while reporting the write
|
|
16
|
+
* as successful. An identity that silently regenerates every load makes every
|
|
17
|
+
* previous conversation undecryptable, with nothing raised to explain it, so the
|
|
18
|
+
* check is worth its round trip.
|
|
19
|
+
*
|
|
20
|
+
* Migration is the caller's to trigger, via `migrateFromKeyStorage`, because it
|
|
21
|
+
* removes the raw key and that is not a decision to take on someone's behalf.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createSecureIdentity(store?: IdentityRecordStore): Promise<WebCryptoIdentityProvider | null>;
|
|
3
24
|
export declare function initialize(options: InitializeOptions): Promise<DropOnAirClient>;
|
|
4
25
|
export type { InitializeOptions, DropOnAirClient, DropOnAirEvent, MessageCallback, EventCallback, DecryptedMessage, KeyStorageAdapter, CallEvent, CallEventType, CallEventCallback, TurnCredentials, BroadcastMessage, BroadcastCallback, GroupInfo, GroupMemberInfo, DecryptedGroupMessage, GroupMessageCallback, GroupCallEvent, GroupCallEventType, GroupCallEventCallback, Room, RoomPolicy, CreateRoomOptions, UpdateRoomOptions, SfuToken, SfuRecording, MessageEditEvent, MessageEditCallback, MessageDeleteEvent, MessageDeleteCallback, DeviceInfo, ReadReceiptEvent, ReadReceiptCallback, NotificationClearEvent, NotificationClearCallback, DraftSyncEvent, DraftSyncCallback, } from './core/types';
|
|
5
26
|
export type { AttachmentEncryptionType, AttachmentConversationType, AttachmentRef, DeviceWrappedKey, CreateUploadSessionOptions, UploadSession, PrepareAttachmentOptions, DownloadedAttachment, } from './attachment/attachment-types';
|
|
6
27
|
export { SseTransport, type SseTransportOptions, type SseFrameHandler, type SseStateHandler, } from './transport/sse-transport';
|
|
7
28
|
export { WebTransportTransport, type WebTransportTransportOptions, type WTFrameHandler, type WTStateHandler, } from './transport/webtransport-transport';
|
|
8
29
|
export { selectTransport, type TransportLane, type SelectTransportOptions, } from './transport/auto-select';
|
|
30
|
+
/**
|
|
31
|
+
* Identity providers, for a private key that script cannot read.
|
|
32
|
+
*
|
|
33
|
+
* Exported so a consumer can construct one deliberately, supply its own record
|
|
34
|
+
* store, or implement {@link SecureIdentityProvider} against custody of its own.
|
|
35
|
+
* The platform offers the capability; it does not impose an implementation.
|
|
36
|
+
*/
|
|
37
|
+
export { WebCryptoIdentityProvider, IndexedDbIdentityRecordStore, MemoryIdentityRecordStore, type IdentityRecordStore, } from './crypto/webcrypto-identity-provider';
|
|
9
38
|
declare const _default: {
|
|
10
39
|
initialize: typeof initialize;
|
|
11
40
|
};
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.selectTransport = exports.WebTransportTransport = exports.SseTransport = exports.PAYLOAD_FORMAT_VERSION = exports.PROTOCOL_VERSION = exports.SDK_VERSION = void 0;
|
|
3
|
+
exports.MemoryIdentityRecordStore = exports.IndexedDbIdentityRecordStore = exports.WebCryptoIdentityProvider = exports.selectTransport = exports.WebTransportTransport = exports.SseTransport = exports.PAYLOAD_FORMAT_VERSION = exports.PROTOCOL_VERSION = exports.SDK_VERSION = void 0;
|
|
4
|
+
exports.createSecureIdentity = createSecureIdentity;
|
|
4
5
|
exports.initialize = initialize;
|
|
5
6
|
const messaging_client_1 = require("./core/messaging-client");
|
|
6
7
|
const session_manager_1 = require("./core/session-manager");
|
|
7
8
|
const crypto_service_1 = require("./crypto/crypto-service");
|
|
8
9
|
const indexeddb_key_storage_1 = require("./storage/indexeddb-key-storage");
|
|
10
|
+
const webcrypto_identity_provider_1 = require("./crypto/webcrypto-identity-provider");
|
|
9
11
|
var version_1 = require("./version");
|
|
10
12
|
Object.defineProperty(exports, "SDK_VERSION", { enumerable: true, get: function () { return version_1.SDK_VERSION; } });
|
|
11
13
|
Object.defineProperty(exports, "PROTOCOL_VERSION", { enumerable: true, get: function () { return version_1.PROTOCOL_VERSION; } });
|
|
@@ -16,10 +18,41 @@ function resolveStorage(adapter) {
|
|
|
16
18
|
}
|
|
17
19
|
return new indexeddb_key_storage_1.IndexedDbKeyStorage();
|
|
18
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* An identity whose private key script cannot read, or `null` where the platform
|
|
23
|
+
* cannot provide one.
|
|
24
|
+
*
|
|
25
|
+
* Returns `null` rather than throwing, and rather than quietly handing back a
|
|
26
|
+
* weaker implementation, because those are the two ways a caller ends up
|
|
27
|
+
* believing it has a guarantee it does not have. A `null` here means: this engine
|
|
28
|
+
* cannot do it, carry on with the ordinary path and know that you did.
|
|
29
|
+
*
|
|
30
|
+
* Two separate conditions are checked, and both matter. `isSupported` asks
|
|
31
|
+
* whether the primitive exists. `canPersist` asks whether a key survives being
|
|
32
|
+
* stored and read back, which WebKit fails for X25519 while reporting the write
|
|
33
|
+
* as successful. An identity that silently regenerates every load makes every
|
|
34
|
+
* previous conversation undecryptable, with nothing raised to explain it, so the
|
|
35
|
+
* check is worth its round trip.
|
|
36
|
+
*
|
|
37
|
+
* Migration is the caller's to trigger, via `migrateFromKeyStorage`, because it
|
|
38
|
+
* removes the raw key and that is not a decision to take on someone's behalf.
|
|
39
|
+
*/
|
|
40
|
+
async function createSecureIdentity(store) {
|
|
41
|
+
if (!(await webcrypto_identity_provider_1.WebCryptoIdentityProvider.isSupported())) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const recordStore = store ?? new webcrypto_identity_provider_1.IndexedDbIdentityRecordStore();
|
|
45
|
+
if (!(await webcrypto_identity_provider_1.WebCryptoIdentityProvider.canPersist(recordStore))) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return new webcrypto_identity_provider_1.WebCryptoIdentityProvider(recordStore);
|
|
49
|
+
}
|
|
19
50
|
async function initialize(options) {
|
|
20
51
|
const storage = resolveStorage(options.storage);
|
|
21
52
|
const sessionManager = new session_manager_1.SessionManager();
|
|
22
|
-
|
|
53
|
+
// An explicitly supplied identity provider owns the key agreement; otherwise
|
|
54
|
+
// the storage adapter does, exactly as in every previous release.
|
|
55
|
+
const cryptoService = new crypto_service_1.CryptoService(options.identity ?? storage, sessionManager);
|
|
23
56
|
const client = new messaging_client_1.MessagingClient(options, cryptoService, sessionManager, storage);
|
|
24
57
|
if (options.autoConnect !== false) {
|
|
25
58
|
await client.connect();
|
|
@@ -35,4 +68,15 @@ Object.defineProperty(exports, "WebTransportTransport", { enumerable: true, get:
|
|
|
35
68
|
// Phase 4.6: transport auto-select - intersects runtime + platform support.
|
|
36
69
|
var auto_select_1 = require("./transport/auto-select");
|
|
37
70
|
Object.defineProperty(exports, "selectTransport", { enumerable: true, get: function () { return auto_select_1.selectTransport; } });
|
|
71
|
+
/**
|
|
72
|
+
* Identity providers, for a private key that script cannot read.
|
|
73
|
+
*
|
|
74
|
+
* Exported so a consumer can construct one deliberately, supply its own record
|
|
75
|
+
* store, or implement {@link SecureIdentityProvider} against custody of its own.
|
|
76
|
+
* The platform offers the capability; it does not impose an implementation.
|
|
77
|
+
*/
|
|
78
|
+
var webcrypto_identity_provider_2 = require("./crypto/webcrypto-identity-provider");
|
|
79
|
+
Object.defineProperty(exports, "WebCryptoIdentityProvider", { enumerable: true, get: function () { return webcrypto_identity_provider_2.WebCryptoIdentityProvider; } });
|
|
80
|
+
Object.defineProperty(exports, "IndexedDbIdentityRecordStore", { enumerable: true, get: function () { return webcrypto_identity_provider_2.IndexedDbIdentityRecordStore; } });
|
|
81
|
+
Object.defineProperty(exports, "MemoryIdentityRecordStore", { enumerable: true, get: function () { return webcrypto_identity_provider_2.MemoryIdentityRecordStore; } });
|
|
38
82
|
exports.default = { initialize };
|
|
@@ -387,7 +387,9 @@ class ProtobufCodec {
|
|
|
387
387
|
tryDecodeAck(payload) {
|
|
388
388
|
try {
|
|
389
389
|
const decoded = AckType.decode(payload);
|
|
390
|
-
|
|
390
|
+
// type must be a real ack type (SCREAMING_SNAKE); otherwise a broadcast /
|
|
391
|
+
// notification with a UUID or userId in field 2 would false-match as an Ack.
|
|
392
|
+
if (!decoded.messageId || !decoded.type || !/^[A-Z][A-Z0-9_]*$/.test(decoded.type)) {
|
|
391
393
|
return null;
|
|
392
394
|
}
|
|
393
395
|
return decoded;
|
|
@@ -475,10 +477,11 @@ class ProtobufCodec {
|
|
|
475
477
|
tryDecodeGroupAck(payload) {
|
|
476
478
|
try {
|
|
477
479
|
const decoded = GroupAckType.decode(payload);
|
|
478
|
-
// Require type (field 3)
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
|
|
480
|
+
// Require type (field 3) to be a real ack type (SCREAMING_SNAKE). Without the
|
|
481
|
+
// shape check a BroadcastNotification {broadcastId(1), channelId(2),
|
|
482
|
+
// publisherId(3)} satisfies messageId+groupId+type (publisherId lands in type)
|
|
483
|
+
// and is misrouted here instead of to the broadcast handler.
|
|
484
|
+
if (!decoded.messageId || !decoded.groupId || !decoded.type || !/^[A-Z][A-Z0-9_]*$/.test(decoded.type)) {
|
|
482
485
|
return null;
|
|
483
486
|
}
|
|
484
487
|
return decoded;
|
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.
|
|
10
|
+
export declare const SDK_VERSION = "0.25.0";
|
|
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.
|
|
13
|
+
exports.SDK_VERSION = '0.25.0';
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@droponair/sdk-js",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.25.0",
|
|
4
|
+
"description": "End-to-end encrypted messaging, voice and video calling SDK. The relay never sees your keys or message content.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"files": [
|
|
@@ -18,19 +18,30 @@
|
|
|
18
18
|
"prepublishOnly": "npm run build"
|
|
19
19
|
},
|
|
20
20
|
"keywords": [
|
|
21
|
-
"droponair",
|
|
22
|
-
"messaging",
|
|
23
21
|
"e2ee",
|
|
24
|
-
"encryption",
|
|
25
|
-
"
|
|
26
|
-
"
|
|
22
|
+
"end-to-end-encryption",
|
|
23
|
+
"encrypted-chat",
|
|
24
|
+
"chat-api",
|
|
25
|
+
"chat-sdk",
|
|
26
|
+
"messaging",
|
|
27
|
+
"messaging-sdk",
|
|
28
|
+
"realtime",
|
|
29
|
+
"websocket",
|
|
30
|
+
"webrtc",
|
|
31
|
+
"voice-calls",
|
|
32
|
+
"video-calls",
|
|
33
|
+
"video-calling",
|
|
34
|
+
"group-chat",
|
|
35
|
+
"push-notifications",
|
|
36
|
+
"sdk",
|
|
37
|
+
"droponair"
|
|
27
38
|
],
|
|
28
39
|
"author": "DropOnAir",
|
|
29
40
|
"license": "MIT",
|
|
30
41
|
"publishConfig": {
|
|
31
42
|
"access": "public"
|
|
32
43
|
},
|
|
33
|
-
"homepage": "https://droponair.com",
|
|
44
|
+
"homepage": "https://www.droponair.com/",
|
|
34
45
|
"engines": {
|
|
35
46
|
"node": ">=18.0.0"
|
|
36
47
|
},
|
|
@@ -40,10 +51,18 @@
|
|
|
40
51
|
"tweetnacl-util": "^0.15.1"
|
|
41
52
|
},
|
|
42
53
|
"devDependencies": {
|
|
54
|
+
"@playwright/test": "^1.62.1",
|
|
55
|
+
"@types/jest": "^30.0.0",
|
|
43
56
|
"@types/node": "^20.0.0",
|
|
57
|
+
"esbuild": "^0.28.2",
|
|
44
58
|
"eslint": "^8.0.0",
|
|
45
59
|
"jest": "^29.0.0",
|
|
60
|
+
"ts-jest": "^29.4.12",
|
|
46
61
|
"ts-node": "^10.9.2",
|
|
47
62
|
"typescript": "^5.0.0"
|
|
63
|
+
},
|
|
64
|
+
"bugs": {
|
|
65
|
+
"url": "https://www.droponair.com/contact/",
|
|
66
|
+
"email": "info@droponair.com"
|
|
48
67
|
}
|
|
49
68
|
}
|