@droponair/sdk-js 0.24.3 → 0.25.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.
@@ -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
- constructor(storage, sessionManager) {
17
- this.storage = storage;
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
- const keyPair = tweetnacl_1.default.box.keyPair();
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
- const existingPublic = await this.storage.get(STORAGE_PUBLIC);
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
- const privateBase64 = await this.storage.get(STORAGE_PRIVATE);
43
- if (!privateBase64) {
44
- throw new Error('Local identity keypair is missing');
45
- }
46
- const privateKey = (0, bytes_1.fromBase64)(privateBase64);
47
- const peerPublicKey = (0, bytes_1.fromBase64)(peerPublicKeyBase64);
48
- if (peerPublicKey.length !== 32 || privateKey.length !== 32) {
49
- throw new Error('Invalid X25519 key length');
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
  };