Skip to content

Commit d7a8877

Browse files
richvdhpoljar
andauthored
indexeddb: expose new method IndexeddbCryptoStore::open_with_key (#3423)
Allow applications to skip the PBKDF2 operation if they already have a cryptographically secure key, instead using a simple HKDF to derive a key. In order to maintain compatibility for existing element-web sessions, if we discover that we have an existing store that was encrypted with a key derived from PBKDF2, then we reconstruct what element-web used to do: specifically, we base64-encode the key to obtain the "passphrase" that was previously passed in. If that matches, we know we've got the right key, and can update the meta store accordingly. Part of a resolution to element-hq/element-web#26821. Signed-off-by: Richard van der Hoff <[email protected]> Co-authored-by: Damir Jelić <[email protected]>
1 parent 794b11a commit d7a8877

File tree

4 files changed

+233
-41
lines changed

4 files changed

+233
-41
lines changed

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
# unreleased
1+
# UNRELEASED
2+
3+
- Add new method `IndexeddbCryptoStore::open_with_key`. ([#3423](https://github.com/matrix-org/matrix-rust-sdk/pull/3423))
24

35
- `save_change` performance improvement, all encryption and serialization
4-
is done now outside of the db transaction.
6+
is done now outside of the db transaction.

crates/matrix-sdk-indexeddb/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ tokio = { workspace = true }
3838
tracing = { workspace = true }
3939
wasm-bindgen = "0.2.83"
4040
web-sys = { version = "0.3.57", features = ["IdbKeyRange"] }
41+
hkdf = "0.12.4"
42+
zeroize = { workspace = true }
43+
sha2 = { workspace = true }
4144

4245
[target.'cfg(target_arch = "wasm32")'.dependencies]
4346
# for wasm32 we need to activate this
@@ -50,6 +53,7 @@ matrix-sdk-base = { workspace = true, features = ["testing"] }
5053
matrix-sdk-common = { workspace = true, features = ["js"] }
5154
matrix-sdk-crypto = { workspace = true, features = ["js", "testing"] }
5255
matrix-sdk-test = { workspace = true }
56+
rand = { workspace = true }
5357
tracing-subscriber = { version = "0.3.18", default-features = false, features = ["registry", "tracing-log"] }
5458
uuid = "1.3.0"
5559
wasm-bindgen-test = "0.3.33"

crates/matrix-sdk-indexeddb/src/crypto_store/mod.rs

Lines changed: 221 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use std::{
1919

2020
use async_trait::async_trait;
2121
use gloo_utils::format::JsValueSerdeExt;
22+
use hkdf::Hkdf;
2223
use indexed_db_futures::prelude::*;
2324
use matrix_sdk_crypto::{
2425
olm::{
@@ -30,6 +31,7 @@ use matrix_sdk_crypto::{
3031
RoomKeyCounts, RoomSettings,
3132
},
3233
types::events::room_key_withheld::RoomKeyWithheldEvent,
34+
vodozemac::base64_encode,
3335
Account, GossipRequest, GossippedSecret, ReadOnlyDevice, ReadOnlyUserIdentities, SecretInfo,
3436
TrackedUser,
3537
};
@@ -38,6 +40,7 @@ use ruma::{
3840
events::secret::request::SecretName, DeviceId, MilliSecondsSinceUnixEpoch, OwnedDeviceId,
3941
RoomId, TransactionId, UserId,
4042
};
43+
use sha2::Sha256;
4144
use tokio::sync::Mutex;
4245
use tracing::{debug, warn};
4346
use wasm_bindgen::JsValue;
@@ -272,34 +275,25 @@ impl IndexeddbCryptoStore {
272275
IndexeddbCryptoStore::open_with_store_cipher("crypto", None).await
273276
}
274277

275-
/// Open a new `IndexeddbCryptoStore` with given name and passphrase
278+
/// Open an `IndexeddbCryptoStore` with given name and passphrase.
279+
///
280+
/// If the store previously existed, the encryption cipher is initialised
281+
/// using the given passphrase and the details from the meta store. If the
282+
/// store did not previously exist, a new encryption cipher is derived
283+
/// from the passphrase, and the details are stored to the metastore.
284+
///
285+
/// The store is then opened, or a new one created, using the encryption
286+
/// cipher.
287+
///
288+
/// # Arguments
289+
///
290+
/// * `prefix` - Common prefix for the names of the two IndexedDB stores.
291+
/// * `passphrase` - Passphrase which is used to derive a key to encrypt the
292+
/// key which is used to encrypt the store. Must be the same each time the
293+
/// store is opened.
276294
pub async fn open_with_passphrase(prefix: &str, passphrase: &str) -> Result<Self> {
277-
let name = format!("{prefix:0}::matrix-sdk-crypto-meta");
278-
279-
debug!("IndexedDbCryptoStore: Opening meta-store {name}");
280-
let mut db_req: OpenDbRequest = IdbDatabase::open_u32(&name, 1)?;
281-
db_req.set_on_upgrade_needed(Some(|evt: &IdbVersionChangeEvent| -> Result<(), JsValue> {
282-
let old_version = evt.old_version() as u32;
283-
if old_version < 1 {
284-
// migrating to version 1
285-
let db = evt.db();
286-
287-
db.create_object_store("matrix-sdk-crypto")?;
288-
}
289-
Ok(())
290-
}));
291-
292-
let db: IdbDatabase = db_req.await?;
293-
294-
let tx: IdbTransaction<'_> =
295-
db.transaction_on_one_with_mode("matrix-sdk-crypto", IdbTransactionMode::Readonly)?;
296-
let ob = tx.object_store("matrix-sdk-crypto")?;
297-
298-
let store_cipher: Option<Vec<u8>> = ob
299-
.get(&JsValue::from_str(keys::STORE_CIPHER))?
300-
.await?
301-
.map(|k| k.into_serde())
302-
.transpose()?;
295+
let db = open_meta_db(prefix).await?;
296+
let store_cipher = load_store_cipher(&db).await?;
303297

304298
let store_cipher = match store_cipher {
305299
Some(cipher) => {
@@ -315,17 +309,58 @@ impl IndexeddbCryptoStore {
315309
#[cfg(test)]
316310
let export = cipher._insecure_export_fast_for_testing(passphrase);
317311

318-
let tx: IdbTransaction<'_> = db.transaction_on_one_with_mode(
319-
"matrix-sdk-crypto",
320-
IdbTransactionMode::Readwrite,
321-
)?;
322-
let ob = tx.object_store("matrix-sdk-crypto")?;
323-
324-
ob.put_key_val(
325-
&JsValue::from_str(keys::STORE_CIPHER),
326-
&JsValue::from_serde(&export.map_err(CryptoStoreError::backend)?)?,
327-
)?;
328-
tx.await.into_result()?;
312+
let export = export.map_err(CryptoStoreError::backend)?;
313+
314+
save_store_cipher(&db, &export).await?;
315+
cipher
316+
}
317+
};
318+
319+
// Must release the database access manually as it's not done when
320+
// dropping it.
321+
db.close();
322+
323+
IndexeddbCryptoStore::open_with_store_cipher(prefix, Some(store_cipher.into())).await
324+
}
325+
326+
/// Open an `IndexeddbCryptoStore` with given name and key.
327+
///
328+
/// If the store previously existed, the encryption cipher is initialised
329+
/// using the given key and the details from the meta store. If the store
330+
/// did not previously exist, a new encryption cipher is derived from
331+
/// the passphrase, and the details are stored to the metastore.
332+
///
333+
/// The store is then opened, or a new one created, using the encryption
334+
/// cipher.
335+
///
336+
/// # Arguments
337+
///
338+
/// * `prefix` - Common prefix for the names of the two IndexedDB stores.
339+
/// * `key` - Key with which to encrypt the key which is used to encrypt the
340+
/// store. Must be the same each time the store is opened.
341+
pub async fn open_with_key(prefix: &str, key: &[u8; 32]) -> Result<Self> {
342+
// The application might also use the provided key for something else, so to
343+
// avoid key reuse, we pass the provided key through an HKDF
344+
let mut chacha_key = zeroize::Zeroizing::new([0u8; 32]);
345+
const HKDF_INFO: &[u8] = b"CRYPTOSTORE_CIPHER";
346+
let hkdf = Hkdf::<Sha256>::new(None, key);
347+
hkdf.expand(HKDF_INFO, &mut *chacha_key)
348+
.expect("We should be able to generate a 32-byte key");
349+
350+
let db = open_meta_db(prefix).await?;
351+
let store_cipher = load_store_cipher(&db).await?;
352+
353+
let store_cipher = match store_cipher {
354+
Some(cipher) => {
355+
debug!("IndexedDbCryptoStore: decrypting store cipher");
356+
import_store_cipher_with_key(&chacha_key, key, &cipher, &db).await?
357+
}
358+
None => {
359+
debug!("IndexedDbCryptoStore: encrypting new store cipher");
360+
let cipher = StoreCipher::new().map_err(CryptoStoreError::backend)?;
361+
let export =
362+
cipher.export_with_key(&chacha_key).map_err(CryptoStoreError::backend)?;
363+
save_store_cipher(&db, &export).await?;
329364
cipher
330365
}
331366
};
@@ -1289,6 +1324,114 @@ impl Drop for IndexeddbCryptoStore {
12891324
}
12901325
}
12911326

1327+
/// Open the meta store.
1328+
///
1329+
/// The meta store contains details about the encryption of the main store.
1330+
async fn open_meta_db(prefix: &str) -> Result<IdbDatabase, IndexeddbCryptoStoreError> {
1331+
let name = format!("{prefix:0}::matrix-sdk-crypto-meta");
1332+
1333+
debug!("IndexedDbCryptoStore: Opening meta-store {name}");
1334+
let mut db_req: OpenDbRequest = IdbDatabase::open_u32(&name, 1)?;
1335+
db_req.set_on_upgrade_needed(Some(|evt: &IdbVersionChangeEvent| -> Result<(), JsValue> {
1336+
let old_version = evt.old_version() as u32;
1337+
if old_version < 1 {
1338+
// migrating to version 1
1339+
let db = evt.db();
1340+
1341+
db.create_object_store("matrix-sdk-crypto")?;
1342+
}
1343+
Ok(())
1344+
}));
1345+
1346+
Ok(db_req.await?)
1347+
}
1348+
1349+
/// Load the serialised store cipher from the meta store.
1350+
///
1351+
/// # Arguments:
1352+
///
1353+
/// * `meta_db`: Connection to the meta store, as returned by [`open_meta_db`].
1354+
///
1355+
/// # Returns:
1356+
///
1357+
/// The serialised `StoreCipher` object.
1358+
async fn load_store_cipher(
1359+
meta_db: &IdbDatabase,
1360+
) -> Result<Option<Vec<u8>>, IndexeddbCryptoStoreError> {
1361+
let tx: IdbTransaction<'_> =
1362+
meta_db.transaction_on_one_with_mode("matrix-sdk-crypto", IdbTransactionMode::Readonly)?;
1363+
let ob = tx.object_store("matrix-sdk-crypto")?;
1364+
1365+
let store_cipher: Option<Vec<u8>> = ob
1366+
.get(&JsValue::from_str(keys::STORE_CIPHER))?
1367+
.await?
1368+
.map(|k| k.into_serde())
1369+
.transpose()?;
1370+
Ok(store_cipher)
1371+
}
1372+
1373+
/// Save the serialised store cipher to the meta store.
1374+
///
1375+
/// # Arguments:
1376+
///
1377+
/// * `meta_db`: Connection to the meta store, as returned by [`open_meta_db`].
1378+
/// * `store_cipher`: The serialised `StoreCipher` object.
1379+
async fn save_store_cipher(
1380+
db: &IdbDatabase,
1381+
export: &Vec<u8>,
1382+
) -> Result<(), IndexeddbCryptoStoreError> {
1383+
let tx: IdbTransaction<'_> =
1384+
db.transaction_on_one_with_mode("matrix-sdk-crypto", IdbTransactionMode::Readwrite)?;
1385+
let ob = tx.object_store("matrix-sdk-crypto")?;
1386+
1387+
ob.put_key_val(&JsValue::from_str(keys::STORE_CIPHER), &JsValue::from_serde(&export)?)?;
1388+
tx.await.into_result()?;
1389+
Ok(())
1390+
}
1391+
1392+
/// Given a serialised store cipher, try importing with the given key.
1393+
///
1394+
/// This is a helper for [`IndexeddbCryptoStore::open_with_key`].
1395+
///
1396+
/// # Arguments
1397+
///
1398+
/// * `chacha_key`: The key to use with [`StoreCipher::import_with_key`].
1399+
/// Derived from `original_key` via an HKDF.
1400+
/// * `original_key`: The key provided by the application. Used to provide a
1401+
/// migration path from an older key derivation system.
1402+
/// * `serialised_cipher`: The serialized `EncryptedStoreCipher`, retrieved from
1403+
/// the database.
1404+
/// * `db`: Connection to the database.
1405+
async fn import_store_cipher_with_key(
1406+
chacha_key: &[u8; 32],
1407+
original_key: &[u8],
1408+
serialised_cipher: &[u8],
1409+
db: &IdbDatabase,
1410+
) -> Result<StoreCipher, IndexeddbCryptoStoreError> {
1411+
let cipher = match StoreCipher::import_with_key(chacha_key, serialised_cipher) {
1412+
Ok(cipher) => cipher,
1413+
Err(matrix_sdk_store_encryption::Error::KdfMismatch) => {
1414+
// Old versions of the matrix-js-sdk used to base64-encode their encryption
1415+
// key, and pass it into [`IndexeddbCryptoStore::open_with_passphrase`]. For
1416+
// backwards compatibility, we fall back to that if we discover we have a cipher
1417+
// encrypted with a KDF when we expected it to be encrypted directly with a key.
1418+
let cipher = StoreCipher::import(&base64_encode(original_key), serialised_cipher)
1419+
.map_err(|_| CryptoStoreError::UnpicklingError)?;
1420+
1421+
// Loading the cipher with the passphrase was successful. Let's update the
1422+
// stored version of the cipher so that it is encrypted with a key,
1423+
// to save doing this again.
1424+
debug!("IndexedDbCryptoStore: Migrating passphrase-encrypted store cipher to key-encryption");
1425+
1426+
let export = cipher.export_with_key(chacha_key).map_err(CryptoStoreError::backend)?;
1427+
save_store_cipher(db, &export).await?;
1428+
cipher
1429+
}
1430+
Err(_) => Err(CryptoStoreError::UnpicklingError)?,
1431+
};
1432+
Ok(cipher)
1433+
}
1434+
12921435
/// Fetch items from an object store in batches, transform each item using
12931436
/// the supplied function, and stuff the transformed items into a single
12941437
/// vector to return.
@@ -1546,7 +1689,14 @@ mod tests {
15461689

15471690
#[cfg(all(test, target_arch = "wasm32"))]
15481691
mod encrypted_tests {
1549-
use matrix_sdk_crypto::cryptostore_integration_tests;
1692+
use matrix_sdk_crypto::{
1693+
cryptostore_integration_tests,
1694+
olm::Account,
1695+
store::{CryptoStore, PendingChanges},
1696+
vodozemac::base64_encode,
1697+
};
1698+
use matrix_sdk_test::async_test;
1699+
use ruma::{device_id, user_id};
15501700

15511701
use super::IndexeddbCryptoStore;
15521702

@@ -1561,4 +1711,36 @@ mod encrypted_tests {
15611711
.expect("Can't create a passphrase protected store")
15621712
}
15631713
cryptostore_integration_tests!();
1714+
1715+
/// Test that we can migrate a store created with a passphrase, to being
1716+
/// encrypted with a key instead.
1717+
#[async_test]
1718+
async fn migrate_passphrase_to_key() {
1719+
let store_name = "test_migrate_passphrase_to_key";
1720+
let passdata: [u8; 32] = rand::random();
1721+
let b64_passdata = base64_encode(passdata);
1722+
1723+
// Initialise the store with some account data
1724+
let store = IndexeddbCryptoStore::open_with_passphrase(&store_name, &b64_passdata)
1725+
.await
1726+
.expect("Can't create a passphrase-protected store");
1727+
1728+
store
1729+
.save_pending_changes(PendingChanges {
1730+
account: Some(Account::with_device_id(
1731+
user_id!("@alice:example.org"),
1732+
device_id!("ALICEDEVICE"),
1733+
)),
1734+
})
1735+
.await
1736+
.expect("Can't save account");
1737+
1738+
// Now reopen the store, passing the key directly rather than as a b64 string.
1739+
let store = IndexeddbCryptoStore::open_with_key(&store_name, &passdata)
1740+
.await
1741+
.expect("Can't create a key-protected store");
1742+
let loaded_account =
1743+
store.load_account().await.expect("Can't load account").expect("Account was not saved");
1744+
assert_eq!(loaded_account.user_id, user_id!("@alice:example.org"));
1745+
}
15641746
}

0 commit comments

Comments
 (0)