Skip to content

Commit 1b82b95

Browse files
committed
Include encryption nonce in share tokens for proxy-based decryption
1 parent 0324671 commit 1b82b95

8 files changed

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

crates/fula-client/src/encryption.rs

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,6 +1240,7 @@ impl EncryptedClient {
12401240
///
12411241
/// # Note
12421242
/// Handles both single-block and chunked files automatically.
1243+
/// Uses nonce from share token if available, otherwise falls back to metadata headers.
12431244
pub async fn get_object_with_share(
12441245
&self,
12451246
bucket: &str,
@@ -1269,7 +1270,35 @@ impl EncryptedClient {
12691270
));
12701271
}
12711272

1272-
// Fetch the object
1273+
// Check if share token includes encryption metadata (nonce or chunked info)
1274+
// If so, we can decrypt using just the raw data without needing S3 metadata headers
1275+
if accepted_share.chunked_metadata.is_some() {
1276+
// CHUNKED FILE: Use chunked metadata from share token
1277+
return self.get_object_chunked_with_share_token(bucket, storage_key, accepted_share).await;
1278+
}
1279+
1280+
if let Some(ref nonce_b64) = accepted_share.nonce {
1281+
// SINGLE FILE: Use nonce from share token - fetch raw data only
1282+
let data = self.inner.get_object(bucket, storage_key).await?;
1283+
1284+
let nonce_bytes = base64::Engine::decode(
1285+
&base64::engine::general_purpose::STANDARD,
1286+
nonce_b64,
1287+
).map_err(|e| ClientError::Encryption(
1288+
fula_crypto::CryptoError::Decryption(format!("Invalid nonce in share token: {}", e))
1289+
))?;
1290+
let nonce = Nonce::from_bytes(&nonce_bytes)
1291+
.map_err(ClientError::Encryption)?;
1292+
1293+
let aead = Aead::new_default(&accepted_share.dek);
1294+
let plaintext = aead.decrypt(&nonce, &data)
1295+
.map_err(ClientError::Encryption)?;
1296+
1297+
return Ok(Bytes::from(plaintext));
1298+
}
1299+
1300+
// FALLBACK: Share token doesn't have encryption metadata, try to get it from S3 headers
1301+
// This is for backwards compatibility with old share tokens
12731302
let result = self.inner.get_object_with_metadata(bucket, storage_key).await?;
12741303

