Skip to content

Commit

Permalink
Replace matrix-sdk-store-encryption by our own store cipher (whisperf…
Browse files Browse the repository at this point in the history
…ish#190)

* Replace matrix-sdk-store-encryption by our own store cipher

Even though, the implementation of `presage_store_cipher::StoreCipher`
is almost verbatim along the lines in `matrix-sdk-store-encryption`, our
custom implementation allows us to decouple from dependencies of matrix
and their release process.

Also fix `encryption` feature flags: now it is possible to use
presage-store-sled without encryption.

* Panic when passphrase is set but encryption feature is not
* Build without default features in CI as well

---------

Co-authored-by: Gabriel Féron <[email protected]>
  • Loading branch information
boxdot and gferon authored Sep 23, 2023
1 parent 8abd8ca commit 1e84c13
Show file tree
Hide file tree
Showing 7 changed files with 444 additions and 46 deletions.
9 changes: 6 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ concurrency:

jobs:
build_and_test:
name: cargo build
strategy:
matrix:
cargo_flags: ["--all-features", "--no-default-features"]
name: cargo ${{ matrix.cargo_flags }}
runs-on: ubuntu-latest
env:
RUSTFLAGS: -D warnings
Expand All @@ -36,13 +39,13 @@ jobs:
uses: actions-rs/cargo@v1
with:
command: build
args: --all-targets
args: --all-targets ${{ matrix.cargo_flags }}

- name: Run tests
uses: actions-rs/cargo@v1
with:
command: test
args: --all-targets
args: --all-targets ${{ matrix.cargo_flags }}

rustfmt:
name: rustfmt
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = ["presage", "presage-cli", "presage-store-sled"]
members = ["presage", "presage-cli", "presage-store-sled", "presage-store-cipher"]
resolver = "2"

[patch.crates-io]
Expand Down
16 changes: 16 additions & 0 deletions presage-store-cipher/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[package]
name = "presage-store-cipher"
version = "0.1.0"
edition = "2021"

[dependencies]
blake3 = "1.5.0"
chacha20poly1305 = { version = "0.10.1", features = ["std"] }
hmac = "0.12.1"
pbkdf2 = "0.12.2"
rand = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.10"
thiserror = "1.0"
zeroize = { version = "1.6.0", features = ["derive"] }
303 changes: 303 additions & 0 deletions presage-store-cipher/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
// Based on `matrix-sdk-store-encryption` (License Apache-2.0)

use blake3::{derive_key, Hash};
use chacha20poly1305::aead::Aead;
use chacha20poly1305::{AeadCore, KeyInit, XChaCha20Poly1305, XNonce};
use hmac::Hmac;
use pbkdf2::pbkdf2;
use rand::{thread_rng, RngCore};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::Sha256;

use zeroize::{Zeroize, Zeroizing};

const VERSION: u8 = 1;
const KDF_SALT_SIZE: usize = 32;
const XNONCE_SIZE: usize = 24;
const KDF_ROUNDS: u32 = 200_000;

/// Hashes keys and encrypts/decrypts values
///
/// Allows to encrypt/decrypt data in a key/value store. Can be exported as bytes encrypted by a
/// passphrase, and imported back from bytes.
#[derive(Zeroize)]
#[zeroize(drop)]
pub struct StoreCipher {
encryption_key: Box<[u8; 32]>,
mac_key_seed: Box<[u8; 32]>,
}

impl StoreCipher {
pub fn new() -> Self {
let mut rng = thread_rng();
let mut store_cipher = Self::zero();
rng.fill_bytes(store_cipher.encryption_key.as_mut_slice());
rng.fill_bytes(store_cipher.mac_key_seed.as_mut_slice());
store_cipher
}

pub fn export(&self, passphrase: &str) -> Result<Vec<u8>, StoreCipherError> {
self.export_inner(passphrase, KDF_ROUNDS)
}

pub fn insecure_export_fast_for_testing(
&self,
passphrase: &str,
) -> Result<Vec<u8>, StoreCipherError> {
self.export_inner(passphrase, 1000)
}

pub(crate) fn export_inner(
&self,
passphrase: &str,
rounds: u32,
) -> Result<Vec<u8>, StoreCipherError> {
let mut rng = thread_rng();
let mut salt = [0u8; KDF_SALT_SIZE];
rng.fill_bytes(&mut salt);

let key = StoreCipher::expand_key(passphrase, &salt, rounds);
let key = chacha20poly1305::Key::from(key);
let cipher = XChaCha20Poly1305::new(&key);

let nonce = XChaCha20Poly1305::generate_nonce(rng);

let mut keys = Zeroizing::new([0u8; 64]);
keys[0..32].copy_from_slice(&*self.encryption_key);
keys[32..64].copy_from_slice(&*self.mac_key_seed);

let ciphertext = cipher.encrypt(&nonce, keys.as_slice())?;

let store_cipher = EncryptedStoreCipher {
kdf_info: KdfInfo::Pbkdf2ToChaCha20Poly1305 { rounds, salt },
ciphertext_info: CipherTextInfo::ChaCha20Poly1305 {
nonce: nonce.as_slice().try_into().expect("invalid array len"),
ciphertext,
},
};
Ok(serde_json::to_vec(&store_cipher)?)
}

pub fn import(passphrase: &str, encrypted: &[u8]) -> Result<Self, StoreCipherError> {
let encrypted: EncryptedStoreCipher = serde_json::from_slice(encrypted)?;
let key = match encrypted.kdf_info {
KdfInfo::Pbkdf2ToChaCha20Poly1305 {
rounds,
salt: kdf_salt,
} => Self::expand_key(passphrase, &kdf_salt, rounds),
};

let key = chacha20poly1305::Key::from(key);

let decrypted = match encrypted.ciphertext_info {
CipherTextInfo::ChaCha20Poly1305 { nonce, ciphertext } => {
let cipher = XChaCha20Poly1305::new(&key);
let nonce = XNonce::from_slice(&nonce);
Zeroizing::new(cipher.decrypt(nonce, &*ciphertext)?)
}
};

if decrypted.len() != 64 {
return Err(StoreCipherError::Length(64, decrypted.len()));
}

let mut store_cipher = Self::zero();
store_cipher
.encryption_key
.copy_from_slice(&decrypted[0..32]);
store_cipher
.mac_key_seed
.copy_from_slice(&decrypted[32..64]);
Ok(store_cipher)
}

fn expand_key(passphrase: &str, salt: &[u8], rounds: u32) -> [u8; 32] {
let mut key = [0u8; 32];
pbkdf2::<Hmac<Sha256>>(passphrase.as_bytes(), salt, rounds, &mut key)
.expect("invalid length");
key
}

pub fn encrypt_value(&self, value: &impl Serialize) -> Result<Vec<u8>, StoreCipherError> {
Ok(serde_json::to_vec(&self.encrypt_value_typed(value)?)?)
}

fn encrypt_value_typed(
&self,
value: &impl Serialize,
) -> Result<EncryptedValue, StoreCipherError> {
let data = serde_json::to_vec(value)?;
self.encrypt_value_data(data)
}

fn encrypt_value_data(&self, mut data: Vec<u8>) -> Result<EncryptedValue, StoreCipherError> {
let nonce = XChaCha20Poly1305::generate_nonce(thread_rng());
let cipher = XChaCha20Poly1305::new(self.encryption_key());

let ciphertext = cipher.encrypt(&nonce, &*data)?;

data.zeroize();
Ok(EncryptedValue {
version: VERSION,
ciphertext,
nonce: nonce.as_slice().try_into().expect("invalid array len"),
})
}

pub fn decrypt_value<T: DeserializeOwned>(&self, value: &[u8]) -> Result<T, StoreCipherError> {
let value: EncryptedValue = serde_json::from_slice(value)?;
self.decrypt_value_typed(value)
}

fn decrypt_value_typed<T: DeserializeOwned>(
&self,
value: EncryptedValue,
) -> Result<T, StoreCipherError> {
let mut plaintext = self.decrypt_value_data(value)?;
let ret = serde_json::from_slice(&plaintext);
plaintext.zeroize();
Ok(ret?)
}

fn decrypt_value_data(&self, value: EncryptedValue) -> Result<Vec<u8>, StoreCipherError> {
if value.version != VERSION {
return Err(StoreCipherError::Version(VERSION, value.version));
}

let cipher = XChaCha20Poly1305::new(self.encryption_key());
let nonce = XNonce::from_slice(&value.nonce);
Ok(cipher.decrypt(nonce, &*value.ciphertext)?)
}

pub fn hash_key(&self, table_name: &str, key: &[u8]) -> [u8; 32] {
let mac_key = self.get_mac_key_for_table(table_name);
mac_key.mac(key).into()
}

fn get_mac_key_for_table(&self, table_name: &str) -> MacKey {
let mut key = MacKey(Box::new([0u8; 32]));
let output = Zeroizing::new(derive_key(table_name, &*self.mac_key_seed));
key.0.copy_from_slice(&*output);
key
}

fn encryption_key(&self) -> &chacha20poly1305::Key {
chacha20poly1305::Key::from_slice(&*self.encryption_key)
}

fn zero() -> StoreCipher {
Self {
encryption_key: Box::new([0; 32]),
mac_key_seed: Box::new([0; 32]),
}
}
}

#[derive(Zeroize)]
#[zeroize(drop)]
struct MacKey(Box<[u8; 32]>);

impl MacKey {
fn mac(&self, input: &[u8]) -> Hash {
blake3::keyed_hash(&self.0, input)
}
}

impl Default for StoreCipher {
fn default() -> Self {
Self::new()
}
}

#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct EncryptedValue {
version: u8,
ciphertext: Vec<u8>,
nonce: [u8; XNONCE_SIZE],
}

