Skip to content

Commit f11d4de

Browse files
committed
security improvements
1 parent 478692b commit f11d4de

3,754 files changed

Lines changed: 206 additions & 34893 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ Thumbs.db
2323
/wasm-package
2424
/target
2525
/__pycache__
26+
/target

Cargo.lock

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ name = "encrypted_upload_test"
7474
path = "examples/encrypted_upload_test.rs"
7575

7676
[workspace.package]
77-
version = "0.2.25"
77+
version = "0.2.26"
7878
edition = "2021"
7979
license = "MIT OR Apache-2.0"
8080
repository = "https://github.com/functionland/fula-api"

crates/fula-client/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ url = "2.5"
2929
base64 = { workspace = true }
3030
hex = { workspace = true }
3131
mime_guess = "2.0"
32+
tokio = { version = "1.42", default-features = false, features = ["sync"] }
3233

3334
# Platform-specific dependencies
3435
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]

crates/fula-client/src/encryption.rs

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ use fula_crypto::{
1818
rotation::{KeyRotationManager, WrappedKeyInfo},
1919
ChunkedEncoder, ChunkedFileMetadata, should_use_chunked,
2020
};
21-
use std::sync::{Arc, RwLock};
21+
use std::sync::Arc;
2222
use std::collections::HashMap;
23+
use tokio::sync::RwLock;
2324