12751304
// Check if encrypted
@@ -1288,11 +1317,11 @@ impl EncryptedClient {
12881317
.map(|v| v == "true")
12891318
.unwrap_or(false);
12901319

1291-
// Parse encryption metadata
1320+
// Parse encryption metadata from S3 headers
12921321
let enc_metadata_str = result.metadata
12931322
.get("x-fula-encryption")
12941323
.ok_or_else(|| ClientError::Encryption(
1295-
fula_crypto::CryptoError::Decryption("Missing encryption metadata".to_string())
1324+
fula_crypto::CryptoError::Decryption("Missing encryption metadata (not in share token or S3 headers)".to_string())
12961325
))?;
12971326

12981327
let enc_metadata: serde_json::Value = serde_json::from_str(enc_metadata_str)
@@ -1302,12 +1331,12 @@ impl EncryptedClient {
13021331

13031332
if is_chunked {
13041333
// CHUNKED DOWNLOAD: Download and decrypt each chunk using the share's DEK
1305-
self.get_object_chunked_with_share(bucket, storage_key, &enc_metadata, &accepted_share.dek).await
1334+
self.get_object_chunked_with_share_metadata(bucket, storage_key, &enc_metadata, &accepted_share.dek).await
13061335
} else {
13071336
// SINGLE OBJECT: Decrypt directly
13081337
let nonce_b64 = enc_metadata["nonce"].as_str()
13091338
.ok_or_else(|| ClientError::Encryption(
1310-
fula_crypto::CryptoError::Decryption("Missing nonce".to_string())
1339+
fula_crypto::CryptoError::Decryption("Missing nonce in encryption metadata".to_string())
13111340
))?;
13121341
let nonce_bytes = base64::Engine::decode(
13131342
&base64::engine::general_purpose::STANDARD,
@@ -1327,8 +1356,46 @@ impl EncryptedClient {
13271356
}
13281357
}
13291358

1330-
/// Internal: Download and decrypt a chunked file using a share's DEK
1331-
async fn get_object_chunked_with_share(
1359+
/// Internal: Download and decrypt a chunked file using metadata from share token
1360+
async fn get_object_chunked_with_share_token(
1361+
&self,
1362+
bucket: &str,
1363+
storage_key: &str,
1364+
accepted_share: &AcceptedShare,
1365+
) -> Result<Bytes> {
1366+
let chunked_json = accepted_share.chunked_metadata.as_ref()
1367+
.ok_or_else(|| ClientError::Encryption(
1368+
fula_crypto::CryptoError::Decryption("Missing chunked metadata in share token".to_string())
1369+
))?;
1370+
1371+
let chunked_meta: ChunkedFileMetadata = serde_json::from_str(chunked_json)
1372+
.map_err(|e| ClientError::Encryption(
1373+
fula_crypto::CryptoError::Decryption(format!("Invalid chunked metadata in share token: {}", e))
1374+
))?;
1375+
1376+
// Create decoder
1377+
let mut decoder = fula_crypto::ChunkedDecoder::new(accepted_share.dek.clone(), chunked_meta.clone());
1378+
1379+
// Pre-allocate result buffer
1380+
let mut plaintext = Vec::with_capacity(chunked_meta.total_size as usize);
1381+
1382+
// Download and decrypt each chunk in order
1383+
for chunk_index in 0..chunked_meta.num_chunks {
1384+
let chunk_key = ChunkedFileMetadata::chunk_key(storage_key, chunk_index);
1385+
1386+
let chunk_result = self.inner.get_object(bucket, &chunk_key).await?;
1387+
1388+
let chunk_plaintext = decoder.decrypt_chunk(chunk_index, &chunk_result)
1389+
.map_err(ClientError::Encryption)?;
1390+
1391+
plaintext.extend_from_slice(&chunk_plaintext);
1392+
}
1393+
1394+
Ok(Bytes::from(plaintext))
1395+
}
1396+
1397+
/// Internal: Download and decrypt a chunked file using metadata from S3 headers
1398+
async fn get_object_chunked_with_share_metadata(
13321399
&self,
13331400
bucket: &str,
13341401
storage_key: &str,

crates/fula-crypto/src/sharing.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,13 @@ pub struct ShareToken {
160160
/// Snapshot binding (required for Snapshot mode)
161161
#[serde(skip_serializing_if = "Option::is_none")]
162162
pub snapshot_binding: Option<SnapshotBinding>,
163+
/// Encryption nonce (base64 encoded) - included so recipient can decrypt
164+
/// without needing to fetch metadata headers from S3
165+
#[serde(skip_serializing_if = "Option::is_none")]
166+
pub nonce: Option<String>,
167+
/// Chunked file metadata (JSON) - for files > 768KB that use per-chunk nonces
168+
#[serde(skip_serializing_if = "Option::is_none")]
169+
pub chunked_metadata: Option<String>,
163170
}
164171

165172
impl ShareToken {
@@ -264,6 +271,10 @@ pub struct ShareBuilder<'a> {
264271
permissions: SharePermissions,
265272
mode: ShareMode,
266273
snapshot_binding: Option<SnapshotBinding>,
274+
/// Encryption nonce (base64) for single-block files
275+
nonce: Option<String>,
276+
/// Chunked file metadata (JSON) for files > 768KB
277+
chunked_metadata: Option<String>,
267278
}
268279

269280
impl<'a> ShareBuilder<'a> {
@@ -282,9 +293,25 @@ impl<'a> ShareBuilder<'a> {
282293
permissions: SharePermissions::read_only(),
283294
mode: ShareMode::Temporal,
284295
snapshot_binding: None,
296+
nonce: None,
297+
chunked_metadata: None,
285298
}
286299
}
287300

301+
/// Set the encryption nonce (base64 encoded) for single-block files
302+
/// This allows recipients to decrypt without needing S3 metadata headers
303+
pub fn nonce(mut self, nonce_b64: impl Into<String>) -> Self {
304+
self.nonce = Some(nonce_b64.into());
305+
self
306+
}
307+
308+
/// Set chunked file metadata (JSON) for files > 768KB
309+
/// This allows recipients to decrypt chunked files without needing S3 metadata headers
310+
pub fn chunked_metadata(mut self, metadata_json: impl Into<String>) -> Self {
311+
self.chunked_metadata = Some(metadata_json.into());
312+
self
313+
}
314+
288315
/// Set the path scope for this share
289316
pub fn path_scope(mut self, path: impl Into<String>) -> Self {
290317
self.path_scope = path.into();
@@ -381,9 +408,11 @@ impl<'a> ShareBuilder<'a> {
381408
expires_at: self.expires_at,
382409
created_at: current_timestamp(),
383410
permissions: self.permissions,
384-
version: 2, // Bump version for new format with mode
411+
version: 3, // Bump version for new format with nonce
385412
mode: self.mode,
386413
snapshot_binding: self.snapshot_binding,
414+
nonce: self.nonce,
415+
chunked_metadata: self.chunked_metadata,
387416
})
388417
}
389418
}
@@ -543,6 +572,8 @@ impl ShareRecipient {
543572
path_scope: token.path_scope.clone(),
544573
expires_at: token.expires_at,
545574
permissions: token.permissions,
575+
nonce: token.nonce.clone(),
576+
chunked_metadata: token.chunked_metadata.clone(),
546577
})
547578
}
548579
}
@@ -557,6 +588,10 @@ pub struct AcceptedShare {
557588
pub expires_at: Option<i64>,
558589
/// Permissions
559590
pub permissions: SharePermissions,
591+
/// Encryption nonce (base64 encoded) - for single-block files
592+
pub nonce: Option<String>,
593+
/// Chunked file metadata (JSON) - for files > 768KB
594+
pub chunked_metadata: Option<String>,
560595
}
561596

