Skip to content

Commit

Permalink
indexeddb: expose new method IndexeddbCryptoStore::open_with_key (#…
Browse files Browse the repository at this point in the history
…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]>
  • Loading branch information
richvdh and poljar authored May 22, 2024
1 parent 794b11a commit d7a8877
Show file tree
Hide file tree
Showing 4 changed files with 233 additions and 41 deletions.
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions crates/matrix-sdk-indexeddb/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# unreleased
# UNRELEASED

- Add new method `IndexeddbCryptoStore::open_with_key`. ([#3423](https://github.com/matrix-org/matrix-rust-sdk/pull/3423))

- `save_change` performance improvement, all encryption and serialization
is done now outside of the db transaction.
is done now outside of the db transaction.
4 changes: 4 additions & 0 deletions crates/matrix-sdk-indexeddb/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ tokio = { workspace = true }
tracing = { workspace = true }
wasm-bindgen = "0.2.83"
web-sys = { version = "0.3.57", features = ["IdbKeyRange"] }
hkdf = "0.12.4"
zeroize = { workspace = true }
sha2 = { workspace = true }

[target.'cfg(target_arch = "wasm32")'.dependencies]
# for wasm32 we need to activate this
Expand All @@ -50,6 +53,7 @@ matrix-sdk-base = { workspace = true, features = ["testing"] }
matrix-sdk-common = { workspace = true, features = ["js"] }
matrix-sdk-crypto = { workspace = true, features = ["js", "testing"] }
matrix-sdk-test = { workspace = true }
rand = { workspace = true }
tracing-subscriber = { version = "0.3.18", default-features = false, features = ["registry", "tracing-log"] }
uuid = "1.3.0"
wasm-bindgen-test = "0.3.33"
Expand Down
260 changes: 221 additions & 39 deletions crates/matrix-sdk-indexeddb/src/crypto_store/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use std::{

use async_trait::async_trait;
use gloo_utils::format::JsValueSerdeExt;
use hkdf::Hkdf;
use indexed_db_futures::prelude::*;
use matrix_sdk_crypto::{
olm::{
Expand All @@ -30,6 +31,7 @@ use matrix_sdk_crypto::{
RoomKeyCounts, RoomSettings,
},
types::events::room_key_withheld::RoomKeyWithheldEvent,
vodozemac::base64_encode,
Account, GossipRequest, GossippedSecret, ReadOnlyDevice, ReadOnlyUserIdentities, SecretInfo,
TrackedUser,
};
Expand All @@ -38,6 +40,7 @@ use ruma::{
events::secret::request::SecretName, DeviceId, MilliSecondsSinceUnixEpoch, OwnedDeviceId,
RoomId, TransactionId, UserId,
};
use sha2::Sha256;
use tokio::sync::Mutex;
use tracing::{debug, warn};
use wasm_bindgen::JsValue;
Expand Down Expand Up @@ -272,34 +275,25 @@ impl IndexeddbCryptoStore {
IndexeddbCryptoStore::open_with_store_cipher("crypto", None).await
}

/// Open a new `IndexeddbCryptoStore` with given name and passphrase
/// Open an `IndexeddbCryptoStore` with given name and passphrase.
///
/// If the store previously existed, the encryption cipher is initialised
/// using the given passphrase and the details from the meta store. If the
/// store did not previously exist, a new encryption cipher is derived
/// from the passphrase, and the details are stored to the metastore.
///
/// The store is then opened, or a new one created, using the encryption
/// cipher.
///
/// # Arguments
///
/// * `prefix` - Common prefix for the names of the two IndexedDB stores.
/// * `passphrase` - Passphrase which is used to derive a key to encrypt the
/// key which is used to encrypt the store. Must be the same each time the
/// store is opened.
pub async fn open_with_passphrase(prefix: &str, passphrase: &str) -> Result<Self> {
let name = format!("{prefix:0}::matrix-sdk-crypto-meta");

debug!("IndexedDbCryptoStore: Opening meta-store {name}");
let mut db_req: OpenDbRequest = IdbDatabase::open_u32(&name, 1)?;
db_req.set_on_upgrade_needed(Some(|evt: &IdbVersionChangeEvent| -> Result<(), JsValue> {
let old_version = evt.old_version() as u32;
if old_version < 1 {
// migrating to version 1
let db = evt.db();

db.create_object_store("matrix-sdk-crypto")?;
}
Ok(())
}));

let db: IdbDatabase = db_req.await?;

let tx: IdbTransaction<'_> =
db.transaction_on_one_with_mode("matrix-sdk-crypto", IdbTransactionMode::Readonly)?;
let ob = tx.object_store("matrix-sdk-crypto")?;

let store_cipher: Option<Vec<u8>> = ob
.get(&JsValue::from_str(keys::STORE_CIPHER))?
.await?
.map(|k| k.into_serde())
.transpose()?;
let db = open_meta_db(prefix).await?;
let store_cipher = load_store_cipher(&db).await?;

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

let tx: IdbTransaction<'_> = db.transaction_on_one_with_mode(
"matrix-sdk-crypto",
IdbTransactionMode::Readwrite,
)?;
let ob = tx.object_store("matrix-sdk-crypto")?;

ob.put_key_val(
&JsValue::from_str(keys::STORE_CIPHER),
&JsValue::from_serde(&export.map_err(CryptoStoreError::backend)?)?,
)?;
tx.await.into_result()?;
let export = export.map_err(CryptoStoreError::backend)?;

save_store_cipher(&db, &export).await?;
cipher
}
};

// Must release the database access manually as it's not done when
// dropping it.
db.close();

IndexeddbCryptoStore::open_with_store_cipher(prefix, Some(store_cipher.into())).await
}

/// Open an `IndexeddbCryptoStore` with given name and key.
///
/// If the store previously existed, the encryption cipher is initialised
/// using the given key and the details from the meta store. If the store
/// did not previously exist, a new encryption cipher is derived from
/// the passphrase, and the details are stored to the metastore.
///
/// The store is then opened, or a new one created, using the encryption
/// cipher.
///
/// # Arguments
///
/// * `prefix` - Common prefix for the names of the two IndexedDB stores.
/// * `key` - Key with which to encrypt the key which is used to encrypt the
/// store. Must be the same each time the store is opened.
pub async fn open_with_key(prefix: &str, key: &[u8; 32]) -> Result<Self> {
// The application might also use the provided key for something else, so to
// avoid key reuse, we pass the provided key through an HKDF
let mut chacha_key = zeroize::Zeroizing::new([0u8; 32]);
const HKDF_INFO: &[u8] = b"CRYPTOSTORE_CIPHER";
let hkdf = Hkdf::<Sha256>::new(None, key);
hkdf.expand(HKDF_INFO, &mut *chacha_key)
.expect("We should be able to generate a 32-byte key");

let db = open_meta_db(prefix).await?;
let store_cipher = load_store_cipher(&db).await?;

let store_cipher = match store_cipher {
Some(cipher) => {
debug!("IndexedDbCryptoStore: decrypting store cipher");
import_store_cipher_with_key(&chacha_key, key, &cipher, &db).await?
}
None => {
debug!("IndexedDbCryptoStore: encrypting new store cipher");
let cipher = StoreCipher::new().map_err(CryptoStoreError::backend)?;
let export =
cipher.export_with_key(&chacha_key).map_err(CryptoStoreError::backend)?;
save_store_cipher(&db, &export).await?;
cipher
}
};
Expand Down Expand Up @@ -1289,6 +1324,114 @@ impl Drop for IndexeddbCryptoStore {
}
}

/// Open the meta store.
///
/// The meta store contains details about the encryption of the main store.
async fn open_meta_db(prefix: &str) -> Result<IdbDatabase, IndexeddbCryptoStoreError> {
let name = format!("{prefix:0}::matrix-sdk-crypto-meta");

debug!("IndexedDbCryptoStore: Opening meta-store {name}");
let mut db_req: OpenDbRequest = IdbDatabase::open_u32(&name, 1)?;
db_req.set_on_upgrade_needed(Some(|evt: &IdbVersionChangeEvent| -> Result<(), JsValue> {
let old_version = evt.old_version() as u32;
if old_version < 1 {
// migrating to version 1
let db = evt.db();

db.create_object_store("matrix-sdk-crypto")?;
}
Ok(())
}));

Ok(db_req.await?)
}

/// Load the serialised store cipher from the meta store.
///
/// # Arguments:
///
/// * `meta_db`: Connection to the meta store, as returned by [`open_meta_db`].
///
/// # Returns:
///
/// The serialised `StoreCipher` object.
async fn load_store_cipher(
meta_db: &IdbDatabase,
) -> Result<Option<Vec<u8>>, IndexeddbCryptoStoreError> {
let tx: IdbTransaction<'_> =
meta_db.transaction_on_one_with_mode("matrix-sdk-crypto", IdbTransactionMode::Readonly)?;
let ob = tx.object_store("matrix-sdk-crypto")?;

let store_cipher: Option<Vec<u8>> = ob
.get(&JsValue::from_str(keys::STORE_CIPHER))?
.await?
.map(|k| k.into_serde())
.transpose()?;
Ok(store_cipher)
}

/// Save the serialised store cipher to the meta store.
///
/// # Arguments:
///
/// * `meta_db`: Connection to the meta store, as returned by [`open_meta_db`].
/// * `store_cipher`: The serialised `StoreCipher` object.
async fn save_store_cipher(
db: &IdbDatabase,
export: &Vec<u8>,
) -> Result<(), IndexeddbCryptoStoreError> {
let tx: IdbTransaction<'_> =
db.transaction_on_one_with_mode("matrix-sdk-crypto", IdbTransactionMode::Readwrite)?;
let ob = tx.object_store("matrix-sdk-crypto")?;

ob.put_key_val(&JsValue::from_str(keys::STORE_CIPHER), &JsValue::from_serde(&export)?)?;
tx.await.into_result()?;
Ok(())
}

/// Given a serialised store cipher, try importing with the given key.
///
/// This is a helper for [`IndexeddbCryptoStore::open_with_key`].
///
/// # Arguments
///
/// * `chacha_key`: The key to use with [`StoreCipher::import_with_key`].
/// Derived from `original_key` via an HKDF.
/// * `original_key`: The key provided by the application. Used to provide a
/// migration path from an older key derivation system.
/// * `serialised_cipher`: The serialized `EncryptedStoreCipher`, retrieved from
/// the database.
/// * `db`: Connection to the database.
async fn import_store_cipher_with_key(
chacha_key: &[u8; 32],
original_key: &[u8],
serialised_cipher: &[u8],
db: &IdbDatabase,
) -> Result<StoreCipher, IndexeddbCryptoStoreError> {
let cipher = match StoreCipher::import_with_key(chacha_key, serialised_cipher) {
Ok(cipher) => cipher,
Err(matrix_sdk_store_encryption::Error::KdfMismatch) => {
// Old versions of the matrix-js-sdk used to base64-encode their encryption
// key, and pass it into [`IndexeddbCryptoStore::open_with_passphrase`]. For
// backwards compatibility, we fall back to that if we discover we have a cipher
// encrypted with a KDF when we expected it to be encrypted directly with a key.
let cipher = StoreCipher::import(&base64_encode(original_key), serialised_cipher)
.map_err(|_| CryptoStoreError::UnpicklingError)?;

// Loading the cipher with the passphrase was successful. Let's update the
// stored version of the cipher so that it is encrypted with a key,
// to save doing this again.
debug!("IndexedDbCryptoStore: Migrating passphrase-encrypted store cipher to key-encryption");

let export = cipher.export_with_key(chacha_key).map_err(CryptoStoreError::backend)?;
save_store_cipher(db, &export).await?;
cipher
}
Err(_) => Err(CryptoStoreError::UnpicklingError)?,
};
Ok(cipher)
}

/// Fetch items from an object store in batches, transform each item using
/// the supplied function, and stuff the transformed items into a single
/// vector to return.
Expand Down Expand Up @@ -1546,7 +1689,14 @@ mod tests {

#[cfg(all(test, target_arch = "wasm32"))]
mod encrypted_tests {
use matrix_sdk_crypto::cryptostore_integration_tests;
use matrix_sdk_crypto::{
cryptostore_integration_tests,
olm::Account,
store::{CryptoStore, PendingChanges},
vodozemac::base64_encode,
};
use matrix_sdk_test::async_test;
use ruma::{device_id, user_id};

use super::IndexeddbCryptoStore;

Expand All @@ -1561,4 +1711,36 @@ mod encrypted_tests {
.expect("Can't create a passphrase protected store")
}
cryptostore_integration_tests!();

/// Test that we can migrate a store created with a passphrase, to being
/// encrypted with a key instead.
#[async_test]
async fn migrate_passphrase_to_key() {
let store_name = "test_migrate_passphrase_to_key";
let passdata: [u8; 32] = rand::random();
let b64_passdata = base64_encode(passdata);

// Initialise the store with some account data
let store = IndexeddbCryptoStore::open_with_passphrase(&store_name, &b64_passdata)
.await
.expect("Can't create a passphrase-protected store");

store
.save_pending_changes(PendingChanges {
account: Some(Account::with_device_id(
user_id!("@alice:example.org"),
device_id!("ALICEDEVICE"),
)),
})
.await
.expect("Can't save account");

// Now reopen the store, passing the key directly rather than as a b64 string.
let store = IndexeddbCryptoStore::open_with_key(&store_name, &passdata)
.await
.expect("Can't create a key-protected store");
let loaded_account =
store.load_account().await.expect("Can't load account").expect("Account was not saved");
assert_eq!(loaded_account.user_id, user_id!("@alice:example.org"));
}
}

0 comments on commit d7a8877

Please sign in to comment.