2425
/// Configuration for client-side encryption
2526
pub struct EncryptionConfig {
@@ -479,7 +480,10 @@ impl EncryptedClient {
479480
.map_err(ClientError::Encryption)?;
480481

481482
// Decrypt data
482-
let nonce_b64 = enc_metadata["nonce"].as_str().unwrap();
483+
let nonce_b64 = enc_metadata["nonce"].as_str()
484+
.ok_or_else(|| ClientError::Encryption(
485+
fula_crypto::CryptoError::Decryption("Missing nonce in encryption metadata".to_string())
486+
))?;
483487
let nonce_bytes = base64::Engine::decode(
484488
&base64::engine::general_purpose::STANDARD,
485489
nonce_b64,
@@ -765,7 +769,7 @@ impl EncryptedClient {
765769
pub async fn load_forest(&self, bucket: &str) -> Result<PrivateForest> {
766770
// Check cache first
767771
{
768-
let cache = self.forest_cache.read().unwrap();
772+
let cache = self.forest_cache.read().await;
769773
if let Some(forest) = cache.get(bucket) {
770774
return Ok(forest.clone());
771775
}
@@ -788,19 +792,19 @@ impl EncryptedClient {
788792

789793
// Cache it
790794
{
791-
let mut cache = self.forest_cache.write().unwrap();
795+
let mut cache = self.forest_cache.write().await;
792796
cache.insert(bucket.to_string(), forest.clone());
793797
}
794-
798+
795799
Ok(forest)
796800
}
797801
Err(_) => {
798802
// No forest exists yet - create empty one
799803
let forest = PrivateForest::new();
800-
804+
801805
// Cache it
802806
{
803-
let mut cache = self.forest_cache.write().unwrap();
807+
let mut cache = self.forest_cache.write().await;
804808
cache.insert(bucket.to_string(), forest.clone());
805809
}
806810

@@ -835,7 +839,7 @@ impl EncryptedClient {
835839

836840
// Update cache
837841
{
838-
let mut cache = self.forest_cache.write().unwrap();
842+
let mut cache = self.forest_cache.write().await;
839843
cache.insert(bucket.to_string(), forest.clone());
840844
}
841845

@@ -966,7 +970,7 @@ impl EncryptedClient {
966970

967971
// Update cache (but don't save to storage yet)
968972
{
969-
let mut cache = self.forest_cache.write().unwrap();
973+
let mut cache = self.forest_cache.write().await;
970974
cache.insert(bucket.to_string(), forest);
971975
}
972976

@@ -1076,7 +1080,7 @@ impl EncryptedClient {
10761080
/// This persists the in-memory forest index to encrypted storage.
10771081
pub async fn flush_forest(&self, bucket: &str) -> Result<()> {
10781082
let forest = {
1079-
let cache = self.forest_cache.read().unwrap();
1083+
let cache = self.forest_cache.read().await;
10801084
cache.get(bucket).cloned()
10811085
};
10821086

@@ -1088,8 +1092,8 @@ impl EncryptedClient {
10881092
}
10891093

10901094
/// Check if there are unsaved forest changes
1091-
pub fn has_pending_forest_changes(&self, bucket: &str) -> bool {
1092-
let cache = self.forest_cache.read().unwrap();
1095+
pub async fn has_pending_forest_changes(&self, bucket: &str) -> bool {
1096+
let cache = self.forest_cache.read().await;
10931097
cache.contains_key(bucket)
10941098
}
10951099

@@ -1717,7 +1721,8 @@ impl EncryptedClient {
17171721
).await?;
17181722

17191723
// Update forest cache if we have one
1720-
if let Ok(mut cache) = self.forest_cache.write() {
1724+
{
1725+
let mut cache = self.forest_cache.write().await;
17211726
if let Some(forest) = cache.get_mut(bucket) {
17221727
let now = chrono::Utc::now().timestamp();
17231728
forest.upsert_file(ForestFileEntry {

crates/fula-crypto/src/hpke.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use crate::{
1515
keys::{DekKey, KekKeyPair, PublicKey, SecretKey},
1616
symmetric::AeadCipher,
1717
};
18+
use zeroize::Zeroizing;
1819
use hpke::{
1920
Deserializable, Kem, Serializable,
2021
aead::ChaCha20Poly1305,
@@ -54,7 +55,12 @@ pub const ENCAPSULATED_KEY_SIZE: usize = 32;
5455
/// HPKE configuration
5556
#[derive(Clone, Debug, Serialize, SerdeDeserialize)]
5657
pub struct HpkeConfig {
57-
/// The AEAD cipher to use
58+
/// The AEAD cipher preference.
59+
///
60+
/// **Note:** This field is currently ignored by the HPKE encryption logic.
61+
/// The actual AEAD algorithm is hardcoded to ChaCha20Poly1305 as part of the
62+
/// RFC 9180 cipher suite (X25519HkdfSha256 + HkdfSha256 + ChaCha20Poly1305).
63+
/// This field is retained for forward compatibility and configuration display.
5864
pub aead: AeadCipher,
5965
/// Key derivation context
6066
pub context: String,
@@ -328,7 +334,7 @@ impl Decryptor {
328334
/// Decrypt a wrapped DEK
329335
/// Uses AAD context "fula:v2:dek-wrap" (must match encryption)
330336
pub fn decrypt_dek(&self, encrypted: &EncryptedData) -> Result<DekKey> {
331-
let bytes = self.decrypt_with_aad(encrypted, b"fula:v2:dek-wrap")?;
337+
let bytes = Zeroizing::new(self.decrypt_with_aad(encrypted, b"fula:v2:dek-wrap")?);
332338
DekKey::from_bytes(&bytes)
333339
}
334340
}

crates/fula-crypto/src/hybrid_kem.rs

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,17 +156,27 @@ pub struct HybridSecretKey {
156156

157157
impl HybridSecretKey {
158158
/// Generate a new random hybrid key pair
159+
///
160+
/// # Panics
161+
/// Panics if the OS random number generator is unavailable.
159162
pub fn generate() -> (Self, HybridPublicKey) {
163+
Self::try_generate().expect("getrandom failed during hybrid key generation")
164+
}
165+
166+
/// Generate a new random hybrid key pair, returning an error if OS entropy is unavailable.
167+
pub fn try_generate() -> Result<(Self, HybridPublicKey)> {
160168
// Generate X25519 keypair using getrandom (WASM compatible)
161169
let mut x25519_bytes = [0u8; 32];
162-
getrandom::getrandom(&mut x25519_bytes).expect("getrandom failed");
170+
getrandom::getrandom(&mut x25519_bytes)
171+
.map_err(|e| CryptoError::KeyGeneration(format!("getrandom failed: {}", e)))?;
163172
let x25519_secret = StaticSecret::from(x25519_bytes);
164173
let x25519_public = X25519Public::from(&x25519_secret);
165174

166175
// Generate ML-KEM-768 keypair using libcrux (FIPS 203)
167176
// libcrux uses explicit 64-byte randomness instead of callback RNG
168177
let mut keygen_randomness = [0u8; 64];
169-
getrandom::getrandom(&mut keygen_randomness).expect("getrandom failed");
178+
getrandom::getrandom(&mut keygen_randomness)
179+
.map_err(|e| CryptoError::KeyGeneration(format!("getrandom failed: {}", e)))?;
170180
let mlkem_keypair = mlkem_generate_key_pair(keygen_randomness);
171181

172182
// Extract raw bytes from libcrux types
@@ -185,7 +195,7 @@ impl HybridSecretKey {
185195
mlkem: mlkem_pk,
186196
};
187197

188-
(secret, public)
198+
Ok((secret, public))
189199
}
190200

191201
/// Create from raw bytes
@@ -350,6 +360,10 @@ impl std::fmt::Debug for HybridEncapsulatedKey {
350360
/// 3. HKDF-SHA256 to derive the final shared secret
351361
///
352362
/// Returns the encapsulated key (to send to recipient) and the shared secret
363+
///
364+
/// # Panics
365+
/// Panics if the OS random number generator is unavailable.
366+
/// Use [`try_encapsulate()`] for a fallible alternative.
353367
pub fn encapsulate(recipient_public: &HybridPublicKey) -> Result<(HybridEncapsulatedKey, [u8; SHARED_SECRET_SIZE])> {
354368
// X25519: Generate ephemeral keypair using getrandom (WASM compatible)
355369
let mut ephemeral_bytes = [0u8; 32];
@@ -393,6 +407,49 @@ pub fn encapsulate(recipient_public: &HybridPublicKey) -> Result<(HybridEncapsul
393407
Ok((encapsulated, shared_secret))
394408
}
395409

410+
/// Fallible version of [`encapsulate()`] that returns an error instead of panicking
411+
/// if OS entropy is unavailable.
412+
pub fn try_encapsulate(recipient_public: &HybridPublicKey) -> Result<(HybridEncapsulatedKey, [u8; SHARED_SECRET_SIZE])> {
413+
// X25519: Generate ephemeral keypair
414+
let mut ephemeral_bytes = [0u8; 32];
415+
getrandom::getrandom(&mut ephemeral_bytes)
416+
.map_err(|e| CryptoError::KeyGeneration(format!("getrandom failed: {}", e)))?;
417+
let x25519_ephemeral_secret = StaticSecret::from(ephemeral_bytes);
418+
let x25519_ephemeral_public = X25519Public::from(&x25519_ephemeral_secret);
419+
let x25519_recipient_public = X25519Public::from(recipient_public.x25519);
420+
let x25519_shared = x25519_ephemeral_secret.diffie_hellman(&x25519_recipient_public);
421+
422+
// ML-KEM-768: Encapsulate
423+
let mut encaps_randomness = [0u8; 32];
424+
getrandom::getrandom(&mut encaps_randomness)
425+
.map_err(|e| CryptoError::KeyGeneration(format!("getrandom failed: {}", e)))?;
426+
427+
let mlkem_pk = MlKem768PublicKey::from(recipient_public.mlkem);
428+
let (ct, mlkem_shared_secret) = mlkem_encapsulate(&mlkem_pk, encaps_randomness);
429+
430+
let mut mlkem_ciphertext = [0u8; MLKEM_CIPHERTEXT_SIZE];
431+
mlkem_ciphertext.copy_from_slice(ct.as_ref());
432+
433+
// Combine shared secrets using HKDF-SHA256
434+
let mut ikm = Vec::with_capacity(32 + SHARED_SECRET_SIZE);
435+
ikm.extend_from_slice(x25519_shared.as_bytes());
436+
ikm.extend_from_slice(&mlkem_shared_secret);
437+
438+
let hk = Hkdf::<Sha256>::new(None, &ikm);
439+
let mut shared_secret = [0u8; SHARED_SECRET_SIZE];
440+
hk.expand(HKDF_INFO, &mut shared_secret)
441+
.map_err(|e| CryptoError::Encryption(format!("HKDF expansion failed: {:?}", e)))?;
442+
443+
ikm.zeroize();
444+
445+
let encapsulated = HybridEncapsulatedKey {
446+
x25519_ephemeral: *x25519_ephemeral_public.as_bytes(),
447+
mlkem_ciphertext,
448+
};
449+
450+
Ok((encapsulated, shared_secret))
451+
}
452+
396453
/// Decapsulate a shared secret (recipient side)
397454
///
398455
/// This uses the recipient's secret key to recover:

0 commit comments

Comments
 (0)