Skip to content

Commit 965194a

Browse files
committed
added flatname space support
1 parent 2a83342 commit 965194a

10 files changed

Lines changed: 1242 additions & 13 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,9 @@ cargo run --example metadata_privacy
239239
# Metadata fetch only
240240
cargo run --example file_manager_demo
241241

242+
# FlatNamespace (maximum privacy - complete structure hiding)
243+
cargo run --example flat_namespace_demo
244+
242245

243246
```
244247

crates/fula-client/src/encryption.rs

Lines changed: 321 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ use fula_crypto::{
1313
hpke::{Encryptor, Decryptor, EncryptedData},
1414
symmetric::{Aead, Nonce},
1515
private_metadata::{PrivateMetadata, EncryptedPrivateMetadata, KeyObfuscation, obfuscate_key},
16+
private_forest::{PrivateForest, EncryptedForest, ForestFileEntry, derive_index_key},
1617
};
17-
use std::sync::Arc;
18+
use std::sync::{Arc, RwLock};
1819
use std::collections::HashMap;
1920

2021
/// Configuration for client-side encryption
@@ -46,6 +47,23 @@ impl EncryptionConfig {
4647
}
4748
}
4849

50+
/// Create with FlatNamespace mode - RECOMMENDED for maximum privacy
51+
///
52+
/// This mode provides complete structure hiding:
53+
/// - Storage keys look like random CID-style hashes (e.g., `QmX7a8f3...`)
54+
/// - No prefixes or structure hints visible to server
55+
/// - Server cannot determine folder structure or parent/child relationships
56+
/// - File tree is stored in an encrypted PrivateForest index
57+
///
58+
/// Inspired by WNFS (WebNative File System) and Peergos.
59+
pub fn new_flat_namespace() -> Self {
60+
Self {
61+
key_manager: Arc::new(KeyManager::new()),
62+
metadata_privacy: true,
63+
obfuscation_mode: KeyObfuscation::FlatNamespace,
64+
}
65+
}
66+
4967
/// Create from an existing secret key
5068
pub fn from_secret_key(secret: fula_crypto::keys::SecretKey) -> Self {
5169
Self {
@@ -98,13 +116,19 @@ impl Default for EncryptionConfig {
98116
pub struct EncryptedClient {
99117
inner: FulaClient,
100118
encryption: EncryptionConfig,
119+
/// Private forest index for FlatNamespace mode (cached)
120+
forest_cache: RwLock<HashMap<String, PrivateForest>>,
101121
}
102122

103123
impl EncryptedClient {
104124
/// Create a new encrypted client
105125
pub fn new(config: Config, encryption: EncryptionConfig) -> Result<Self> {
106126
let inner = FulaClient::new(config)?;
107-
Ok(Self { inner, encryption })
127+
Ok(Self {
128+
inner,
129+
encryption,
130+
forest_cache: RwLock::new(HashMap::new()),
131+
})
108132
}
109133

110134
/// Get the underlying client
@@ -578,6 +602,11 @@ impl EncryptedClient {
578602
bucket: &str,
579603
prefix: Option<&str>,
580604
) -> Result<DirectoryListing> {
605+
// For FlatNamespace, use the forest directly
606+
if self.encryption.obfuscation_mode == KeyObfuscation::FlatNamespace {
607+
return self.list_directory_from_forest(bucket, prefix).await;
608+
}
609+
581610
let options = prefix.map(|p| ListObjectsOptions {
582611
prefix: Some(p.to_string()),
583612
..Default::default()
@@ -603,6 +632,296 @@ impl EncryptedClient {
603632
directories,
604633
})
605634
}
635+
636+
// ═══════════════════════════════════════════════════════════════════════════
637+
// FLATNAMESPACE / PRIVATE FOREST SUPPORT
638+
// Complete structure hiding - server sees only random CID-like hashes
639+
// ═══════════════════════════════════════════════════════════════════════════
640+
641+
/// Check if FlatNamespace mode is enabled
642+
pub fn is_flat_namespace(&self) -> bool {
643+
self.encryption.obfuscation_mode == KeyObfuscation::FlatNamespace
644+
}
645+
646+
/// Load the private forest index for a bucket
647+
///
648+
/// The forest contains the encrypted directory structure and path→storage_key mapping.
649+
/// This is only used in FlatNamespace mode.
650+
pub async fn load_forest(&self, bucket: &str) -> Result<PrivateForest> {
651+
// Check cache first
652+
{
653+
let cache = self.forest_cache.read().unwrap();
654+
if let Some(forest) = cache.get(bucket) {
655+
return Ok(forest.clone());
656+
}
657+
}
658+
659+
// Derive the index key deterministically
660+
let path_dek = self.encryption.key_manager.generate_dek();
661+
let index_key = derive_index_key(&path_dek, bucket);
662+
663+
// Try to load from storage
664+
match self.inner.get_object_with_metadata(bucket, &index_key).await {
665+
Ok(result) => {
666+
// Decrypt the forest
667+
let encrypted = EncryptedForest::from_bytes(&result.data)
668+
.map_err(ClientError::Encryption)?;
669+
let forest = encrypted.decrypt(&path_dek)
670+
.map_err(ClientError::Encryption)?;
671+
672+
// Cache it
673+
{
674+
let mut cache = self.forest_cache.write().unwrap();
675+
cache.insert(bucket.to_string(), forest.clone());
676+
}
677+
678+
Ok(forest)
679+
}
680+
Err(_) => {
681+
// No forest exists yet - create empty one
682+
let forest = PrivateForest::new();
683+
684+
// Cache it
685+
{
686+
let mut cache = self.forest_cache.write().unwrap();
687+
cache.insert(bucket.to_string(), forest.clone());
688+
}
689+
690+
Ok(forest)
691+
}
692+
}
693+
}
694+
695+
/// Save the private forest index for a bucket
696+
pub async fn save_forest(&self, bucket: &str, forest: &PrivateForest) -> Result<()> {
697+
let path_dek = self.encryption.key_manager.generate_dek();
698+
let index_key = derive_index_key(&path_dek, bucket);
699+
700+
// Encrypt the forest
701+
let encrypted = EncryptedForest::encrypt(forest, &path_dek)
702+
.map_err(ClientError::Encryption)?;
703+
let data = encrypted.to_bytes()
704+
.map_err(ClientError::Encryption)?;
705+
706+
// Upload (the index looks like any other encrypted blob)
707+
let metadata = ObjectMetadata::new()
708+
.with_content_type("application/octet-stream")
709+
.with_metadata("x-fula-forest", "true");
710+
711+
self.inner.put_object_with_metadata(
712+
bucket,
713+
&index_key,
714+
Bytes::from(data),
715+
Some(metadata),
716+
).await?;
717+
718+
// Update cache
719+
{
720+
let mut cache = self.forest_cache.write().unwrap();
721+
cache.insert(bucket.to_string(), forest.clone());
722+
}
723+
724+
Ok(())
725+
}
726+
727+
/// Put an encrypted object using FlatNamespace mode
728+
///
729+
/// This automatically updates the forest index.
730+
pub async fn put_object_flat(
731+
&self,
732+
bucket: &str,
733+
key: &str,
734+
data: impl Into<Bytes>,
735+
content_type: Option<&str>,
736+
) -> Result<PutObjectResult> {
737+
let data = data.into();
738+
let original_size = data.len() as u64;
739+
740+
// Load or create forest
741+
let mut forest = self.load_forest(bucket).await?;
742+
743+
// Generate a DEK for this object
744+
let dek = self.encryption.key_manager.generate_dek();
745+
746+
// Encrypt the data
747+
let nonce = Nonce::generate();
748+
let aead = Aead::new_default(&dek);
749+
let ciphertext = aead.encrypt(&nonce, &data)
750+
.map_err(ClientError::Encryption)?;
751+
752+
// Generate flat storage key (no structure hints!)
753+
let storage_key = forest.generate_key(key, &dek);
754+
755+
// Encrypt the DEK with HPKE
756+
let encryptor = Encryptor::new(self.encryption.public_key());
757+
let wrapped_dek = encryptor.encrypt_dek(&dek)
758+
.map_err(ClientError::Encryption)?;
759+
760+
// Create private metadata
761+
let private_meta = PrivateMetadata::new(key, original_size)
762+
.with_content_type(content_type.unwrap_or("application/octet-stream"));
763+
764+
let encrypted_meta = EncryptedPrivateMetadata::encrypt(&private_meta, &dek)
765+
.map_err(ClientError::Encryption)?;
766+
767+
// Add to forest index
768+
let entry = ForestFileEntry::from_metadata(&private_meta, storage_key.clone());
769+
forest.upsert_file(entry);
770+
771+
// Serialize encryption metadata
772+
let enc_metadata = serde_json::json!({
773+
"version": 2,
774+
"algorithm": "AES-256-GCM",
775+
"nonce": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, nonce.as_bytes()),
776+
"wrapped_key": serde_json::to_value(&wrapped_dek).unwrap(),
777+
"metadata_privacy": true,
778+
"obfuscation_mode": "flat",
779+
"private_metadata": encrypted_meta.to_json().map_err(ClientError::Encryption)?,
780+
});
781+
782+
// Upload the file (server sees only QmX7a8f3...)
783+
let metadata = ObjectMetadata::new()
784+
.with_content_type("application/octet-stream")
785+
.with_metadata("x-fula-encrypted", "true")
786+
.with_metadata("x-fula-encryption", &enc_metadata.to_string());
787+
788+
let result = self.inner.put_object_with_metadata(
789+
bucket,
790+
&storage_key,
791+
Bytes::from(ciphertext),
792+
Some(metadata),
793+
).await?;
794+
795+
// Save updated forest
796+
self.save_forest(bucket, &forest).await?;
797+
798+
Ok(result)
799+
}
800+
801+
/// Get an object using FlatNamespace mode
802+
///
803+
/// Uses the forest index to resolve original path → storage key.
804+
pub async fn get_object_flat(
805+
&self,
806+
bucket: &str,
807+
key: &str,
808+
) -> Result<Bytes> {
809+
// Load forest to get the storage key
810+
let forest = self.load_forest(bucket).await?;
811+
812+
let storage_key = forest.get_storage_key(key)
813+
.ok_or_else(|| ClientError::NotFound { bucket: bucket.to_string(), key: key.to_string() })?;
814+
815+
self.get_object_decrypted_by_storage_key(bucket, storage_key).await
816+
}
817+
818+
/// List directory from forest index (FlatNamespace mode)
819+
///
820+
/// This is much faster than HEAD requests because the forest already
821+
/// contains all metadata.
822+
async fn list_directory_from_forest(
823+
&self,
824+
bucket: &str,
825+
prefix: Option<&str>,
826+
) -> Result<DirectoryListing> {
827+
let forest = self.load_forest(bucket).await?;
828+
829+
let prefix_str = prefix.unwrap_or("/");
830+
let files = forest.list_recursive(prefix_str);
831+
832+
let mut directories: HashMap<String, Vec<FileMetadata>> = HashMap::new();
833+
834+
for entry in files {
835+
let dir = if let Some(last_slash) = entry.path.rfind('/') {
836+
entry.path[..last_slash].to_string()
837+
} else {
838+
"/".to_string()
839+
};
840+
841+
let metadata = FileMetadata {
842+
storage_key: entry.storage_key.clone(),
843+
original_key: entry.path.clone(),
844+
original_size: entry.size,
845+
content_type: entry.content_type.clone(),
846+
created_at: Some(entry.created_at),
847+
modified_at: Some(entry.modified_at),
848+
user_metadata: entry.user_metadata.clone(),
849+
is_encrypted: true,
850+
};
851+
852+
directories.entry(dir).or_default().push(metadata);
853+
}
854+
855+
Ok(DirectoryListing {
856+
bucket: bucket.to_string(),
857+
prefix: prefix.map(|s| s.to_string()),
858+
directories,
859+
})
860+
}
861+
862+
/// List all files from forest (FlatNamespace mode)
863+
///
864+
/// No network requests needed - uses cached/loaded forest index.
865+
pub async fn list_files_from_forest(
866+
&self,
867+
bucket: &str,
868+
) -> Result<Vec<FileMetadata>> {
869+
let forest = self.load_forest(bucket).await?;
870+
871+
let files: Vec<FileMetadata> = forest.list_all_files()
872+
.map(|entry| FileMetadata {
873+
storage_key: entry.storage_key.clone(),
874+
original_key: entry.path.clone(),
875+
original_size: entry.size,
876+
content_type: entry.content_type.clone(),
877+
created_at: Some(entry.created_at),
878+
modified_at: Some(entry.modified_at),
879+
user_metadata: entry.user_metadata.clone(),
880+
is_encrypted: true,
881+
})
882+
.collect();
883+
884+
Ok(files)
885+
}
886+
887+
/// Delete a file in FlatNamespace mode
888+
///
889+
/// Removes from storage and updates forest index.
890+
pub async fn delete_object_flat(
891+
&self,
892+
bucket: &str,
893+
key: &str,
894+
) -> Result<()> {
895+
let mut forest = self.load_forest(bucket).await?;
896+
897+
// Get storage key before removing from forest
898+
let storage_key = forest.get_storage_key(key)
899+
.ok_or_else(|| ClientError::NotFound { bucket: bucket.to_string(), key: key.to_string() })?
900+
.to_string();
901+
902+
// Remove from storage
903+
self.inner.delete_object(bucket, &storage_key).await?;
904+
905+
// Remove from forest
906+
forest.remove_file(key);
907+
908+
// Save updated forest
909+
self.save_forest(bucket, &forest).await?;
910+
911+
Ok(())
912+
}
913+
914+
/// Get the private forest for sharing (extract subtree)
915+
///
916+
/// This allows sharing a portion of your file tree with someone else.
917+
pub async fn get_forest_subtree(
918+
&self,
919+
bucket: &str,
920+
prefix: &str,
921+
) -> Result<PrivateForest> {
922+
let forest = self.load_forest(bucket).await?;
923+
Ok(forest.extract_subtree(prefix))
924+
}
606925
}
607926

608927
/// File metadata (without file content) - optimized for file managers

crates/fula-client/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,4 @@ pub use types::*;
5353

5454
// Re-export useful crypto types for encryption configuration
5555
pub use fula_crypto::private_metadata::KeyObfuscation;
56+
pub use fula_crypto::private_forest::{PrivateForest, ForestFileEntry, ForestDirectoryEntry};

crates/fula-crypto/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pub mod error;
3838
pub mod hashing;
3939
pub mod hpke;
4040
pub mod keys;
41+
pub mod private_forest;
4142
pub mod private_metadata;
4243
pub mod rotation;
4344
pub mod sharing;
@@ -48,6 +49,7 @@ pub use error::{CryptoError, Result};
4849
pub use hashing::{Blake3Hash, Hasher, HashOutput};
4950
pub use hpke::{Decryptor, EncapsulatedKey, EncryptedData, Encryptor, HpkeConfig, SharePermissions};
5051
pub use keys::{DekKey, KekKeyPair, KeyManager, PublicKey, SecretKey};
52+
pub use private_forest::{PrivateForest, EncryptedForest, ForestFileEntry, ForestDirectoryEntry, derive_index_key, generate_flat_key};
5153
pub use private_metadata::{PrivateMetadata, EncryptedPrivateMetadata, PublicMetadata, KeyObfuscation, obfuscate_key};
5254
pub use rotation::{KeyRotationManager, FileSystemRotation, WrappedKeyInfo, RotationResult};
5355
pub use sharing::{ShareToken, ShareBuilder, ShareRecipient, AcceptedShare, FolderShareManager, AccessValidation};

0 commit comments

Comments
 (0)