Skip to content

Commit 2569090

Browse files
committed
corrected shsare
1 parent ecb162e commit 2569090

9 files changed

Lines changed: 299 additions & 27 deletions

File tree

Cargo.lock

Lines changed: 8 additions & 8 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.12"
77+
version = "0.2.13"
7878
edition = "2021"
7979
license = "MIT OR Apache-2.0"
8080
repository = "https://github.com/functionland/fula-api"

crates/fula-core/src/bucket.rs

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -923,41 +923,45 @@ mod tests {
923923
#[tokio::test]
924924
async fn test_bucket_manager_persistence() {
925925
use tempfile::tempdir;
926-
926+
927927
let temp_dir = tempdir().unwrap();
928928
let cid_path = temp_dir.path().join("registry.cid");
929-
929+
930930
// Create a shared store that simulates IPFS persistence
931931
let store = Arc::new(MemoryBlockStore::new());
932-
932+
933+
// Use a consistent user_id for testing (this matches production flow)
934+
let user_id = "user123";
935+
933936
// Create manager with persistence and add buckets
934937
{
935938
let manager = BucketManager::with_persistence(Arc::clone(&store), &cid_path);
936-
937-
let owner = Owner::new("user123");
938-
manager.create_bucket("persist-bucket1".to_string(), owner.clone()).await.unwrap();
939-
manager.create_bucket("persist-bucket2".to_string(), owner).await.unwrap();
940-
939+
940+
let owner = Owner::new(user_id);
941+
// Use create_bucket_for_user to match production behavior (per-user bucket isolation)
942+
manager.create_bucket_for_user(user_id, "persist-bucket1".to_string(), owner.clone()).await.unwrap();
943+
manager.create_bucket_for_user(user_id, "persist-bucket2".to_string(), owner).await.unwrap();
944+
941945
assert_eq!(manager.list_buckets().len(), 2);
942-
946+
943947
// Verify CID file was created
944948
assert!(cid_path.exists(), "Registry CID file should exist");
945949
}
946-
950+
947951
// Create a new manager and load the registry
948952
{
949953
let manager = BucketManager::with_persistence(Arc::clone(&store), &cid_path);
950-
954+
951955
// Initially empty
952956
assert_eq!(manager.list_buckets().len(), 0);
953-
957+
954958
// Load from persisted registry
955959
let loaded_count = manager.load_registry().await.unwrap();
956960
assert_eq!(loaded_count, 2, "Should load 2 buckets from registry");
957-
958-
// Verify buckets are restored
959-
assert!(manager.bucket_exists("persist-bucket1"));
960-
assert!(manager.bucket_exists("persist-bucket2"));
961+
962+
// Verify buckets are restored (use user-scoped check to match how they were created)
963+
assert!(manager.bucket_exists_for_user(user_id, "persist-bucket1"));
964+
assert!(manager.bucket_exists_for_user(user_id, "persist-bucket2"));
961965
assert_eq!(manager.list_buckets().len(), 2);
962966
}
963967
}

crates/fula-crypto/src/sharing.rs

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,4 +1014,162 @@ mod tests {
10141014
assert_eq!(binding.content_hash, "hash");
10151015
assert_eq!(binding.size, 512);
10161016
}
1017+
1018+
/// Test that simulates the exact FxFiles → Web UI share flow:
1019+
/// 1. Generate random bytes (like Dart's X25519().newKeyPair().extractPrivateKeyBytes())
1020+
/// 2. Create public key from those bytes (Rust derives public key)
1021+
/// 3. Create share token with that public key
1022+
/// 4. Serialize to JSON, transmit, deserialize (simulating URL transmission)
1023+
/// 5. Accept share using SecretKey from same bytes
1024+
/// 6. Verify DEK matches
1025+
/// 7. Use DEK to decrypt test content
1026+
#[test]
1027+
fn test_fxfiles_to_webui_share_flow() {
1028+
use crate::symmetric::{Aead, Nonce};
1029+
1030+
// === STEP 1: FxFiles side - generate disposable keypair ===
1031+
// This simulates: final x25519 = X25519(); final keyPair = await x25519.newKeyPair();
1032+
let mut disposable_secret_bytes = [0u8; 32];
1033+
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut disposable_secret_bytes);
1034+
1035+
// === STEP 2: Create SecretKey and derive PublicKey ===
1036+
// This simulates: final publicKeyBytes = Uint8List.fromList(publicKeyData.bytes);
1037+
let disposable_secret = SecretKey::from_bytes(&disposable_secret_bytes).unwrap();
1038+
let disposable_public = disposable_secret.public_key();
1039+
1040+
println!("Disposable secret key: {:?}", hex::encode(&disposable_secret_bytes));
1041+
println!("Derived public key: {:?}", hex::encode(disposable_public.as_bytes()));
1042+
1043+
// === STEP 3: Owner creates share token ===
1044+
// Original file was encrypted with this DEK
1045+
let owner_keypair = KekKeyPair::generate();
1046+
let original_dek = DekKey::generate();
1047+
1048+
// Encrypt some test content
1049+
let plaintext = b"This is a test JPEG file. Should start with FF D8 FF in real case.";
1050+
let nonce = Nonce::generate();
1051+
let aead = Aead::new_default(&original_dek);
1052+
let ciphertext = aead.encrypt(&nonce, plaintext).unwrap();
1053+
1054+
// Create share token with disposable public key
1055+
let token = ShareBuilder::new(&owner_keypair, &disposable_public, &original_dek)
1056+
.path_scope("/test/file.jpg")
1057+
.build()
1058+
.unwrap();
1059+
1060+
// === STEP 4: Serialize token for transmission ===
1061+
// This simulates: base64Encode(jsonEncode(token))
1062+
let token_json = serde_json::to_string(&token).unwrap();
1063+
println!("Share token JSON length: {}", token_json.len());
1064+
1065+
// === STEP 5: Web UI side - deserialize token ===
1066+
// This simulates: jsonDecode(base64Decode(payload.t))
1067+
let received_token: ShareToken = serde_json::from_str(&token_json).unwrap();
1068+
1069+
// === STEP 6: Web UI creates SecretKey from URL's sk bytes ===
1070+
// This simulates: createEncryptedClient({ secretKey: base64Decode(payload.sk) })
1071+
let web_secret = SecretKey::from_bytes(&disposable_secret_bytes).unwrap();
1072+
let web_derived_public = web_secret.public_key();
1073+
1074+
println!("Web derived public key: {:?}", hex::encode(web_derived_public.as_bytes()));
1075+
1076+
// CRITICAL CHECK: The derived public key must match!
1077+
assert_eq!(
1078+
disposable_public.as_bytes(),
1079+
web_derived_public.as_bytes(),
1080+
"Public key derived on web side must match the one used for token creation"
1081+
);
1082+
1083+
// === STEP 7: Accept the share ===
1084+
let recipient = ShareRecipient::from_secret_key(web_secret);
1085+
let accepted = recipient.accept_share(&received_token).unwrap();
1086+
1087+
println!("Original DEK first 4 bytes: {:02x}{:02x}{:02x}{:02x}",
1088+
original_dek.as_bytes()[0], original_dek.as_bytes()[1],
1089+
original_dek.as_bytes()[2], original_dek.as_bytes()[3]);
1090+
println!("Accepted DEK first 4 bytes: {:02x}{:02x}{:02x}{:02x}",
1091+
accepted.dek.as_bytes()[0], accepted.dek.as_bytes()[1],
1092+
accepted.dek.as_bytes()[2], accepted.dek.as_bytes()[3]);
1093+
1094+
// CRITICAL CHECK: DEK must match!
1095+
assert_eq!(
1096+
original_dek.as_bytes(),
1097+
accepted.dek.as_bytes(),
1098+
"Decrypted DEK must match the original DEK"
1099+
);
1100+
1101+
// === STEP 8: Decrypt the content ===
1102+
let recipient_aead = Aead::new_default(&accepted.dek);
1103+
let decrypted = recipient_aead.decrypt(&nonce, &ciphertext).unwrap();
1104+
1105+
assert_eq!(
1106+
plaintext.as_slice(),
1107+
decrypted.as_slice(),
1108+
"Decrypted content must match original plaintext"
1109+
);
1110+
1111+
println!("SUCCESS: Full share flow works correctly!");
1112+
}
1113+
1114+
/// Test share with SecretKey bytes that look like what Dart might produce
1115+
/// (testing various edge cases in key format)
1116+
#[test]
1117+
fn test_share_with_various_key_formats() {
1118+
// Test with bytes that have various patterns
1119+
let test_cases: Vec<[u8; 32]> = vec![
1120+
// All zeros (will be clamped)
1121+
[0u8; 32],
1122+
// All ones (will be clamped)
1123+
[0xFFu8; 32],
1124+
// Sequential bytes
1125+
{
1126+
let mut arr = [0u8; 32];
1127+
for (i, b) in arr.iter_mut().enumerate() {
1128+
*b = i as u8;
1129+
}
1130+
arr
1131+
},
1132+
// Random bytes (typical case)
1133+
{
1134+
let mut arr = [0u8; 32];
1135+
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut arr);
1136+
arr
1137+
},
1138+
];
1139+
1140+
for (i, secret_bytes) in test_cases.iter().enumerate() {
1141+
println!("\n=== Test case {} ===", i);
1142+
1143+
let owner = KekKeyPair::generate();
1144+
let dek = DekKey::generate();
1145+
1146+
// Create disposable keypair from raw bytes
1147+
let disposable_secret = SecretKey::from_bytes(secret_bytes).unwrap();
1148+
let disposable_public = disposable_secret.public_key();
1149+
1150+
// Create share token
1151+
let token = ShareBuilder::new(&owner, &disposable_public, &dek)
1152+
.path_scope("/test")
1153+
.build()
1154+
.unwrap();
1155+
1156+
// Serialize and deserialize (round-trip)
1157+
let json = serde_json::to_string(&token).unwrap();
1158+
let restored_token: ShareToken = serde_json::from_str(&json).unwrap();
1159+
1160+
// Accept share using same secret bytes
1161+
let recipient_secret = SecretKey::from_bytes(secret_bytes).unwrap();
1162+
let recipient = ShareRecipient::from_secret_key(recipient_secret);
1163+
let accepted = recipient.accept_share(&restored_token).unwrap();
1164+
1165+
// DEK must match
1166+
assert_eq!(
1167+
dek.as_bytes(),
1168+
accepted.dek.as_bytes(),
1169+
"Test case {}: DEK mismatch", i
1170+
);
1171+
1172+
println!("Test case {}: PASSED", i);
1173+
}
1174+
}
10171175
}

