From e78da4e032fb4736a5067a6ec77e4d16e28f1a04 Mon Sep 17 00:00:00 2001 From: ehsan shariati Date: Fri, 26 Jun 2026 14:45:56 -0400 Subject: [PATCH 1/4] feat(fula-crypto): recipient-side link-secret unwrap + owner-file decrypt cores Method-2 RECIPIENT counterparts to wrap_secret_for_recipient (#86) so the hosted Worker (wasm) can consume a collaboration share without re-implementing fula:v4 crypto in TS. Thin compositions of ShareRecipient::accept_share + Aead + ChunkedDecoder (no new crypto): unwrap_secret_for_recipient (recover the exact 32-byte link secret), describe_shared_file (non-secret framing), decrypt_shared_file_single_block/_chunked (owner encType:fula decrypt, byte-mirroring the native read path; fail-closed). Co-Authored-By: Claude Opus 4.8 --- crates/fula-crypto/src/sharing.rs | 212 ++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) diff --git a/crates/fula-crypto/src/sharing.rs b/crates/fula-crypto/src/sharing.rs index 353e8e5..4780a04 100644 --- a/crates/fula-crypto/src/sharing.rs +++ b/crates/fula-crypto/src/sharing.rs @@ -9,10 +9,13 @@ use crate::{ CryptoError, Result, + chunked::{ChunkedDecoder, ChunkedFileMetadata}, hpke::{Encryptor, Decryptor, EncryptedData, SharePermissions}, keys::{DekKey, KekKeyPair, PublicKey, SecretKey}, + symmetric::{Aead, Nonce}, time::now_timestamp, }; +use base64::Engine as _; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -694,6 +697,215 @@ pub fn wrap_secret_for_recipient( builder.build() } +// ═══════════════════════════════════════════════════════════════════════════ +// METHOD-2 RECIPIENT CONSUMER: link-secret unwrap + owner-file decrypt +// ═══════════════════════════════════════════════════════════════════════════ +// +// These are the RECIPIENT-side counterparts to `wrap_secret_for_recipient`, +// exposed (via the `fula-js` wasm bindings) so the hosted Cloudflare Worker can +// consume a Method-2 collaboration share WITHOUT re-implementing any `fula:v4` +// crypto in TypeScript. They are THIN compositions of already-tested primitives +// (`ShareRecipient::accept_share`, `Aead`, `Nonce`, `ChunkedDecoder`) — they add +// NO new crypto. +// +// The owner-file decrypt logic ([`decrypt_accepted_single_block`] / +// [`assemble_accepted_chunked`]) is a byte-for-byte mirror of the native MCP read +// path (`crates/fula-mcp/src/read.rs` `decrypt_single_block` / `assemble_chunked`). +// A `#[cfg(test)]` cross-check in that file asserts the two stay byte-identical, +// so the Worker (wasm) and the native MCP cannot silently drift. + +/// Accept a strict-v5 share token with raw recipient X25519 secret-key bytes. +/// +/// Centralizes the fail-closed 32-byte length check + [`ShareRecipient::accept_share`] +/// so the unwrap / describe / decrypt entry points share ONE accept path. +/// `accept_share` itself enforces the strict-v5 gate, the expiry check, and the +/// recipient-public-key AAD binding (a wrong key, an expired token, or any +/// tampered field ⇒ a generic authentication failure). +fn accept_token(recipient_secret_key: &[u8], token: &ShareToken) -> Result { + if recipient_secret_key.len() != 32 { + return Err(CryptoError::InvalidKey(format!( + "recipient secret key must be exactly 32 bytes, got {}", + recipient_secret_key.len() + ))); + } + let secret = SecretKey::from_bytes(recipient_secret_key)?; + ShareRecipient::from_secret_key(secret).accept_share(token) +} + +/// Recipient counterpart to [`wrap_secret_for_recipient`]: recover the EXACT +/// 32-byte secret a producer wrapped for this recipient's X25519 public key. +/// +/// For the Method-2 collaboration pairing the recovered "DEK" *is* the group +/// link secret (the recipient then derives the manifest / collab-file keys from +/// it). Fail-closed: a non-32-byte key, a pre-v5 / expired / tampered token, or a +/// token addressed to a different key all return `Err`. +/// +/// This is the shared core that the `fula-js` `unwrapSecretForRecipient` binding +/// and the cross-impl KAT both call. +pub fn unwrap_secret_for_recipient( + recipient_secret_key: &[u8], + token: &ShareToken, +) -> Result<[u8; 32]> { + let accepted = accept_token(recipient_secret_key, token)?; + Ok(*accepted.dek.as_bytes()) +} + +/// Non-secret framing of an owner (`encType:"fula"`) share: whether the file is +/// chunked and, if so, how many chunks. Lets a recipient that fetches ciphertext +/// itself (the Worker) learn how many chunk objects to fetch BEFORE fetching — +/// `num_chunks` lives INSIDE the encrypted share, so it is otherwise unknowable. +/// +/// Deliberately carries NO key material (no DEK, no nonce, no metadata plaintext); +/// only the framing the caller needs to drive its fetch loop. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedFileFraming { + /// True when the file is stored as multiple per-chunk objects. + pub chunked: bool, + /// Number of chunk objects to fetch (`1` for a single-block file). + pub num_chunks: u32, + /// The content encryption version recorded in the share (`None` = legacy v2). + pub encryption_version: Option, +} + +/// Accept a share and report its [`SharedFileFraming`] (chunked? how many +/// chunks?) WITHOUT exposing any key material. The DEK is decrypted only to +/// authenticate the share, then dropped. +pub fn describe_shared_file( + recipient_secret_key: &[u8], + token: &ShareToken, +) -> Result { + let accepted = accept_token(recipient_secret_key, token)?; + let (chunked, num_chunks) = match accepted.chunked_metadata.as_deref() { + Some(meta_json) => (true, parse_chunked_metadata(meta_json)?.num_chunks), + None => (false, 1), + }; + Ok(SharedFileFraming { + chunked, + num_chunks, + encryption_version: accepted.encryption_version, + }) +} + +/// Decrypt a SINGLE-BLOCK owner file: accept the share, then decrypt the +/// already-fetched whole ciphertext. Errors if the share is actually chunked +/// (the caller must use [`decrypt_shared_file_chunked`]). +/// +/// Mirrors `read.rs::decrypt_single_block` exactly: the nonce is the +/// base64-STANDARD decode of the share `nonce`; the AAD is +/// `fula:v4:content:{storage_key}`; the version gate is `Some(>=4)` ⇒ AAD, +/// `Some(<4)` ⇒ no AAD, `None` ⇒ try-AAD-then-plaintext. +pub fn decrypt_shared_file_single_block( + recipient_secret_key: &[u8], + token: &ShareToken, + storage_key: &str, + ciphertext: &[u8], +) -> Result> { + let accepted = accept_token(recipient_secret_key, token)?; + if accepted.chunked_metadata.is_some() { + return Err(CryptoError::InvalidFormat( + "share is for a chunked file; use decrypt_shared_file_chunked".to_string(), + )); + } + decrypt_accepted_single_block(&accepted, ciphertext, storage_key) +} + +/// Decrypt a CHUNKED owner file: accept the share, then assemble the +/// already-fetched per-chunk ciphertexts. `chunks` MUST be in ascending chunk +/// order (`chunks[i]` is chunk index `i`, fetched from `{storage_key}.chunks/{i:08}`). +/// Errors if the share is single-block, or if `chunks.len()` does not match the +/// share's `num_chunks` (checked BEFORE any per-chunk AEAD open). +/// +/// Mirrors `read.rs::assemble_chunked` exactly: `format == "streaming-v2"` ⇒ +/// `ChunkedDecoder::with_aad(dek, meta, "fula:v4:chunk:{storage_key}")` (the +/// decoder appends `:{index}` to the AAD per chunk), otherwise the legacy no-AAD +/// decoder. +pub fn decrypt_shared_file_chunked( + recipient_secret_key: &[u8], + token: &ShareToken, + storage_key: &str, + chunks: Vec>, +) -> Result> { + let accepted = accept_token(recipient_secret_key, token)?; + let meta_json = accepted.chunked_metadata.as_deref().ok_or_else(|| { + CryptoError::InvalidFormat( + "share is for a single-block file; use decrypt_shared_file_single_block".to_string(), + ) + })?; + let meta = parse_chunked_metadata(meta_json)?; + if chunks.len() != meta.num_chunks as usize { + return Err(CryptoError::InvalidFormat(format!( + "chunk count mismatch: share declares {} chunk(s), got {}", + meta.num_chunks, + chunks.len() + ))); + } + let indexed: Vec<(u32, Vec)> = chunks + .into_iter() + .enumerate() + .map(|(i, c)| (i as u32, c)) + .collect(); + assemble_accepted_chunked(accepted.dek, meta, &indexed, storage_key) +} + +/// Parse a share's `chunked_metadata` JSON into [`ChunkedFileMetadata`]. +fn parse_chunked_metadata(meta_json: &str) -> Result { + serde_json::from_str(meta_json) + .map_err(|e| CryptoError::InvalidFormat(format!("chunked metadata parse failed: {e}"))) +} + +/// Decrypt a single-block owner object from an ALREADY-accepted share. PURE (no +/// I/O). Byte-for-byte mirror of `read.rs::decrypt_single_block` — the native MCP +/// and the wasm binding share this exact logic (enforced by a cross-check test in +/// `read.rs`). +pub fn decrypt_accepted_single_block( + accepted: &AcceptedShare, + ciphertext: &[u8], + storage_key: &str, +) -> Result> { + let nonce_b64 = accepted.nonce.as_deref().ok_or_else(|| { + CryptoError::InvalidFormat("single-block fula file has no nonce".to_string()) + })?; + let nonce_bytes = base64::engine::general_purpose::STANDARD + .decode(nonce_b64) + .map_err(|e| CryptoError::InvalidFormat(format!("nonce base64: {e}")))?; + let nonce = Nonce::from_bytes(&nonce_bytes)?; + + let aead = Aead::new_default(&accepted.dek); + let aad = format!("fula:v4:content:{storage_key}"); + + // Version gate — identical to fula-client's `get_object_with_share`. + let plaintext = match accepted.encryption_version { + Some(v) if v >= 4 => aead.decrypt_with_aad(&nonce, ciphertext, aad.as_bytes())?, + Some(_) => aead.decrypt(&nonce, ciphertext)?, + None => match aead.decrypt_with_aad(&nonce, ciphertext, aad.as_bytes()) { + Ok(p) => p, + Err(_) => aead.decrypt(&nonce, ciphertext)?, + }, + }; + Ok(plaintext) +} + +/// Assemble a chunked owner object from already-fetched per-chunk ciphertexts and +/// an already-accepted share's DEK + metadata. PURE (no I/O). Byte-for-byte mirror +/// of `read.rs::assemble_chunked` (enforced by a cross-check test in `read.rs`). +pub fn assemble_accepted_chunked( + dek: DekKey, + meta: ChunkedFileMetadata, + chunks: &[(u32, Vec)], + storage_key: &str, +) -> Result> { + let mut decoder = if meta.format == "streaming-v2" { + ChunkedDecoder::with_aad(dek, meta, format!("fula:v4:chunk:{storage_key}")) + } else { + ChunkedDecoder::new(dek, meta) + }; + for (i, ct) in chunks { + decoder.decrypt_chunk(*i, ct)?; + } + decoder.finalize().map(|b| b.to_vec()) +} + /// Folder share manager for managing multiple shares #[derive(Default)] pub struct FolderShareManager { From 1e4b77c13379dab45f99e02be638436bffdbc175 Mon Sep 17 00:00:00 2001 From: ehsan shariati Date: Fri, 26 Jun 2026 14:46:12 -0400 Subject: [PATCH 2/4] feat(fula-js): unwrapSecretForRecipient + describeSharedFile + decryptSharedFile* wasm bindings Method-2 recipient wasm bindings (sibling of wrapSecretForRecipient). Each delegates to the shared fula_crypto::sharing core; fail-closed on bad key length, malformed/expired/tampered/non-addressed token, single-vs-chunked mismatch, or a non-Uint8Array chunk element. Co-Authored-By: Claude Opus 4.8 --- crates/fula-js/src/lib.rs | 123 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/crates/fula-js/src/lib.rs b/crates/fula-js/src/lib.rs index 671cd90..cb05192 100644 --- a/crates/fula-js/src/lib.rs +++ b/crates/fula-js/src/lib.rs @@ -1352,6 +1352,129 @@ pub fn wrap_secret_for_recipient( .map_err(|e| JsError::new(&format!("Failed to serialize share token: {}", e))) } +// ---------------------------------------------------------------------------- +// Method-2 RECIPIENT bindings (sibling of `wrapSecretForRecipient`) +// ---------------------------------------------------------------------------- +// +// These let a Method-2 recipient (the hosted Cloudflare Worker) consume a v5 +// `ShareToken` WITHOUT an `EncryptedClient` and WITHOUT re-implementing any +// `fula:v4` crypto in TypeScript. Each delegates to the shared +// `fula_crypto::sharing` core that the cross-impl KAT also exercises; all are +// fail-closed (they throw on a bad key length, malformed token JSON, a pre-v5 / +// expired / tampered token, a token not addressed to this key, the wrong +// single-block-vs-chunked path, or a chunk-count mismatch). HPKE acceptance +// consumes no randomness, so these need no wasm RNG. + +/// Recover the raw 32-byte secret a producer wrapped for this recipient — the +/// consumer half of `wrapSecretForRecipient`. For the Method-2 collaboration +/// pairing the recovered bytes ARE the group link secret, from which the caller +/// derives the manifest / collab-file keys. +/// +/// @param recipient_secret_key - the recipient's 32-byte X25519 secret key (Uint8Array) +/// @param token_json - the JSON-serialized v5 ShareToken +/// @returns the recovered 32 secret bytes (Uint8Array) +#[wasm_bindgen(js_name = unwrapSecretForRecipient)] +pub fn unwrap_secret_for_recipient( + recipient_secret_key: &[u8], + token_json: &str, +) -> Result, JsError> { + let token: fula_crypto::ShareToken = serde_json::from_str(token_json) + .map_err(|e| JsError::new(&format!("Invalid share token JSON: {}", e)))?; + let secret = fula_crypto::sharing::unwrap_secret_for_recipient(recipient_secret_key, &token) + .map_err(|e| JsError::new(&format!("Failed to unwrap secret for recipient: {}", e)))?; + Ok(secret.to_vec()) +} + +/// Report the NON-SECRET framing of an owner (`encType:"fula"`) share so the +/// caller can plan its fetches: `{ chunked: boolean, numChunks: number, +/// encryptionVersion: number | null }`. `numChunks` lives inside the encrypted +/// share, so the caller must accept the share to learn how many chunk objects to +/// fetch. Exposes NO key material (DEK / nonce / metadata stay in wasm). +/// +/// @param recipient_secret_key - the recipient's 32-byte X25519 secret key (Uint8Array) +/// @param token_json - the JSON-serialized v5 ShareToken +/// @returns `{ chunked, numChunks, encryptionVersion }` +#[wasm_bindgen(js_name = describeSharedFile)] +pub fn describe_shared_file( + recipient_secret_key: &[u8], + token_json: &str, +) -> Result { + let token: fula_crypto::ShareToken = serde_json::from_str(token_json) + .map_err(|e| JsError::new(&format!("Invalid share token JSON: {}", e)))?; + let framing = fula_crypto::sharing::describe_shared_file(recipient_secret_key, &token) + .map_err(|e| JsError::new(&format!("Failed to describe shared file: {}", e)))?; + serde_wasm_bindgen::to_value(&framing) + .map_err(|e| JsError::new(&format!("Failed to serialize framing: {}", e))) +} + +/// Decrypt a SINGLE-BLOCK owner file (`encType:"fula"`): the caller fetches the +/// whole ciphertext itself, then hands it here with the file's share token and +/// obfuscated `storage_key`. Mirrors the native MCP read path exactly (AAD +/// `fula:v4:content:{storage_key}` + the share nonce + the version gate). Throws +/// if the share is actually chunked. +/// +/// @param recipient_secret_key - the recipient's 32-byte X25519 secret key (Uint8Array) +/// @param token_json - the JSON-serialized v5 ShareToken for this file +/// @param storage_key - the file's obfuscated storage key (the AAD binding) +/// @param ciphertext - the whole fetched ciphertext (Uint8Array) +/// @returns the decrypted plaintext (Uint8Array) +#[wasm_bindgen(js_name = decryptSharedFileSingleBlock)] +pub fn decrypt_shared_file_single_block( + recipient_secret_key: &[u8], + token_json: &str, + storage_key: &str, + ciphertext: &[u8], +) -> Result, JsError> { + let token: fula_crypto::ShareToken = serde_json::from_str(token_json) + .map_err(|e| JsError::new(&format!("Invalid share token JSON: {}", e)))?; + fula_crypto::sharing::decrypt_shared_file_single_block( + recipient_secret_key, + &token, + storage_key, + ciphertext, + ) + .map_err(|e| JsError::new(&format!("Failed to decrypt owner file: {}", e))) +} + +/// Decrypt a CHUNKED owner file (`encType:"fula"`): the caller fetches each chunk +/// object itself (in order, from `{storage_key}.chunks/{i:08}`) and passes the +/// ordered array of chunk ciphertexts here. `chunks[i]` MUST be chunk index `i`. +/// Mirrors the native MCP read path exactly (`streaming-v2` ⇒ per-chunk AAD +/// `fula:v4:chunk:{storage_key}:{index}`). Throws if the share is single-block or +/// if the chunk count does not match the share. +/// +/// @param recipient_secret_key - the recipient's 32-byte X25519 secret key (Uint8Array) +/// @param token_json - the JSON-serialized v5 ShareToken for this file +/// @param storage_key - the file's obfuscated storage key (the AAD binding) +/// @param chunks - the ordered per-chunk ciphertexts (Array) +/// @returns the decrypted, reassembled plaintext (Uint8Array) +#[wasm_bindgen(js_name = decryptSharedFileChunked)] +pub fn decrypt_shared_file_chunked( + recipient_secret_key: &[u8], + token_json: &str, + storage_key: &str, + chunks: js_sys::Array, +) -> Result, JsError> { + let token: fula_crypto::ShareToken = serde_json::from_str(token_json) + .map_err(|e| JsError::new(&format!("Invalid share token JSON: {}", e)))?; + // Convert the JS `Array` to `Vec>`, failing closed on any + // element that is not a Uint8Array. + let mut owned: Vec> = Vec::with_capacity(chunks.length() as usize); + for (i, val) in chunks.iter().enumerate() { + let arr = val + .dyn_into::() + .map_err(|_| JsError::new(&format!("chunk at index {} is not a Uint8Array", i)))?; + owned.push(arr.to_vec()); + } + fula_crypto::sharing::decrypt_shared_file_chunked( + recipient_secret_key, + &token, + storage_key, + owned, + ) + .map_err(|e| JsError::new(&format!("Failed to decrypt chunked owner file: {}", e))) +} + /// Accept a share token and get an AcceptedShare for accessing shared files /// /// @param client - EncryptedClient handle From 493c8789a52443d4c925621b446950d769e0f4db Mon Sep 17 00:00:00 2001 From: ehsan shariati Date: Fri, 26 Jun 2026 14:46:28 -0400 Subject: [PATCH 3/4] test(fula-mcp): recipient-bindings cross-impl KAT + native/shared no-drift gate Known-answer tests (fixed vectors, fail-closed coverage) + a no-drift cross-check asserting the native read.rs cores and the shared fula_crypto::sharing cores are byte-identical. Per advisor review (GLM F1/F2) the cross-check covers all 3 version arms (Some>=4 / Some<4 / None) and the KAT adds version-downgrade rejection (encryption_version is in the v5 AAD), empty-plaintext, and single-chunk cases. Co-Authored-By: Claude Opus 4.8 --- crates/fula-mcp/src/read.rs | 125 +++++ .../fula-mcp/tests/recipient_bindings_kat.rs | 484 ++++++++++++++++++ 2 files changed, 609 insertions(+) create mode 100644 crates/fula-mcp/tests/recipient_bindings_kat.rs diff --git a/crates/fula-mcp/src/read.rs b/crates/fula-mcp/src/read.rs index 9f0687a..24f0929 100644 --- a/crates/fula-mcp/src/read.rs +++ b/crates/fula-mcp/src/read.rs @@ -444,4 +444,129 @@ mod tests { let out = assemble_chunked(accepted.dek, meta, &chunks, storage_key).unwrap(); assert_eq!(out, payload); } + + /// NO-DRIFT GATE: the native private `decrypt_single_block` / `assemble_chunked` + /// in this file and the shared `fula_crypto::sharing` cores that the wasm + /// recipient bindings call MUST produce BYTE-IDENTICAL output for the same + /// inputs. If anyone edits one copy of the `fula:v4` AAD / version-gate / chunk + /// logic without the other, this test fails — keeping the hosted Worker (wasm) + /// and the native MCP in lockstep. (The bindings live in `crates/fula-js`; this + /// test is the cheapest place that can see BOTH the private native fns and the + /// public shared cores.) + #[test] + fn native_and_shared_owner_file_cores_are_byte_identical() { + use fula_crypto::sharing::{assemble_accepted_chunked, decrypt_accepted_single_block}; + + // ── single block ── + let owner = KekKeyPair::generate(); + let link = KekKeyPair::generate(); + let dek = DekKey::generate(); + let storage_key = "obfs-crosscheck-single"; + let plaintext = b"cross-check single-block payload"; + let nonce = Nonce::generate(); + let aad = format!("fula:v4:content:{storage_key}"); + let ciphertext = Aead::new_default(&dek) + .encrypt_with_aad(&nonce, plaintext, aad.as_bytes()) + .unwrap(); + let nonce_b64 = base64::engine::general_purpose::STANDARD.encode(nonce.as_bytes()); + let token = ShareBuilder::new(&owner, link.public_key(), &dek) + .path_scope(storage_key) + .nonce(nonce_b64) + .encryption_version(4) + .build() + .unwrap(); + let accepted = ShareRecipient::new(&link).accept_share(&token).unwrap(); + + let native = decrypt_single_block(&accepted, &ciphertext, storage_key).unwrap(); + let shared = decrypt_accepted_single_block(&accepted, &ciphertext, storage_key).unwrap(); + assert_eq!(native, shared, "single-block cores diverged"); + assert_eq!(native.as_slice(), plaintext); + + // Both reject a wrong storage_key (AAD mismatch) identically. + assert!(decrypt_single_block(&accepted, &ciphertext, "wrong-key").is_err()); + assert!(decrypt_accepted_single_block(&accepted, &ciphertext, "wrong-key").is_err()); + + // ── single block, version Some(2) (legacy, NO AAD) — cross-check the no-AAD arm ── + { + let dek_v2 = DekKey::generate(); + let sk = "obfs-crosscheck-single-v2"; + let pt = b"cross-check v2 no-aad payload"; + let nonce_v2 = Nonce::generate(); + let ct_v2 = Aead::new_default(&dek_v2).encrypt(&nonce_v2, pt).unwrap(); // no AAD + let nb64 = base64::engine::general_purpose::STANDARD.encode(nonce_v2.as_bytes()); + let tok = ShareBuilder::new(&owner, link.public_key(), &dek_v2) + .path_scope(sk) + .nonce(nb64) + .encryption_version(2) + .build() + .unwrap(); + let acc = ShareRecipient::new(&link).accept_share(&tok).unwrap(); + let n = decrypt_single_block(&acc, &ct_v2, sk).unwrap(); + let s = decrypt_accepted_single_block(&acc, &ct_v2, sk).unwrap(); + assert_eq!(n, s, "single-block Some(<4) cores diverged"); + assert_eq!(n.as_slice(), pt); + } + // ── single block, version None (try-AAD-then-plaintext) — cross-check BOTH sub-paths ── + { + let dek_none = DekKey::generate(); + let sk = "obfs-crosscheck-single-none"; + let pt = b"cross-check none-arm payload"; + // (i) None + AAD-bound ciphertext (the primary try-AAD path) + let nonce_a = Nonce::generate(); + let aad_n = format!("fula:v4:content:{sk}"); + let ct_aad = Aead::new_default(&dek_none) + .encrypt_with_aad(&nonce_a, pt, aad_n.as_bytes()) + .unwrap(); + let nb64a = base64::engine::general_purpose::STANDARD.encode(nonce_a.as_bytes()); + let tok_a = ShareBuilder::new(&owner, link.public_key(), &dek_none) + .path_scope(sk) + .nonce(nb64a) + .build() + .unwrap(); // no encryption_version => None + let acc_a = ShareRecipient::new(&link).accept_share(&tok_a).unwrap(); + assert_eq!( + decrypt_single_block(&acc_a, &ct_aad, sk).unwrap(), + decrypt_accepted_single_block(&acc_a, &ct_aad, sk).unwrap(), + "single-block None+AAD cores diverged" + ); + // (ii) None + no-AAD ciphertext (the plaintext fallback) + let nonce_b = Nonce::generate(); + let ct_plain = Aead::new_default(&dek_none).encrypt(&nonce_b, pt).unwrap(); + let nb64b = base64::engine::general_purpose::STANDARD.encode(nonce_b.as_bytes()); + let tok_b = ShareBuilder::new(&owner, link.public_key(), &dek_none) + .path_scope(sk) + .nonce(nb64b) + .build() + .unwrap(); + let acc_b = ShareRecipient::new(&link).accept_share(&tok_b).unwrap(); + let n = decrypt_single_block(&acc_b, &ct_plain, sk).unwrap(); + let s = decrypt_accepted_single_block(&acc_b, &ct_plain, sk).unwrap(); + assert_eq!(n, s, "single-block None+plaintext-fallback cores diverged"); + assert_eq!(n.as_slice(), pt); + } + + // ── chunked (streaming-v2, multi-chunk) ── + let dek2 = DekKey::generate(); + let storage_key2 = "obfs-crosscheck-chunk"; + let payload = vec![0x5au8; 200_000]; // > one 64 KiB chunk ⇒ multi-chunk + let prefix = format!("fula:v4:chunk:{storage_key2}"); + let mut enc = + ChunkedEncoder::with_aad_and_chunk_size(dek2.clone(), prefix.into_bytes(), 64 * 1024); + let mut chunks: Vec<(u32, Vec)> = enc + .update(&payload) + .unwrap() + .into_iter() + .map(|c| (c.index, c.ciphertext.to_vec())) + .collect(); + let (final_chunk, meta, _ob) = enc.finalize().unwrap(); + if let Some(c) = final_chunk { + chunks.push((c.index, c.ciphertext.to_vec())); + } + assert!(meta.num_chunks > 1, "test must be multi-chunk"); + + let native_c = assemble_chunked(dek2.clone(), meta.clone(), &chunks, storage_key2).unwrap(); + let shared_c = assemble_accepted_chunked(dek2, meta, &chunks, storage_key2).unwrap(); + assert_eq!(native_c, shared_c, "chunked cores diverged"); + assert_eq!(native_c, payload); + } } diff --git a/crates/fula-mcp/tests/recipient_bindings_kat.rs b/crates/fula-mcp/tests/recipient_bindings_kat.rs new file mode 100644 index 0000000..98687df --- /dev/null +++ b/crates/fula-mcp/tests/recipient_bindings_kat.rs @@ -0,0 +1,484 @@ +//! # Cross-implementation KAT: Method-2 RECIPIENT bindings +//! +//! This is the correctness GATE for the recipient-side bindings that let the +//! hosted Cloudflare Worker (wasm) consume a Method-2 collaboration share. It +//! exercises the EXACT shared `fula_crypto::sharing` core fns that the `fula-js` +//! wasm bindings (`unwrapSecretForRecipient` / `describeSharedFile` / +//! `decryptSharedFileSingleBlock` / `decryptSharedFileChunked`) call, so a green +//! KAT here means a token produced by FxFiles (the A6 `wrap_secret_for_recipient` +//! producer) or an owner file written by FxFiles/web is byte-for-byte readable by +//! the Worker — and ONLY by the addressed recipient. +//! +//! These are real KNOWN-ANSWER tests, not vacuous round-trips: every case pins +//! FIXED inputs (recipient secret bytes, link secret, file DEK, nonce, storage +//! key, plaintext) and asserts the recovered bytes equal a LITERAL expected +//! value. Because AES-GCM ciphertext never equals its plaintext, a decrypt that +//! merely echoed its input would FAIL these assertions. (Token `id`/`created_at` +//! are random, so the token JSON itself is non-deterministic — hence we pin the +//! recovered PLAINTEXT, not the token bytes, exactly as the design review +//! prescribed.) + +use base64::Engine as _; +use fula_crypto::sharing::{ + decrypt_shared_file_chunked, decrypt_shared_file_single_block, describe_shared_file, + unwrap_secret_for_recipient, wrap_secret_for_recipient, ShareBuilder, +}; +use fula_crypto::{Aead, ChunkedEncoder, DekKey, KekKeyPair, Nonce, PublicKey, SecretKey, ShareToken}; + +// ── Fixed KAT vectors ──────────────────────────────────────────────────────── + +/// The recipient's (Worker's) X25519 secret key — fixed so the recipient side is +/// fully deterministic. +const RECIPIENT_SK: [u8; 32] = [0x55u8; 32]; + +/// A DIFFERENT recipient's secret key (the "stranger" who must NOT decrypt). +const STRANGER_SK: [u8; 32] = [0x66u8; 32]; + +/// The link secret a producer wraps for the recipient (`0x00..=0x1f`). This is +/// the literal the unwrap must recover verbatim. +const LINK_SECRET: [u8; 32] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, +]; + +/// The owner file's DEK (fixed), carried encrypted inside the share token. +const FILE_DEK: [u8; 32] = [0xa7u8; 32]; + +/// A fixed 12-byte AES-GCM nonce for the single-block vectors. +const FILE_NONCE: [u8; 12] = [0x24u8; 12]; + +/// Serialize -> deserialize a token, mirroring the JSON wire form the wasm +/// bindings receive. +fn over_the_wire(token: &ShareToken) -> ShareToken { + let json = serde_json::to_string(token).expect("serialize token to JSON"); + serde_json::from_str(&json).expect("deserialize token from JSON") +} + +fn recipient_pub() -> PublicKey { + SecretKey::from_bytes(&RECIPIENT_SK).unwrap().public_key() +} + +// ════════════════════════════════════════════════════════════════════════════ +// 1. Link-secret unwrap (consumer half of `wrapSecretForRecipient`) +// ════════════════════════════════════════════════════════════════════════════ + +#[test] +fn kat_unwrap_recovers_exact_link_secret() { + let recipient_pub = recipient_pub(); + + // PRODUCER: the A6 shared core fn FxFiles / the Worker producer calls. + let token = wrap_secret_for_recipient( + &LINK_SECRET, + recipient_pub.as_bytes(), + Some("/collab/group-kat"), + Some(3600), + ) + .expect("wrap must succeed for valid 32-byte inputs"); + let token = over_the_wire(&token); + assert_eq!(token.version, 5, "must be a strict v5 token"); + + // CONSUMER: the EXACT shared core fn the `unwrapSecretForRecipient` binding + // calls — recovers the EXACT fixed link secret. + let recovered = unwrap_secret_for_recipient(&RECIPIENT_SK, &token) + .expect("the addressed recipient must recover its secret"); + assert_eq!( + recovered, LINK_SECRET, + "recovered link secret must equal the fixed 0x00..=0x1f vector" + ); + + // A DIFFERENT recipient must NOT recover it (v5 recipient-pk AAD binding). + assert!( + unwrap_secret_for_recipient(&STRANGER_SK, &token).is_err(), + "a non-addressed recipient must be rejected" + ); + + // Bad key length fails closed. + assert!(unwrap_secret_for_recipient(&[0u8; 31], &token).is_err()); + assert!(unwrap_secret_for_recipient(&[0u8; 33], &token).is_err()); +} + +#[test] +fn kat_unwrap_rejects_tampered_and_expired() { + let recipient_pub = recipient_pub(); + let token = wrap_secret_for_recipient( + &LINK_SECRET, + recipient_pub.as_bytes(), + Some("/collab/group-original"), + Some(3600), + ) + .expect("wrap must succeed"); + + // Baseline: pristine token is accepted. + assert_eq!( + unwrap_secret_for_recipient(&RECIPIENT_SK, &over_the_wire(&token)).unwrap(), + LINK_SECRET + ); + + // Mutated path_scope -> generic auth failure (v5 AAD binds every field). + { + let mut t = over_the_wire(&token); + t.path_scope = "/collab/group-WIDENED".to_string(); + assert!(unwrap_secret_for_recipient(&RECIPIENT_SK, &t).is_err()); + } + // Stretched expiry -> generic auth failure. + { + let mut t = over_the_wire(&token); + t.expires_at = Some(t.expires_at.unwrap() + 86_400 * 365); + assert!(unwrap_secret_for_recipient(&RECIPIENT_SK, &t).is_err()); + } + // Born-expired token (expires_in = -3600) -> rejected fail-closed. + let expired = wrap_secret_for_recipient(&LINK_SECRET, recipient_pub.as_bytes(), None, Some(-3600)) + .expect("wrap itself succeeds even for a past expiry"); + assert!(unwrap_secret_for_recipient(&RECIPIENT_SK, &over_the_wire(&expired)).is_err()); +} + +// ════════════════════════════════════════════════════════════════════════════ +// 2. Owner-file decrypt — SINGLE BLOCK +// ════════════════════════════════════════════════════════════════════════════ + +/// Build a single-block owner-file share token addressed to `recipient_pub`, +/// carrying the fixed `FILE_DEK` + the given inline nonce and encryption version. +fn build_single_block_token( + recipient_pub: &PublicKey, + storage_key: &str, + nonce_b64: String, + enc_version: Option, +) -> ShareToken { + let owner = KekKeyPair::generate(); // ephemeral; accept never inspects the sender + let dek = DekKey::from_bytes(&FILE_DEK).unwrap(); + let mut builder = ShareBuilder::new(&owner, recipient_pub, &dek) + .path_scope(storage_key) + .nonce(nonce_b64); + if let Some(v) = enc_version { + builder = builder.encryption_version(v); + } + builder.build().unwrap() +} + +#[test] +fn kat_owner_single_block_v4_decrypts_to_exact_plaintext() { + let recipient_pub = recipient_pub(); + let storage_key = "obfs-kat-single-001"; + // The known-answer plaintext (includes NUL + high bytes so it can't be UTF-8 + // coincidence). + let plaintext: &[u8] = b"KAT owner single-block exact plaintext \x00\x01\xff\xfe"; + + // Producer encrypts with the v4 AAD `fula:v4:content:{storage_key}`. + let dek = DekKey::from_bytes(&FILE_DEK).unwrap(); + let nonce = Nonce::from_bytes(&FILE_NONCE).unwrap(); + let aad = format!("fula:v4:content:{storage_key}"); + let ciphertext = Aead::new_default(&dek) + .encrypt_with_aad(&nonce, plaintext, aad.as_bytes()) + .unwrap(); + let nonce_b64 = base64::engine::general_purpose::STANDARD.encode(FILE_NONCE); + + let token = over_the_wire(&build_single_block_token( + &recipient_pub, + storage_key, + nonce_b64, + Some(4), + )); + + // The binding core recovers the EXACT fixed plaintext. + let out = + decrypt_shared_file_single_block(&RECIPIENT_SK, &token, storage_key, &ciphertext).unwrap(); + assert_eq!(out.as_slice(), plaintext, "must recover the exact KAT plaintext"); + + // Wrong storage_key (AAD mismatch) fails closed. + assert!( + decrypt_shared_file_single_block(&RECIPIENT_SK, &token, "obfs-WRONG", &ciphertext).is_err(), + "a wrong storage_key (AAD) must fail authentication" + ); + // Wrong recipient fails closed. + assert!( + decrypt_shared_file_single_block(&STRANGER_SK, &token, storage_key, &ciphertext).is_err(), + "a non-addressed recipient must be rejected" + ); + // Routing a single-block share through the chunked path fails closed. + assert!( + decrypt_shared_file_chunked(&RECIPIENT_SK, &token, storage_key, vec![ciphertext.clone()]) + .is_err(), + "single-block share must not decrypt via the chunked path" + ); +} + +#[test] +fn kat_owner_single_block_version_gate_tristate() { + let recipient_pub = recipient_pub(); + let storage_key = "obfs-kat-single-version"; + let plaintext: &[u8] = b"version-gate KAT \x00\x10\x20"; + let dek = DekKey::from_bytes(&FILE_DEK).unwrap(); + let nonce = Nonce::from_bytes(&FILE_NONCE).unwrap(); + let nonce_b64 = base64::engine::general_purpose::STANDARD.encode(FILE_NONCE); + let aad = format!("fula:v4:content:{storage_key}"); + + let ct_with_aad = Aead::new_default(&dek) + .encrypt_with_aad(&nonce, plaintext, aad.as_bytes()) + .unwrap(); + let ct_no_aad = Aead::new_default(&dek).encrypt(&nonce, plaintext).unwrap(); + + // Some(>=4): AAD-bound ciphertext decrypts. + let t4 = over_the_wire(&build_single_block_token( + &recipient_pub, + storage_key, + nonce_b64.clone(), + Some(4), + )); + assert_eq!( + decrypt_shared_file_single_block(&RECIPIENT_SK, &t4, storage_key, &ct_with_aad).unwrap(), + plaintext + ); + + // Some(<4): legacy, NO AAD. + let t2 = over_the_wire(&build_single_block_token( + &recipient_pub, + storage_key, + nonce_b64.clone(), + Some(2), + )); + assert_eq!( + decrypt_shared_file_single_block(&RECIPIENT_SK, &t2, storage_key, &ct_no_aad).unwrap(), + plaintext + ); + + // None: try-AAD-then-plaintext fallback — a no-AAD ciphertext still decrypts. + let tnone = over_the_wire(&build_single_block_token( + &recipient_pub, + storage_key, + nonce_b64, + None, + )); + assert_eq!( + decrypt_shared_file_single_block(&RECIPIENT_SK, &tnone, storage_key, &ct_no_aad).unwrap(), + plaintext + ); +} + +// ════════════════════════════════════════════════════════════════════════════ +// 3. Owner-file decrypt — CHUNKED (streaming-v2) +// ════════════════════════════════════════════════════════════════════════════ + +#[test] +fn kat_owner_chunked_streaming_v2_decrypts_and_count_mismatch_fails() { + let recipient_pub = recipient_pub(); + let storage_key = "obfs-kat-chunk-001"; + let payload = vec![0xc3u8; 200_000]; // > one 64 KiB chunk ⇒ multi-chunk + + // Producer chunk-encodes with the v4 chunk AAD prefix. + let dek = DekKey::from_bytes(&FILE_DEK).unwrap(); + let prefix = format!("fula:v4:chunk:{storage_key}"); + let mut enc = + ChunkedEncoder::with_aad_and_chunk_size(dek.clone(), prefix.into_bytes(), 64 * 1024); + let mut indexed: Vec<(u32, Vec)> = enc + .update(&payload) + .unwrap() + .into_iter() + .map(|c| (c.index, c.ciphertext.to_vec())) + .collect(); + let (final_chunk, meta, _ob) = enc.finalize().unwrap(); + if let Some(c) = final_chunk { + indexed.push((c.index, c.ciphertext.to_vec())); + } + assert!(meta.num_chunks > 1, "test must be multi-chunk"); + assert_eq!(meta.format, "streaming-v2"); + + let token = over_the_wire( + &ShareBuilder::new(&KekKeyPair::generate(), &recipient_pub, &dek) + .path_scope(storage_key) + .chunked_metadata(serde_json::to_string(&meta).unwrap()) + .encryption_version(4) + .build() + .unwrap(), + ); + + // Ordered chunk ciphertexts (index == position) the Worker would have fetched. + let mut ordered = indexed.clone(); + ordered.sort_by_key(|(i, _)| *i); + let chunks: Vec> = ordered.into_iter().map(|(_, ct)| ct).collect(); + + let out = + decrypt_shared_file_chunked(&RECIPIENT_SK, &token, storage_key, chunks.clone()).unwrap(); + assert_eq!(out, payload, "chunked decrypt must recover the exact payload"); + + // Chunk-count mismatch (drop the last chunk) fails closed BEFORE any decrypt. + let mut short = chunks.clone(); + short.pop(); + assert!( + decrypt_shared_file_chunked(&RECIPIENT_SK, &token, storage_key, short).is_err(), + "a chunk-count mismatch must fail closed" + ); + + // Wrong recipient fails closed. + assert!(decrypt_shared_file_chunked(&STRANGER_SK, &token, storage_key, chunks.clone()).is_err()); + + // Routing a chunked share through the single-block path fails closed. + assert!( + decrypt_shared_file_single_block(&RECIPIENT_SK, &token, storage_key, &chunks[0]).is_err(), + "chunked share must not decrypt via the single-block path" + ); +} + +// ════════════════════════════════════════════════════════════════════════════ +// 4. describeSharedFile framing (non-secret) +// ════════════════════════════════════════════════════════════════════════════ + +#[test] +fn kat_describe_reports_framing_without_leaking_secrets() { + let recipient_pub = recipient_pub(); + + // Single-block share -> { chunked: false, num_chunks: 1, encryption_version: Some(4) }. + let nonce_b64 = base64::engine::general_purpose::STANDARD.encode(FILE_NONCE); + let single = over_the_wire(&build_single_block_token( + &recipient_pub, + "obfs-desc-single", + nonce_b64, + Some(4), + )); + let f = describe_shared_file(&RECIPIENT_SK, &single).unwrap(); + assert!(!f.chunked); + assert_eq!(f.num_chunks, 1); + assert_eq!(f.encryption_version, Some(4)); + + // Chunked share -> { chunked: true, num_chunks: N>1, encryption_version: Some(4) }. + let storage_key = "obfs-desc-chunk"; + let payload = vec![0x9bu8; 200_000]; + let dek = DekKey::from_bytes(&FILE_DEK).unwrap(); + let prefix = format!("fula:v4:chunk:{storage_key}"); + let mut enc = + ChunkedEncoder::with_aad_and_chunk_size(dek.clone(), prefix.into_bytes(), 64 * 1024); + let _ = enc.update(&payload).unwrap(); + let (_final, meta, _ob) = enc.finalize().unwrap(); + let chunked = over_the_wire( + &ShareBuilder::new(&KekKeyPair::generate(), &recipient_pub, &dek) + .path_scope(storage_key) + .chunked_metadata(serde_json::to_string(&meta).unwrap()) + .encryption_version(4) + .build() + .unwrap(), + ); + let f = describe_shared_file(&RECIPIENT_SK, &chunked).unwrap(); + assert!(f.chunked); + assert_eq!(f.num_chunks, meta.num_chunks); + assert!(f.num_chunks > 1); + + // A stranger cannot even read the framing (it requires accepting the share). + assert!(describe_shared_file(&STRANGER_SK, &chunked).is_err()); +} + +// ════════════════════════════════════════════════════════════════════════════ +// 5. Version-downgrade guard (security): encryption_version is bound in the v5 AAD +// ════════════════════════════════════════════════════════════════════════════ + +#[test] +fn kat_owner_single_block_rejects_version_downgrade() { + let recipient_pub = recipient_pub(); + let storage_key = "obfs-kat-downgrade"; + let plaintext: &[u8] = b"downgrade-guard KAT payload \x00\xff"; + let dek = DekKey::from_bytes(&FILE_DEK).unwrap(); + let nonce = Nonce::from_bytes(&FILE_NONCE).unwrap(); + let nonce_b64 = base64::engine::general_purpose::STANDARD.encode(FILE_NONCE); + let aad = format!("fula:v4:content:{storage_key}"); + let ciphertext = Aead::new_default(&dek) + .encrypt_with_aad(&nonce, plaintext, aad.as_bytes()) + .unwrap(); + + // A pristine Some(4) token decrypts. + let good = over_the_wire(&build_single_block_token( + &recipient_pub, + storage_key, + nonce_b64, + Some(4), + )); + assert_eq!( + decrypt_shared_file_single_block(&RECIPIENT_SK, &good, storage_key, &ciphertext).unwrap(), + plaintext + ); + + // Downgrade Some(4) -> None on the wire: encryption_version is bound in the v5 AAD, + // so acceptance fails (the no-AAD decrypt arm is NEVER reached). + { + let mut t = over_the_wire(&good); + t.encryption_version = None; + assert!( + decrypt_shared_file_single_block(&RECIPIENT_SK, &t, storage_key, &ciphertext).is_err(), + "Some(4)->None downgrade must be rejected (version is in the v5 AAD)" + ); + assert!(unwrap_secret_for_recipient(&RECIPIENT_SK, &t).is_err()); + } + // Downgrade Some(4) -> Some(2) likewise rejected. + { + let mut t = over_the_wire(&good); + t.encryption_version = Some(2); + assert!( + decrypt_shared_file_single_block(&RECIPIENT_SK, &t, storage_key, &ciphertext).is_err(), + "Some(4)->Some(2) downgrade must be rejected" + ); + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// 6. Edge cases: empty plaintext + single-chunk chunked +// ════════════════════════════════════════════════════════════════════════════ + +#[test] +fn kat_owner_single_block_empty_plaintext() { + let recipient_pub = recipient_pub(); + let storage_key = "obfs-kat-empty"; + let dek = DekKey::from_bytes(&FILE_DEK).unwrap(); + let nonce = Nonce::from_bytes(&FILE_NONCE).unwrap(); + let nonce_b64 = base64::engine::general_purpose::STANDARD.encode(FILE_NONCE); + let aad = format!("fula:v4:content:{storage_key}"); + let ciphertext = Aead::new_default(&dek) + .encrypt_with_aad(&nonce, b"", aad.as_bytes()) + .unwrap(); + let token = over_the_wire(&build_single_block_token( + &recipient_pub, + storage_key, + nonce_b64, + Some(4), + )); + // Empty plaintext round-trips (the AEAD tag is still verified). + assert_eq!( + decrypt_shared_file_single_block(&RECIPIENT_SK, &token, storage_key, &ciphertext).unwrap(), + b"" + ); + // Tag enforcement holds even for empty plaintext: a wrong storage_key (AAD) fails. + assert!( + decrypt_shared_file_single_block(&RECIPIENT_SK, &token, "obfs-WRONG", &ciphertext).is_err() + ); +} + +#[test] +fn kat_owner_chunked_single_chunk_uses_per_chunk_aad() { + let recipient_pub = recipient_pub(); + let storage_key = "obfs-kat-1chunk"; + let payload = vec![0x4du8; 1000]; // < 64 KiB ⇒ exactly one chunk + let dek = DekKey::from_bytes(&FILE_DEK).unwrap(); + let prefix = format!("fula:v4:chunk:{storage_key}"); + let mut enc = + ChunkedEncoder::with_aad_and_chunk_size(dek.clone(), prefix.into_bytes(), 64 * 1024); + let mut indexed: Vec<(u32, Vec)> = enc + .update(&payload) + .unwrap() + .into_iter() + .map(|c| (c.index, c.ciphertext.to_vec())) + .collect(); + let (final_chunk, meta, _ob) = enc.finalize().unwrap(); + if let Some(c) = final_chunk { + indexed.push((c.index, c.ciphertext.to_vec())); + } + assert_eq!(meta.num_chunks, 1, "test must be exactly one chunk"); + let token = over_the_wire( + &ShareBuilder::new(&KekKeyPair::generate(), &recipient_pub, &dek) + .path_scope(storage_key) + .chunked_metadata(serde_json::to_string(&meta).unwrap()) + .encryption_version(4) + .build() + .unwrap(), + ); + let mut ordered = indexed.clone(); + ordered.sort_by_key(|(i, _)| *i); + let chunks: Vec> = ordered.into_iter().map(|(_, ct)| ct).collect(); + let out = decrypt_shared_file_chunked(&RECIPIENT_SK, &token, storage_key, chunks).unwrap(); + assert_eq!(out, payload, "single-chunk chunked must use per-chunk AAD and round-trip"); +} From 07789d91f970ecf661c05edda8c5ec59b09c74db Mon Sep 17 00:00:00 2001 From: ehsan shariati Date: Fri, 26 Jun 2026 14:47:01 -0400 Subject: [PATCH 4/4] chore(release): bump fula_client/workspace 0.6.18 -> 0.6.19 Releases the Method-2 recipient bindings (unwrapSecretForRecipient + owner-file decrypt) for the hosted Worker. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 18 +++++++++--------- Cargo.toml | 2 +- crates/fula-flutter/Cargo.toml | 2 +- crates/fula-js/Cargo.toml | 2 +- packages/fula_client/ios/fula_client.podspec | 2 +- packages/fula_client/pubspec.yaml | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 92018fc..a0e8512 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1776,7 +1776,7 @@ dependencies = [ [[package]] name = "fula-api" -version = "0.6.18" +version = "0.6.19" dependencies = [ "anyhow", "axum", @@ -1805,7 +1805,7 @@ dependencies = [ [[package]] name = "fula-blockstore" -version = "0.6.18" +version = "0.6.19" dependencies = [ "anyhow", "async-trait", @@ -1846,7 +1846,7 @@ dependencies = [ [[package]] name = "fula-cli" -version = "0.6.18" +version = "0.6.19" dependencies = [ "anyhow", "async-trait", @@ -1900,7 +1900,7 @@ dependencies = [ [[package]] name = "fula-client" -version = "0.6.18" +version = "0.6.19" dependencies = [ "anyhow", "async-trait", @@ -1943,7 +1943,7 @@ dependencies = [ [[package]] name = "fula-core" -version = "0.6.18" +version = "0.6.19" dependencies = [ "anyhow", "async-trait", @@ -1978,7 +1978,7 @@ dependencies = [ [[package]] name = "fula-crypto" -version = "0.6.18" +version = "0.6.19" dependencies = [ "aes-gcm", "anyhow", @@ -2023,7 +2023,7 @@ dependencies = [ [[package]] name = "fula-flutter" -version = "0.6.18" +version = "0.6.19" dependencies = [ "anyhow", "async-lock", @@ -2047,7 +2047,7 @@ dependencies = [ [[package]] name = "fula-js" -version = "0.6.18" +version = "0.6.19" dependencies = [ "base64 0.22.1", "bytes", @@ -2068,7 +2068,7 @@ dependencies = [ [[package]] name = "fula-mcp" -version = "0.6.18" +version = "0.6.19" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index f349159..a3c8b55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,7 +80,7 @@ name = "encrypted_upload_test" path = "examples/encrypted_upload_test.rs" [workspace.package] -version = "0.6.18" +version = "0.6.19" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/functionland/fula-api" diff --git a/crates/fula-flutter/Cargo.toml b/crates/fula-flutter/Cargo.toml index cea5f9d..c498e0e 100644 --- a/crates/fula-flutter/Cargo.toml +++ b/crates/fula-flutter/Cargo.toml @@ -5,7 +5,7 @@ description = "Flutter bindings for Fula decentralized storage - works on Androi # to parse `*.workspace = true` keys in its own manifest scan. Keep # these in sync with `[workspace.package]` in the root Cargo.toml. # (Same workaround as crates/fula-js.) -version = "0.6.18" +version = "0.6.19" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/functionland/fula-api" diff --git a/crates/fula-js/Cargo.toml b/crates/fula-js/Cargo.toml index 87773e4..9f0ce40 100644 --- a/crates/fula-js/Cargo.toml +++ b/crates/fula-js/Cargo.toml @@ -4,7 +4,7 @@ description = "JavaScript/TypeScript SDK for Fula decentralized storage - WASM b # Hard-coded (not workspace-inherited) because wasm-pack <= 0.13 fails # to parse `*.workspace = true` keys in its own manifest scan. Keep # these in sync with `[workspace.package]` in the root Cargo.toml. -version = "0.6.18" +version = "0.6.19" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/functionland/fula-api" diff --git a/packages/fula_client/ios/fula_client.podspec b/packages/fula_client/ios/fula_client.podspec index 5b3bea4..e4a6e2f 100644 --- a/packages/fula_client/ios/fula_client.podspec +++ b/packages/fula_client/ios/fula_client.podspec @@ -6,7 +6,7 @@ Pod::Spec.new do |s| s.name = 'fula_client' - s.version = '0.6.18' + s.version = '0.6.19' s.summary = 'Flutter SDK for Fula decentralized storage' s.description = <<-DESC A Flutter plugin providing client-side encryption, metadata privacy, diff --git a/packages/fula_client/pubspec.yaml b/packages/fula_client/pubspec.yaml index 7a28fb6..50184fa 100644 --- a/packages/fula_client/pubspec.yaml +++ b/packages/fula_client/pubspec.yaml @@ -1,6 +1,6 @@ name: fula_client description: Flutter SDK for Fula decentralized storage with client-side encryption, metadata privacy, and secure sharing. -version: 0.6.18 +version: 0.6.19 homepage: https://fx.land repository: https://github.com/functionland/fula-api issue_tracker: https://github.com/functionland/fula-api/issues