#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
enum KdfInfo {
Pbkdf2ToChaCha20Poly1305 {
rounds: u32,
salt: [u8; KDF_SALT_SIZE],
},
}

#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
enum CipherTextInfo {
ChaCha20Poly1305 {
nonce: [u8; XNONCE_SIZE],
ciphertext: Vec<u8>,
},
}

#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct EncryptedStoreCipher {
pub kdf_info: KdfInfo,
pub ciphertext_info: CipherTextInfo,
}

#[derive(Debug, thiserror::Error)]
pub enum StoreCipherError {
#[error(transparent)]
Serde(#[from] serde_json::Error),
#[error("unsupported data version, expected {0}, got {1}")]
Version(u8, u8),
#[error(transparent)]
Encryption(#[from] chacha20poly1305::aead::Error),
#[error("invalid ciphertext length, expected {0}, got {1}")]
Length(usize, usize),
}

#[cfg(test)]
mod tests {
use serde_json::{json, Value};

use super::*;

#[test]
fn test_export_import() -> Result<(), StoreCipherError> {
let passphrase = "The first rule of Fight Club is: you do not talk about Fight Club.";
let store_cipher = StoreCipher::new();

let value = json!({"name": "Tyler Durden"});
let encrypted_value = store_cipher.encrypt_value(&value)?;

let encrypted = store_cipher.insecure_export_fast_for_testing(passphrase)?;
let decrypted = StoreCipher::import(passphrase, &encrypted)?;

assert_eq!(store_cipher.encryption_key, decrypted.encryption_key);

let decrypted_value: Value = decrypted.decrypt_value(&encrypted_value)?;
assert_eq!(value, decrypted_value);

Ok(())
}

#[test]
fn test_encrypt_decrypt() -> Result<(), StoreCipherError> {
let store_cipher = StoreCipher::new();

let value = json!({"name": "Tyler Durden"});
let encrypted_value = store_cipher.encrypt_value(&value)?;
let decrypted_value: Value = store_cipher.decrypt_value(&encrypted_value)?;
assert_eq!(value, decrypted_value);

Ok(())
}

#[test]
fn test_hash_key() {
let store_cipher = StoreCipher::new();
let k1 = store_cipher.hash_key("movie", b"Fight Club");
let k2 = store_cipher.hash_key("movie", b"Fight Club");
assert_eq!(k1, k2);
let k3 = store_cipher.hash_key("movie", b"Fifth Element");
assert_ne!(k1, k3);
let k4 = store_cipher.hash_key("film", b"Fight Club");
assert_ne!(k1, k4);
assert_ne!(k3, k4);
}
}
5 changes: 3 additions & 2 deletions presage-store-sled/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ prost-build = "0.10"

[dependencies]
presage = { path = "../presage" }
presage-store-cipher = { path = "../presage-store-cipher", optional = true }

async-trait = "0.1"
base64 = "0.12"
Expand All @@ -17,10 +18,10 @@ log = "0.4.8"
sled = { version = "0.34" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
matrix-sdk-store-encryption = { version = "0.2.0", optional = true }
thiserror = "1.0"
prost = "0.10"
sha2 = "0.10"
quickcheck_macros = "1.0.0"

[dev-dependencies]
anyhow = "1.0"
Expand All @@ -32,4 +33,4 @@ tokio = { version = "1.0", default-features = false, features = ["time"] }

[features]
default = ["encryption"]
encryption = ["dep:matrix-sdk-store-encryption"]
encryption = ["dep:presage-store-cipher"]
3 changes: 2 additions & 1 deletion presage-store-sled/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ pub enum SledStoreError {
Db(#[from] sled::Error),
#[error("data store error: {0}")]
DbTransaction(#[from] sled::transaction::TransactionError),
#[cfg(feature = "encryption")]
#[error("store cipher error: {0}")]
StoreCipher(#[from] matrix_sdk_store_encryption::Error),
StoreCipher(#[from] presage_store_cipher::StoreCipherError),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Prost error: {0}")]
Expand Down
Loading

0 comments on commit 1e84c13

Please sign in to comment.