crates/fula-flutter/src/api/encrypted.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,48 @@ pub async fn get_public_key(client: &EncryptedClientHandle) -> Vec<u8> {
163163
guard.encryption_config().public_key().as_bytes().to_vec()
164164
}
165165

166+
/// Derive X25519 public key from private key bytes
167+
///
168+
/// **IMPORTANT**: Use this function instead of Dart's X25519 public key derivation
169+
/// to ensure compatibility between Flutter and Web/WASM clients.
170+
///
171+
/// This ensures that both FxFiles and Web UI derive the exact same public key
172+
/// from the same private key bytes, avoiding cryptographic mismatches.
173+
///
174+
/// # Arguments
175+
/// * `secret_key_bytes` - 32-byte X25519 private key (raw bytes, not clamped)
176+
///
177+
/// # Returns
178+
/// * `Ok(Vec<u8>)` - 32-byte X25519 public key
179+
/// * `Err` - If secret_key_bytes is not exactly 32 bytes
180+
///
181+
/// # Example
182+
/// ```dart
183+
/// // In Flutter/Dart, generate random 32 bytes:
184+
/// final secretKeyBytes = Uint8List(32);
185+
/// Random.secure().nextBytes(secretKeyBytes);
186+
///
187+
/// // Derive public key using Rust (ensures cross-platform compatibility):
188+
/// final publicKeyBytes = await derivePublicKeyFromSecret(secretKeyBytes);
189+
///
190+
/// // Now use publicKeyBytes for createShareToken
191+
/// // and secretKeyBytes in the share URL
192+
/// ```
193+
pub fn derive_public_key_from_secret(secret_key_bytes: Vec<u8>) -> anyhow::Result<Vec<u8>> {
194+
if secret_key_bytes.len() != 32 {
195+
anyhow::bail!("Secret key must be exactly 32 bytes, got {}", secret_key_bytes.len());
196+
}
197+
198+
let mut arr = [0u8; 32];
199+
arr.copy_from_slice(&secret_key_bytes);
200+
201+
let secret = fula_crypto::SecretKey::from_bytes(&arr)
202+
.map_err(|e| anyhow::anyhow!("Invalid secret key: {}", e))?;
203+
let public = secret.public_key();
204+
205+
Ok(public.as_bytes().to_vec())
206+
}
207+
166208
/// Check if client uses FlatNamespace mode
167209
pub async fn is_flat_namespace(client: &EncryptedClientHandle) -> bool {
168210
let guard = client.inner.read().await;

crates/fula-js/src/lib.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,34 @@ pub fn derive_key(context: &str, input: &[u8]) -> Vec<u8> {
467467
fula_crypto::hashing::derive_key(context, input).as_bytes().to_vec()
468468
}
469469

470+
/// Derive X25519 public key from private key bytes
471+
///
472+
/// **IMPORTANT**: Use this function to ensure compatibility between
473+
/// Flutter/Native and Web/WASM clients when sharing files.
474+
///
475+
/// This ensures both sender and receiver derive the exact same public key
476+
/// from the same private key bytes, avoiding cryptographic mismatches.
477+
///
478+
/// @param secretKeyBytes - 32-byte X25519 private key (Uint8Array)
479+
/// @returns 32-byte X25519 public key (Uint8Array)
480+
#[wasm_bindgen(js_name = derivePublicKeyFromSecret)]
481+
pub fn derive_public_key_from_secret(secret_key_bytes: &[u8]) -> Result<Vec<u8>, JsError> {
482+
if secret_key_bytes.len() != 32 {
483+
return Err(JsError::new(&format!(
484+
"Secret key must be exactly 32 bytes, got {}", secret_key_bytes.len()
485+
)));
486+
}
487+
488+
let mut arr = [0u8; 32];
489+
arr.copy_from_slice(secret_key_bytes);
490+
491+
let secret = fula_crypto::SecretKey::from_bytes(&arr)
492+
.map_err(|e| JsError::new(&format!("Invalid secret key: {}", e)))?;
493+
let public = secret.public_key();
494+
495+
Ok(public.as_bytes().to_vec())
496+
}
497+
470498
// ============================================================================
471499
// Sharing
472500
// ============================================================================

0 commit comments

Comments
 (0)