@towns-labs/encryption 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/CryptoStoreInMemory.d.ts +42 -0
- package/dist/CryptoStoreInMemory.d.ts.map +1 -0
- package/dist/CryptoStoreInMemory.js +172 -0
- package/dist/CryptoStoreInMemory.js.map +1 -0
- package/dist/CryptoStoreIndexedDb.d.ts +55 -0
- package/dist/CryptoStoreIndexedDb.d.ts.map +1 -0
- package/dist/CryptoStoreIndexedDb.js +139 -0
- package/dist/CryptoStoreIndexedDb.js.map +1 -0
- package/dist/base.d.ts +69 -0
- package/dist/base.d.ts.map +1 -0
- package/dist/base.js +44 -0
- package/dist/base.js.map +1 -0
- package/dist/cryptoAesGcm.d.ts +9 -0
- package/dist/cryptoAesGcm.d.ts.map +1 -0
- package/dist/cryptoAesGcm.js +30 -0
- package/dist/cryptoAesGcm.js.map +1 -0
- package/dist/cryptoStore.d.ts +34 -0
- package/dist/cryptoStore.d.ts.map +1 -0
- package/dist/cryptoStore.js +17 -0
- package/dist/cryptoStore.js.map +1 -0
- package/dist/derivedEncryption.d.ts +2 -0
- package/dist/derivedEncryption.d.ts.map +1 -0
- package/dist/derivedEncryption.js +2 -0
- package/dist/derivedEncryption.js.map +1 -0
- package/dist/encryptionDelegate.d.ts +16 -0
- package/dist/encryptionDelegate.d.ts.map +1 -0
- package/dist/encryptionDelegate.js +64 -0
- package/dist/encryptionDelegate.js.map +1 -0
- package/dist/encryptionDevice.d.ts +264 -0
- package/dist/encryptionDevice.d.ts.map +1 -0
- package/dist/encryptionDevice.js +745 -0
- package/dist/encryptionDevice.js.map +1 -0
- package/dist/encryptionTypes.d.ts +21 -0
- package/dist/encryptionTypes.d.ts.map +1 -0
- package/dist/encryptionTypes.js +2 -0
- package/dist/encryptionTypes.js.map +1 -0
- package/dist/groupDecryption.d.ts +34 -0
- package/dist/groupDecryption.d.ts.map +1 -0
- package/dist/groupDecryption.js +84 -0
- package/dist/groupDecryption.js.map +1 -0
- package/dist/groupEncryption.d.ts +35 -0
- package/dist/groupEncryption.d.ts.map +1 -0
- package/dist/groupEncryption.js +99 -0
- package/dist/groupEncryption.js.map +1 -0
- package/dist/groupEncryptionCrypto.d.ts +125 -0
- package/dist/groupEncryptionCrypto.d.ts.map +1 -0
- package/dist/groupEncryptionCrypto.js +268 -0
- package/dist/groupEncryptionCrypto.js.map +1 -0
- package/dist/hybridGroupDecryption.d.ts +33 -0
- package/dist/hybridGroupDecryption.d.ts.map +1 -0
- package/dist/hybridGroupDecryption.js +84 -0
- package/dist/hybridGroupDecryption.js.map +1 -0
- package/dist/hybridGroupEncryption.d.ts +27 -0
- package/dist/hybridGroupEncryption.d.ts.map +1 -0
- package/dist/hybridGroupEncryption.js +101 -0
- package/dist/hybridGroupEncryption.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/olmLib.d.ts +35 -0
- package/dist/olmLib.d.ts.map +1 -0
- package/dist/olmLib.js +37 -0
- package/dist/olmLib.js.map +1 -0
- package/dist/storeTypes.d.ts +27 -0
- package/dist/storeTypes.d.ts.map +1 -0
- package/dist/storeTypes.js +2 -0
- package/dist/storeTypes.js.map +1 -0
- package/dist/tests/cryptoAesGcm.test.d.ts +2 -0
- package/dist/tests/cryptoAesGcm.test.d.ts.map +1 -0
- package/dist/tests/cryptoAesGcm.test.js +71 -0
- package/dist/tests/cryptoAesGcm.test.js.map +1 -0
- package/dist/tests/cryptoStore.test.d.ts +5 -0
- package/dist/tests/cryptoStore.test.d.ts.map +1 -0
- package/dist/tests/cryptoStore.test.js +114 -0
- package/dist/tests/cryptoStore.test.js.map +1 -0
- package/dist/tests/encryption-protocol.test.d.ts +2 -0
- package/dist/tests/encryption-protocol.test.d.ts.map +1 -0
- package/dist/tests/encryption-protocol.test.js +150 -0
- package/dist/tests/encryption-protocol.test.js.map +1 -0
- package/dist/tests/encryptionDelegate.test.d.ts +2 -0
- package/dist/tests/encryptionDelegate.test.d.ts.map +1 -0
- package/dist/tests/encryptionDelegate.test.js +78 -0
- package/dist/tests/encryptionDelegate.test.js.map +1 -0
- package/dist/tests/group-encryption-protocol.test.d.ts +2 -0
- package/dist/tests/group-encryption-protocol.test.d.ts.map +1 -0
- package/dist/tests/group-encryption-protocol.test.js +103 -0
- package/dist/tests/group-encryption-protocol.test.js.map +1 -0
- package/dist/tests/group-encryptionDelegate.test.d.ts +2 -0
- package/dist/tests/group-encryptionDelegate.test.d.ts.map +1 -0
- package/dist/tests/group-encryptionDelegate.test.js +23 -0
- package/dist/tests/group-encryptionDelegate.test.js.map +1 -0
- package/dist/tests/pk.test.d.ts +2 -0
- package/dist/tests/pk.test.d.ts.map +1 -0
- package/dist/tests/pk.test.js +103 -0
- package/dist/tests/pk.test.js.map +1 -0
- package/package.json +51 -0
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
import { EncryptionDelegate } from './encryptionDelegate';
|
|
2
|
+
import { GroupEncryptionAlgorithmId } from './olmLib';
|
|
3
|
+
import { bin_equal, bin_fromHexString, bin_toHexString, dlog } from '@towns-labs/utils';
|
|
4
|
+
import { ExportedDeviceSchema, ExportedDevice_GroupSessionSchema, ExportedDevice_HybridGroupSessionSchema, HybridGroupSessionKeySchema, } from '@towns-labs/proto';
|
|
5
|
+
import { exportAesGsmKeyBytes, generateNewAesGcmKey } from './cryptoAesGcm';
|
|
6
|
+
import { Dexie } from 'dexie';
|
|
7
|
+
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
|
8
|
+
const log = dlog('csb:encryption:encryptionDevice');
|
|
9
|
+
// The maximum size of an event is 65K, and we base64 the content, so this is a
|
|
10
|
+
// reasonable approximation to the biggest plaintext we can encrypt.
|
|
11
|
+
const MAX_PLAINTEXT_LENGTH = (65536 * 3) / 4;
|
|
12
|
+
function checkPayloadLength(payloadString, opts) {
|
|
13
|
+
if (payloadString === undefined) {
|
|
14
|
+
throw new Error('payloadString undefined');
|
|
15
|
+
}
|
|
16
|
+
if (payloadString.length > MAX_PLAINTEXT_LENGTH) {
|
|
17
|
+
// might as well fail early here rather than letting the olm library throw
|
|
18
|
+
// a cryptic memory allocation error.
|
|
19
|
+
throw new Error(`Message too long (${payloadString.length} bytes). ` +
|
|
20
|
+
`The maximum for an encrypted message is ${MAX_PLAINTEXT_LENGTH} bytes.` +
|
|
21
|
+
`streamId: ${opts.streamId}, source: ${opts.source}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export class EncryptionDevice {
|
|
25
|
+
delegate;
|
|
26
|
+
cryptoStore;
|
|
27
|
+
// https://linear.app/hnt-labs/issue/HNT-4273/pick-a-better-pickle-key-in-olmdevice
|
|
28
|
+
pickleKey = 'DEFAULT_KEY'; // set by consumers
|
|
29
|
+
/** Curve25519 key for the account, unknown until we load the account from storage in init() */
|
|
30
|
+
deviceCurve25519Key = null;
|
|
31
|
+
/** Ed25519 key for the account, unknown until we load the account from storage in init() */
|
|
32
|
+
deviceDoNotUseKey = null;
|
|
33
|
+
// keyId: base64(key)
|
|
34
|
+
fallbackKey = { keyId: '', key: '' };
|
|
35
|
+
// Keep track of sessions that we're starting, so that we don't start
|
|
36
|
+
// multiple sessions for the same device at the same time.
|
|
37
|
+
sessionsInProgress = {}; // set by consumers
|
|
38
|
+
// Used by olm to serialise prekey message decryptions
|
|
39
|
+
// todo: ensure we need this to serialize prekey message given we're using fallback keys
|
|
40
|
+
// not one time keys, which suffer a race condition and expire once used.
|
|
41
|
+
olmPrekeyPromise = Promise.resolve(); // set by consumers
|
|
42
|
+
// Store a set of decrypted message indexes for each group session.
|
|
43
|
+
// This partially mitigates a replay attack where a MITM resends a group
|
|
44
|
+
// message into the room.
|
|
45
|
+
//
|
|
46
|
+
// Keys are strings of form "<senderKey>|<session_id>|<message_index>"
|
|
47
|
+
// Values are objects of the form "{id: <event id>, timestamp: <ts>}"
|
|
48
|
+
inboundGroupSessionMessageIndexes = {};
|
|
49
|
+
constructor(delegate, cryptoStore) {
|
|
50
|
+
this.delegate = delegate;
|
|
51
|
+
this.cryptoStore = cryptoStore;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Iniitialize the Account. Must be called prior to any other operation
|
|
55
|
+
* on the device.
|
|
56
|
+
*
|
|
57
|
+
* Data from an exported device can be provided in order to recreate this device.
|
|
58
|
+
*
|
|
59
|
+
* Attempts to load the Account from the crypto store, or create one otherwise
|
|
60
|
+
* storing the account in storage.
|
|
61
|
+
*
|
|
62
|
+
* Reads the device keys from the Account object.
|
|
63
|
+
*
|
|
64
|
+
* @param fromExportedDevice - data from exported device
|
|
65
|
+
* that must be re-created.
|
|
66
|
+
* If present, opts.pickleKey is ignored
|
|
67
|
+
* (exported data already provides a pickle key)
|
|
68
|
+
* @param pickleKey - pickle key to set instead of default one
|
|
69
|
+
*
|
|
70
|
+
*
|
|
71
|
+
*/
|
|
72
|
+
async init(opts) {
|
|
73
|
+
const { fromExportedDevice, pickleKey } = opts ?? {};
|
|
74
|
+
let e2eKeys;
|
|
75
|
+
if (!this.delegate.isInitialized) {
|
|
76
|
+
this.delegate = new EncryptionDelegate();
|
|
77
|
+
await this.delegate.init();
|
|
78
|
+
}
|
|
79
|
+
const account = this.delegate.createAccount();
|
|
80
|
+
try {
|
|
81
|
+
if (fromExportedDevice) {
|
|
82
|
+
this.pickleKey = fromExportedDevice.pickleKey;
|
|
83
|
+
await this.initializeFromExportedDevice(fromExportedDevice, account);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
if (pickleKey) {
|
|
87
|
+
this.pickleKey = pickleKey;
|
|
88
|
+
}
|
|
89
|
+
await this.initializeAccount(account);
|
|
90
|
+
}
|
|
91
|
+
await this.generateFallbackKeyIfNeeded();
|
|
92
|
+
e2eKeys = JSON.parse(account.identity_keys());
|
|
93
|
+
this.fallbackKey = await this.getFallbackKey();
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
account.free();
|
|
97
|
+
}
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
99
|
+
this.deviceCurve25519Key = e2eKeys.curve25519;
|
|
100
|
+
// note jterzis 07/19/23: deprecating ed25519 key in favor of TDK
|
|
101
|
+
// see: https://linear.app/hnt-labs/issue/HNT-1796/tdk-signature-storage-curve25519-key
|
|
102
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
103
|
+
this.deviceDoNotUseKey = e2eKeys.ed25519;
|
|
104
|
+
log(`init: deviceCurve25519Key: ${this.deviceCurve25519Key}, fallbackKey ${JSON.stringify(this.fallbackKey)}`);
|
|
105
|
+
}
|
|
106
|
+
async initializeFromExportedDevice(exportedData, account) {
|
|
107
|
+
await this.cryptoStore.withAccountTx(() => this.cryptoStore.storeAccount(exportedData.pickledAccount));
|
|
108
|
+
await this.cryptoStore.withGroupSessions(() => {
|
|
109
|
+
return Promise.all([
|
|
110
|
+
...exportedData.outboundSessions.map((session) => this.cryptoStore.storeEndToEndOutboundGroupSession(session.sessionId, session.session, session.streamId)),
|
|
111
|
+
...exportedData.inboundSessions.map((session) => this.cryptoStore.storeEndToEndInboundGroupSession(session.streamId, session.sessionId, {
|
|
112
|
+
stream_id: session.streamId,
|
|
113
|
+
session: session.session,
|
|
114
|
+
keysClaimed: {},
|
|
115
|
+
})),
|
|
116
|
+
...exportedData.hybridGroupSessions.map((session) => this.cryptoStore.storeHybridGroupSession(session)),
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
account.unpickle(this.pickleKey, exportedData.pickledAccount);
|
|
120
|
+
}
|
|
121
|
+
async initializeAccount(account) {
|
|
122
|
+
try {
|
|
123
|
+
const pickledAccount = await this.cryptoStore.getAccount();
|
|
124
|
+
account.unpickle(this.pickleKey, pickledAccount);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
account.create();
|
|
128
|
+
const pickledAccount = account.pickle(this.pickleKey);
|
|
129
|
+
await this.cryptoStore.storeAccount(pickledAccount);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Export the current device state
|
|
134
|
+
* @returns ExportedDevice object containing the device state
|
|
135
|
+
*/
|
|
136
|
+
async exportDevice() {
|
|
137
|
+
const account = await this.getAccount();
|
|
138
|
+
const pickledAccount = account.pickle(this.pickleKey);
|
|
139
|
+
account.free();
|
|
140
|
+
const [inboundSessions, outboundSessions, hybridGroupSessions] = await Promise.all([
|
|
141
|
+
this.cryptoStore.getAllEndToEndInboundGroupSessions(),
|
|
142
|
+
this.cryptoStore.getAllEndToEndOutboundGroupSessions(),
|
|
143
|
+
this.cryptoStore.getAllHybridGroupSessions(),
|
|
144
|
+
]);
|
|
145
|
+
return create(ExportedDeviceSchema, {
|
|
146
|
+
pickleKey: this.pickleKey,
|
|
147
|
+
pickledAccount,
|
|
148
|
+
inboundSessions: inboundSessions.map((session) => create(ExportedDevice_GroupSessionSchema, {
|
|
149
|
+
sessionId: session.sessionId,
|
|
150
|
+
streamId: session.streamId,
|
|
151
|
+
session: session.session,
|
|
152
|
+
})),
|
|
153
|
+
outboundSessions: outboundSessions.map((session) => create(ExportedDevice_GroupSessionSchema, {
|
|
154
|
+
sessionId: session.sessionId,
|
|
155
|
+
streamId: session.streamId,
|
|
156
|
+
session: session.session,
|
|
157
|
+
})),
|
|
158
|
+
hybridGroupSessions: hybridGroupSessions.map((session) => create(ExportedDevice_HybridGroupSessionSchema, {
|
|
159
|
+
sessionId: session.sessionId,
|
|
160
|
+
streamId: session.streamId,
|
|
161
|
+
sessionKey: session.sessionKey,
|
|
162
|
+
miniblockNum: session.miniblockNum,
|
|
163
|
+
})),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Extract our Account from the crypto store and call the given function
|
|
168
|
+
* with the account object
|
|
169
|
+
* The `account` object is usable only within the callback passed to this
|
|
170
|
+
* function and will be freed as soon the callback returns. It is *not*
|
|
171
|
+
* usable for the rest of the lifetime of the transaction.
|
|
172
|
+
* This function requires a live transaction object from cryptoStore.doTxn()
|
|
173
|
+
* and therefore may only be called in a doTxn() callback.
|
|
174
|
+
*
|
|
175
|
+
* @param txn - Opaque transaction object from cryptoStore.doTxn()
|
|
176
|
+
* @internal
|
|
177
|
+
*/
|
|
178
|
+
async getAccount() {
|
|
179
|
+
const pickledAccount = await this.cryptoStore.getAccount();
|
|
180
|
+
const account = this.delegate.createAccount();
|
|
181
|
+
account.unpickle(this.pickleKey, pickledAccount);
|
|
182
|
+
return account;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Saves an account to the crypto store.
|
|
186
|
+
* This function requires a live transaction object from cryptoStore.doTxn()
|
|
187
|
+
* and therefore may only be called in a doTxn() callback.
|
|
188
|
+
*
|
|
189
|
+
* @param txn - Opaque transaction object from cryptoStore.doTxn()
|
|
190
|
+
* @param Account object
|
|
191
|
+
* @internal
|
|
192
|
+
*/
|
|
193
|
+
async storeAccount(account) {
|
|
194
|
+
await this.cryptoStore.storeAccount(account.pickle(this.pickleKey));
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* get an OlmUtility and call the given function
|
|
198
|
+
*
|
|
199
|
+
* @returns result of func
|
|
200
|
+
* @internal
|
|
201
|
+
*/
|
|
202
|
+
getUtility(func) {
|
|
203
|
+
const utility = this.delegate.createUtility();
|
|
204
|
+
try {
|
|
205
|
+
return func(utility);
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
utility.free();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Signs a message with the ed25519 key for this account.
|
|
213
|
+
*
|
|
214
|
+
* @param message - message to be signed
|
|
215
|
+
* @returns base64-encoded signature
|
|
216
|
+
*/
|
|
217
|
+
async sign(message) {
|
|
218
|
+
const account = await this.getAccount();
|
|
219
|
+
return account.sign(message);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Marks all of the fallback keys as published.
|
|
223
|
+
*/
|
|
224
|
+
async markKeysAsPublished() {
|
|
225
|
+
const account = await this.getAccount();
|
|
226
|
+
account.mark_keys_as_published();
|
|
227
|
+
await this.storeAccount(account);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Generate a new fallback keys
|
|
231
|
+
*
|
|
232
|
+
* @returns Resolved once the account is saved back having generated the key
|
|
233
|
+
*/
|
|
234
|
+
async generateFallbackKeyIfNeeded() {
|
|
235
|
+
try {
|
|
236
|
+
await this.getFallbackKey();
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
const account = await this.getAccount();
|
|
240
|
+
account.generate_fallback_key();
|
|
241
|
+
await this.storeAccount(account);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async getFallbackKey() {
|
|
245
|
+
const account = await this.getAccount();
|
|
246
|
+
const record = JSON.parse(account.unpublished_fallback_key());
|
|
247
|
+
const key = Object.values(record.curve25519)[0];
|
|
248
|
+
const keyId = Object.keys(record.curve25519)[0];
|
|
249
|
+
if (!key || !keyId) {
|
|
250
|
+
throw new Error('No fallback key');
|
|
251
|
+
}
|
|
252
|
+
return { key, keyId };
|
|
253
|
+
}
|
|
254
|
+
async forgetOldFallbackKey() {
|
|
255
|
+
const account = await this.getAccount();
|
|
256
|
+
account.forget_old_fallback_key();
|
|
257
|
+
await this.storeAccount(account);
|
|
258
|
+
}
|
|
259
|
+
// Outbound group session
|
|
260
|
+
// ======================
|
|
261
|
+
/**
|
|
262
|
+
* Store an OutboundGroupSession in outboundSessionStore
|
|
263
|
+
*
|
|
264
|
+
*/
|
|
265
|
+
async saveOutboundGroupSession(session, streamId) {
|
|
266
|
+
return this.cryptoStore.withGroupSessions(async () => {
|
|
267
|
+
await this.cryptoStore.storeEndToEndOutboundGroupSession(session.session_id(), session.pickle(this.pickleKey), streamId);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Extract OutboundGroupSession from the session store and call given fn.
|
|
272
|
+
*/
|
|
273
|
+
async getOutboundGroupSession(streamId) {
|
|
274
|
+
return this.cryptoStore.withGroupSessions(async () => {
|
|
275
|
+
const pickled = await this.cryptoStore.getEndToEndOutboundGroupSession(streamId);
|
|
276
|
+
if (!pickled) {
|
|
277
|
+
throw new Error(`Unknown outbound group session ${streamId}`);
|
|
278
|
+
}
|
|
279
|
+
const session = this.delegate.createOutboundGroupSession();
|
|
280
|
+
session.unpickle(this.pickleKey, pickled);
|
|
281
|
+
return session;
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Get the session keys for an outbound group session
|
|
286
|
+
*
|
|
287
|
+
* @param sessionId - the id of the outbound group session
|
|
288
|
+
*
|
|
289
|
+
* @returns current chain index, and
|
|
290
|
+
* base64-encoded secret key.
|
|
291
|
+
*/
|
|
292
|
+
async getOutboundGroupSessionKey(streamId) {
|
|
293
|
+
const session = await this.getOutboundGroupSession(streamId);
|
|
294
|
+
const chain_index = session.message_index();
|
|
295
|
+
const key = session.session_key();
|
|
296
|
+
const sessionId = session.session_id();
|
|
297
|
+
session.free();
|
|
298
|
+
return { chain_index, key, sessionId };
|
|
299
|
+
}
|
|
300
|
+
/** */
|
|
301
|
+
async getHybridGroupSessionKeyForStream(streamId) {
|
|
302
|
+
return this.cryptoStore.withGroupSessions(async () => {
|
|
303
|
+
const sessionRecords = await this.cryptoStore.getHybridGroupSessionsForStream(streamId);
|
|
304
|
+
if (sessionRecords.length === 0) {
|
|
305
|
+
throw new Error(`hybrid group session not found for stream ${streamId}`);
|
|
306
|
+
}
|
|
307
|
+
// sort on session.miniblockNum decending
|
|
308
|
+
const sessionRecord = sessionRecords.reduce((max, current) => (current.miniblockNum > max.miniblockNum ? current : max), sessionRecords[0]);
|
|
309
|
+
return fromBinary(HybridGroupSessionKeySchema, sessionRecord.sessionKey);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/** */
|
|
313
|
+
async getHybridGroupSessionKey(streamId, sessionId) {
|
|
314
|
+
return this.cryptoStore.withGroupSessions(async () => {
|
|
315
|
+
const sessionRecord = await this.cryptoStore.getHybridGroupSession(streamId, sessionId);
|
|
316
|
+
if (!sessionRecord) {
|
|
317
|
+
throw new Error(`hybrid group session not found for stream ${streamId}`);
|
|
318
|
+
}
|
|
319
|
+
return fromBinary(HybridGroupSessionKeySchema, sessionRecord.sessionKey);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Generate a new outbound group session
|
|
324
|
+
*
|
|
325
|
+
*/
|
|
326
|
+
async createOutboundGroupSession(streamId) {
|
|
327
|
+
return await this.cryptoStore.withGroupSessions(async () => {
|
|
328
|
+
// Create an outbound group session
|
|
329
|
+
const session = this.delegate.createOutboundGroupSession();
|
|
330
|
+
const inboundSession = this.delegate.createInboundGroupSession();
|
|
331
|
+
try {
|
|
332
|
+
session.create();
|
|
333
|
+
const sessionId = session.session_id();
|
|
334
|
+
await this.saveOutboundGroupSession(session, streamId);
|
|
335
|
+
// While still inside the transaction, create an inbound counterpart session
|
|
336
|
+
// to make sure that the session is exported at message index 0.
|
|
337
|
+
const key = session.session_key();
|
|
338
|
+
inboundSession.create(key);
|
|
339
|
+
const pickled = inboundSession.pickle(this.pickleKey);
|
|
340
|
+
await this.cryptoStore.storeEndToEndInboundGroupSession(streamId, sessionId, {
|
|
341
|
+
session: pickled,
|
|
342
|
+
stream_id: streamId,
|
|
343
|
+
keysClaimed: {},
|
|
344
|
+
});
|
|
345
|
+
return sessionId;
|
|
346
|
+
}
|
|
347
|
+
catch (e) {
|
|
348
|
+
log('Error creating outbound group session', e);
|
|
349
|
+
throw e;
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
session.free();
|
|
353
|
+
inboundSession.free();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
/** */
|
|
358
|
+
async createHybridGroupSession(streamId, miniblockNum, miniblockHash) {
|
|
359
|
+
const streamIdBytes = bin_fromHexString(streamId);
|
|
360
|
+
const aesKey = await generateNewAesGcmKey();
|
|
361
|
+
const aesKeyBytes = await exportAesGsmKeyBytes(aesKey);
|
|
362
|
+
const sessionIdBytes = await hybridSessionKeyHash(streamIdBytes, aesKeyBytes, miniblockNum, miniblockHash);
|
|
363
|
+
const sessionKey = create(HybridGroupSessionKeySchema, {
|
|
364
|
+
sessionId: sessionIdBytes,
|
|
365
|
+
streamId: streamIdBytes,
|
|
366
|
+
key: aesKeyBytes,
|
|
367
|
+
miniblockNum,
|
|
368
|
+
miniblockHash,
|
|
369
|
+
});
|
|
370
|
+
const sessionId = bin_toHexString(sessionIdBytes);
|
|
371
|
+
const sessionRecord = {
|
|
372
|
+
sessionId,
|
|
373
|
+
streamId: streamId,
|
|
374
|
+
sessionKey: toBinary(HybridGroupSessionKeySchema, sessionKey),
|
|
375
|
+
miniblockNum,
|
|
376
|
+
};
|
|
377
|
+
return this.cryptoStore.withGroupSessions(async () => {
|
|
378
|
+
await this.cryptoStore.storeHybridGroupSession(sessionRecord);
|
|
379
|
+
return { sessionId, sessionRecord, sessionKey };
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
// Inbound group session
|
|
383
|
+
// =====================
|
|
384
|
+
/**
|
|
385
|
+
* Unpickle a session from a sessionData object and invoke the given function.
|
|
386
|
+
* The session is valid only until func returns.
|
|
387
|
+
*
|
|
388
|
+
* @param sessionData - Object describing the session.
|
|
389
|
+
* @param func - Invoked with the unpickled session
|
|
390
|
+
* @returns result of func
|
|
391
|
+
*/
|
|
392
|
+
unpickleInboundGroupSession(sessionData) {
|
|
393
|
+
const session = this.delegate.createInboundGroupSession();
|
|
394
|
+
session.unpickle(this.pickleKey, sessionData.session);
|
|
395
|
+
return session;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Extract an InboundGroupSession from the crypto store and call the given function
|
|
399
|
+
*
|
|
400
|
+
* @param streamId - The stream ID to extract the session for, or null to fetch
|
|
401
|
+
* sessions for any room.
|
|
402
|
+
* @param txn - Opaque transaction object from cryptoStore.doTxn()
|
|
403
|
+
* @param func - function to call.
|
|
404
|
+
*
|
|
405
|
+
* @internal
|
|
406
|
+
*/
|
|
407
|
+
async getInboundGroupSession(streamId, sessionId) {
|
|
408
|
+
const sessionInfo = await this.cryptoStore.getEndToEndInboundGroupSession(streamId, sessionId);
|
|
409
|
+
const session = sessionInfo ? this.unpickleInboundGroupSession(sessionInfo) : undefined;
|
|
410
|
+
return {
|
|
411
|
+
session: session,
|
|
412
|
+
data: sessionInfo,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Add an inbound group session to the session store
|
|
417
|
+
*
|
|
418
|
+
* @param streamId - room in which this session will be used
|
|
419
|
+
* @param senderKey - base64-encoded curve25519 key of the sender
|
|
420
|
+
* @param sessionId - session identifier
|
|
421
|
+
* @param sessionKey - base64-encoded secret key
|
|
422
|
+
* @param keysClaimed - Other keys the sender claims.
|
|
423
|
+
* @param exportFormat - true if the group keys are in export format
|
|
424
|
+
* (ie, they lack an ed25519 signature)
|
|
425
|
+
* @param extraSessionData - any other data to be include with the session
|
|
426
|
+
*/
|
|
427
|
+
async addInboundGroupSession(streamId, sessionId, sessionKey, keysClaimed, _exportFormat, extraSessionData = {}) {
|
|
428
|
+
const { session: existingSession, data: existingSessionData } = await this.getInboundGroupSession(streamId, sessionId);
|
|
429
|
+
const session = this.delegate.createInboundGroupSession();
|
|
430
|
+
try {
|
|
431
|
+
log(`Adding group session ${streamId}|${sessionId}`);
|
|
432
|
+
try {
|
|
433
|
+
session.import_session(sessionKey);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
session.create(sessionKey);
|
|
437
|
+
}
|
|
438
|
+
if (sessionId != session.session_id()) {
|
|
439
|
+
throw new Error('Mismatched group session ID from streamId: ' + streamId);
|
|
440
|
+
}
|
|
441
|
+
if (existingSession && existingSessionData) {
|
|
442
|
+
log(`Update for group session ${streamId}|${sessionId}`);
|
|
443
|
+
if (existingSession.first_known_index() <= session.first_known_index()) {
|
|
444
|
+
if (!existingSessionData.untrusted || extraSessionData.untrusted) {
|
|
445
|
+
// existing session has less-than-or-equal index
|
|
446
|
+
// (i.e. can decrypt at least as much), and the
|
|
447
|
+
// new session's trust does not win over the old
|
|
448
|
+
// session's trust, so keep it
|
|
449
|
+
log(`Keeping existing group session ${streamId}|${sessionId}`);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (existingSession.first_known_index() < session.first_known_index()) {
|
|
453
|
+
// We want to upgrade the existing session's trust,
|
|
454
|
+
// but we can't just use the new session because we'll
|
|
455
|
+
// lose the lower index. Check that the sessions connect
|
|
456
|
+
// properly, and then manually set the existing session
|
|
457
|
+
// as trusted.
|
|
458
|
+
if (existingSession.export_session(session.first_known_index()) ===
|
|
459
|
+
session.export_session(session.first_known_index())) {
|
|
460
|
+
log('Upgrading trust of existing group session ' +
|
|
461
|
+
`${streamId}|${sessionId} based on newly-received trusted session`);
|
|
462
|
+
existingSessionData.untrusted = false;
|
|
463
|
+
await this.cryptoStore.storeEndToEndInboundGroupSession(streamId, sessionId, existingSessionData);
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
log(`Newly-received group session ${streamId}|$sessionId}` +
|
|
467
|
+
' does not match existing session! Keeping existing session');
|
|
468
|
+
}
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
// If the sessions have the same index, go ahead and store the new trusted one.
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
log(`Storing group session ${streamId}|${sessionId} with first index ${session.first_known_index()}`);
|
|
475
|
+
const sessionData = Object.assign({}, extraSessionData, {
|
|
476
|
+
stream_id: streamId,
|
|
477
|
+
session: session.pickle(this.pickleKey),
|
|
478
|
+
keysClaimed: keysClaimed,
|
|
479
|
+
});
|
|
480
|
+
await this.cryptoStore.withGroupSessions(async () => {
|
|
481
|
+
await this.cryptoStore.storeEndToEndInboundGroupSession(streamId, sessionId, sessionData);
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
finally {
|
|
485
|
+
session.free();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/** */
|
|
489
|
+
async addHybridGroupSession(streamId, sessionId, sessionKey) {
|
|
490
|
+
const sessionKeyBytes = bin_fromHexString(sessionKey);
|
|
491
|
+
const session = fromBinary(HybridGroupSessionKeySchema, sessionKeyBytes);
|
|
492
|
+
if (bin_toHexString(session.streamId) !== streamId) {
|
|
493
|
+
throw new Error(`Stream ID mismatch for hybrid group session ${streamId}`);
|
|
494
|
+
}
|
|
495
|
+
if (bin_toHexString(session.sessionId) !== sessionId) {
|
|
496
|
+
throw new Error(`Session ID mismatch for hybrid group session ${sessionId}`);
|
|
497
|
+
}
|
|
498
|
+
const expectedSessionPromise = hybridSessionKeyHash(session.streamId, session.key, session.miniblockNum, session.miniblockHash);
|
|
499
|
+
const expectedSessionId = await Dexie.waitFor(expectedSessionPromise);
|
|
500
|
+
if (!bin_equal(expectedSessionId, bin_fromHexString(sessionId))) {
|
|
501
|
+
throw new Error(`Session ID mismatch for hybrid group session ${sessionId} expected ${bin_toHexString(expectedSessionId)}`);
|
|
502
|
+
}
|
|
503
|
+
await this.cryptoStore.withGroupSessions(async () => {
|
|
504
|
+
await this.cryptoStore.storeHybridGroupSession({
|
|
505
|
+
sessionId,
|
|
506
|
+
streamId,
|
|
507
|
+
sessionKey: sessionKeyBytes,
|
|
508
|
+
miniblockNum: session.miniblockNum,
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Encrypt an outgoing message with an outbound group session
|
|
514
|
+
*
|
|
515
|
+
* @param sessionId - this id of the session
|
|
516
|
+
* @param payloadString - payload to be encrypted
|
|
517
|
+
*
|
|
518
|
+
* @returns ciphertext
|
|
519
|
+
*/
|
|
520
|
+
async encryptGroupMessage(payloadString, streamId) {
|
|
521
|
+
return await this.cryptoStore.withGroupSessions(async () => {
|
|
522
|
+
log(`encrypting msg with group session for stream id ${streamId}`);
|
|
523
|
+
checkPayloadLength(payloadString, { streamId, source: 'encryptGroupMessage' });
|
|
524
|
+
const session = await this.getOutboundGroupSession(streamId);
|
|
525
|
+
const ciphertext = session.encrypt(payloadString);
|
|
526
|
+
const sessionId = session.session_id();
|
|
527
|
+
await this.saveOutboundGroupSession(session, streamId);
|
|
528
|
+
session.free();
|
|
529
|
+
return { ciphertext, sessionId };
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
async encryptUsingFallbackKey(theirIdentityKey, fallbackKey, payload) {
|
|
533
|
+
checkPayloadLength(payload, { source: 'encryptUsingFallbackKey' });
|
|
534
|
+
return this.cryptoStore.withAccountTx(async () => {
|
|
535
|
+
const session = this.delegate.createSession();
|
|
536
|
+
try {
|
|
537
|
+
const account = await this.getAccount();
|
|
538
|
+
session.create_outbound(account, theirIdentityKey, fallbackKey);
|
|
539
|
+
const result = session.encrypt(payload);
|
|
540
|
+
return result;
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
log('Error encrypting message with fallback key', error);
|
|
544
|
+
throw error;
|
|
545
|
+
}
|
|
546
|
+
finally {
|
|
547
|
+
session.free();
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Decrypt an incoming message using an existing session
|
|
553
|
+
*
|
|
554
|
+
* @param theirDeviceIdentityKey - Curve25519 identity key for the
|
|
555
|
+
* remote device
|
|
556
|
+
* @param messageType - messageType field from the received message
|
|
557
|
+
* @param ciphertext - base64-encoded body from the received message
|
|
558
|
+
*
|
|
559
|
+
* @returns decrypted payload.
|
|
560
|
+
*/
|
|
561
|
+
async decryptMessage(ciphertext, theirDeviceIdentityKey, messageType = 0) {
|
|
562
|
+
if (messageType !== 0) {
|
|
563
|
+
throw new Error('Only pre-key messages supported');
|
|
564
|
+
}
|
|
565
|
+
checkPayloadLength(ciphertext, { source: 'decryptMessage' });
|
|
566
|
+
return await this.cryptoStore.withAccountTx(async () => {
|
|
567
|
+
const account = await this.getAccount();
|
|
568
|
+
const session = this.delegate.createSession();
|
|
569
|
+
const sessionDesc = session.describe();
|
|
570
|
+
log('Session ID ' +
|
|
571
|
+
session.session_id() +
|
|
572
|
+
' from ' +
|
|
573
|
+
theirDeviceIdentityKey +
|
|
574
|
+
': ' +
|
|
575
|
+
sessionDesc);
|
|
576
|
+
try {
|
|
577
|
+
session.create_inbound_from(account, theirDeviceIdentityKey, ciphertext);
|
|
578
|
+
await this.storeAccount(account);
|
|
579
|
+
return session.decrypt(messageType, ciphertext);
|
|
580
|
+
}
|
|
581
|
+
catch (e) {
|
|
582
|
+
throw new Error('Error decrypting prekey message: ' + JSON.stringify(e.message));
|
|
583
|
+
}
|
|
584
|
+
finally {
|
|
585
|
+
session.free();
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
// Utilities
|
|
590
|
+
// =========
|
|
591
|
+
/**
|
|
592
|
+
* Verify an ed25519 signature.
|
|
593
|
+
*
|
|
594
|
+
* @param key - ed25519 key
|
|
595
|
+
* @param message - message which was signed
|
|
596
|
+
* @param signature - base64-encoded signature to be checked
|
|
597
|
+
*
|
|
598
|
+
* @throws Error if there is a problem with the verification. If the key was
|
|
599
|
+
* too small then the message will be "OLM.INVALID_BASE64". If the signature
|
|
600
|
+
* was invalid then the message will be "OLM.BAD_MESSAGE_MAC".
|
|
601
|
+
*/
|
|
602
|
+
verifySignature(key, message, signature) {
|
|
603
|
+
this.getUtility(function (util) {
|
|
604
|
+
util.ed25519_verify(key, message, signature);
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
// Group Sessions
|
|
608
|
+
async getInboundGroupSessionIds(streamId) {
|
|
609
|
+
return await this.cryptoStore.getInboundGroupSessionIds(streamId);
|
|
610
|
+
}
|
|
611
|
+
async getHybridGroupSessionIds(streamId) {
|
|
612
|
+
return await this.cryptoStore.getHybridGroupSessionIds(streamId);
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Determine if we have the keys for a given group session
|
|
616
|
+
*
|
|
617
|
+
* @param streamId - stream in which the message was received
|
|
618
|
+
* @param senderKey - base64-encoded curve25519 key of the sender
|
|
619
|
+
* @param sessionId - session identifier
|
|
620
|
+
*/
|
|
621
|
+
async hasInboundSessionKeys(streamId, sessionId) {
|
|
622
|
+
const sessionData = await this.cryptoStore.withGroupSessions(async () => {
|
|
623
|
+
return this.cryptoStore.getEndToEndInboundGroupSession(streamId, sessionId);
|
|
624
|
+
});
|
|
625
|
+
if (!sessionData) {
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
if (streamId !== sessionData.stream_id) {
|
|
629
|
+
log(`[hasInboundSessionKey]: requested keys for inbound group session` +
|
|
630
|
+
`${sessionId}, with incorrect stream id ` +
|
|
631
|
+
`(expected ${sessionData.stream_id}, ` +
|
|
632
|
+
`was ${streamId})`);
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
/** */
|
|
640
|
+
async hasHybridGroupSessionKey(streamId, sessionId) {
|
|
641
|
+
const key = await this.cryptoStore.getHybridGroupSession(streamId, sessionId);
|
|
642
|
+
return key !== undefined;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Export an inbound group session
|
|
646
|
+
*
|
|
647
|
+
* @param streamId - streamId of session
|
|
648
|
+
* @param sessionId - session identifier
|
|
649
|
+
*/
|
|
650
|
+
async exportInboundGroupSession(streamId, sessionId) {
|
|
651
|
+
const sessionData = await this.cryptoStore.getEndToEndInboundGroupSession(streamId, sessionId);
|
|
652
|
+
if (!sessionData) {
|
|
653
|
+
return undefined;
|
|
654
|
+
}
|
|
655
|
+
const session = this.unpickleInboundGroupSession(sessionData);
|
|
656
|
+
const messageIndex = session.first_known_index();
|
|
657
|
+
const sessionKey = session.export_session(messageIndex);
|
|
658
|
+
session.free();
|
|
659
|
+
return {
|
|
660
|
+
streamId: streamId,
|
|
661
|
+
sessionId: sessionId,
|
|
662
|
+
sessionKey: sessionKey,
|
|
663
|
+
algorithm: GroupEncryptionAlgorithmId.GroupEncryption,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
/** */
|
|
667
|
+
async exportHybridGroupSession(streamId, sessionId) {
|
|
668
|
+
const sessionData = await this.cryptoStore.getHybridGroupSession(streamId, sessionId);
|
|
669
|
+
if (!sessionData) {
|
|
670
|
+
return undefined;
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
streamId: streamId,
|
|
674
|
+
sessionId: sessionId,
|
|
675
|
+
sessionKey: bin_toHexString(sessionData.sessionKey),
|
|
676
|
+
algorithm: GroupEncryptionAlgorithmId.HybridGroupEncryption,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Get a list containing all of the room keys
|
|
681
|
+
*
|
|
682
|
+
* @returns a list of session export objects
|
|
683
|
+
*/
|
|
684
|
+
async exportInboundGroupSessions() {
|
|
685
|
+
const exportedSessions = [];
|
|
686
|
+
await this.cryptoStore.withGroupSessions(async () => {
|
|
687
|
+
const sessions = await this.cryptoStore.getAllEndToEndInboundGroupSessions();
|
|
688
|
+
for (const sessionData of sessions) {
|
|
689
|
+
if (!sessionData) {
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
const session = this.unpickleInboundGroupSession(sessionData);
|
|
693
|
+
const messageIndex = session.first_known_index();
|
|
694
|
+
const sessionKey = session.export_session(messageIndex);
|
|
695
|
+
session.free();
|
|
696
|
+
exportedSessions.push({
|
|
697
|
+
streamId: sessionData.streamId,
|
|
698
|
+
sessionId: sessionData.sessionId,
|
|
699
|
+
sessionKey: sessionKey,
|
|
700
|
+
algorithm: GroupEncryptionAlgorithmId.GroupEncryption,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
return exportedSessions;
|
|
705
|
+
}
|
|
706
|
+
async exportHybridGroupSessions() {
|
|
707
|
+
const sessions = await this.cryptoStore.getAllHybridGroupSessions();
|
|
708
|
+
return sessions.map((session) => {
|
|
709
|
+
return {
|
|
710
|
+
streamId: session.streamId,
|
|
711
|
+
sessionId: session.sessionId,
|
|
712
|
+
sessionKey: bin_toHexString(session.sessionKey),
|
|
713
|
+
algorithm: GroupEncryptionAlgorithmId.HybridGroupEncryption,
|
|
714
|
+
};
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
const hybridSessionKeyHashPrefixBytes = new TextEncoder().encode('RVR_HSK:');
|
|
719
|
+
// TODO: needs unit tests
|
|
720
|
+
export async function hybridSessionKeyHash(streamId, key, miniblockNum, miniblockHash) {
|
|
721
|
+
const length = hybridSessionKeyHashPrefixBytes.length +
|
|
722
|
+
streamId.length +
|
|
723
|
+
key.length +
|
|
724
|
+
8 +
|
|
725
|
+
miniblockHash.length;
|
|
726
|
+
const bytes = new ArrayBuffer(length);
|
|
727
|
+
const dataView = new DataView(bytes);
|
|
728
|
+
const arrayView = new Uint8Array(bytes);
|
|
729
|
+
arrayView.set(hybridSessionKeyHashPrefixBytes);
|
|
730
|
+
let offset = hybridSessionKeyHashPrefixBytes.length;
|
|
731
|
+
arrayView.set(streamId, offset);
|
|
732
|
+
offset += streamId.length;
|
|
733
|
+
arrayView.set(key, offset);
|
|
734
|
+
offset += key.length;
|
|
735
|
+
dataView.setBigUint64(offset, miniblockNum);
|
|
736
|
+
offset += 8;
|
|
737
|
+
arrayView.set(miniblockHash, offset);
|
|
738
|
+
offset += miniblockHash.length;
|
|
739
|
+
if (offset !== length) {
|
|
740
|
+
throw new Error(`Final offset ${offset} does not match expected length ${length}`);
|
|
741
|
+
}
|
|
742
|
+
const hashBytes = await crypto.subtle.digest('SHA-256', bytes);
|
|
743
|
+
return new Uint8Array(hashBytes);
|
|
744
|
+
}
|
|
745
|
+
//# sourceMappingURL=encryptionDevice.js.map
|