562597
impl AcceptedShare {

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ pub async fn create_share_token(
7878
builder = builder.expires_at(ts);
7979
}
8080

81+
// Include nonce in share token so recipient can decrypt without S3 metadata headers
82+
if let Some(nonce_str) = enc_metadata["nonce"].as_str() {
83+
builder = builder.nonce(nonce_str);
84+
}
85+
86+
// Include chunked metadata for large files (> 768KB)
87+
if enc_metadata.get("chunked").is_some() {
88+
let chunked_json = serde_json::to_string(&enc_metadata["chunked"])
89+
.map_err(|e| anyhow::anyhow!("Failed to serialize chunked metadata: {}", e))?;
90+
builder = builder.chunked_metadata(chunked_json);
91+
}
92+
8193
let token = builder.build()
8294
.map_err(|e| anyhow::anyhow!("Failed to build share token: {}", e))?;
8395

@@ -171,6 +183,22 @@ pub async fn create_share_token_with_mode(
171183
builder
172184
};
173185

186+
// Include nonce in share token so recipient can decrypt without S3 metadata headers
187+
let builder = if let Some(nonce_str) = enc_metadata["nonce"].as_str() {
188+
builder.nonce(nonce_str)
189+
} else {
190+
builder
191+
};
192+
193+
// Include chunked metadata for large files (> 768KB)
194+
let builder = if enc_metadata.get("chunked").is_some() {
195+
let chunked_json = serde_json::to_string(&enc_metadata["chunked"])
196+
.map_err(|e| anyhow::anyhow!("Failed to serialize chunked metadata: {}", e))?;
197+
builder.chunked_metadata(chunked_json)
198+
} else {
199+
builder
200+
};
201+
174202
let token = builder.build()
175203
.map_err(|e| anyhow::anyhow!("Failed to build share token: {}", e))?;
176204

packages/fula_client/CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.2.17] - 2026-01-13
9+
10+
### Fixed
11+
12+
- **CRITICAL: Share tokens missing encryption nonce - decryption produces garbage**
13+
- Share tokens only contained wrapped DEK but not the nonce needed for decryption
14+
- Web UI proxy doesn't forward S3 metadata headers (`x-fula-encryption`)
15+
- Without the nonce, decryption "succeeds" but produces garbage data
16+
- **Fix**: Share tokens now include `nonce` (for single-block files) and `chunked_metadata` (for chunked files)
17+
- Recipients can now decrypt using just the share token without needing S3 metadata headers
18+
19+
### Changed
20+
21+
- `ShareToken` struct now includes optional `nonce` and `chunked_metadata` fields
22+
- `ShareBuilder` has new `.nonce()` and `.chunked_metadata()` builder methods
23+
- `AcceptedShare` now carries nonce and chunked metadata through to decryption
24+
- `get_object_with_share` uses nonce from share token if available, falls back to S3 headers for backwards compatibility
25+
- Share token version bumped to 3
26+
27+
### Migration Guide for FxFiles
28+
29+
Share tokens created with v0.2.17+ will automatically include the nonce.
30+
No code changes needed - just rebuild FxFiles with the new fula_client SDK.
31+
32+
Old share tokens (without nonce) will continue to work if the proxy forwards S3 headers correctly.
33+
834
## [0.2.16] - 2026-01-13
935

1036
### Fixed

packages/fula_client/ios/fula_client.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
Pod::Spec.new do |s|
88
s.name = 'fula_client'
9-
s.version = '0.2.16'
9+
s.version = '0.2.17'
1010
s.summary = 'Flutter SDK for Fula decentralized storage'
1111
s.description = <<-DESC
1212
A Flutter plugin providing client-side encryption, metadata privacy,

packages/fula_client/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: fula_client
22
description: Flutter SDK for Fula decentralized storage with client-side encryption, metadata privacy, and secure sharing.
3-
version: 0.2.16
3+
version: 0.2.17
44
homepage: https://fx.land
55
repository: https://github.com/functionland/fula-api
66
issue_tracker: https://github.com/functionland/fula-api/issues

0 commit comments

Comments
 (0)