v0.3.5: Private blob encryption, blob prefetch, intent-based filtering, crypto refactoring

Private blob encryption:
- Encrypted posts (Friends/Circle/Direct) now encrypt attachment blobs with same CEK
- Public blobs unchanged, CID computed on ciphertext for private
- decrypt_blob_for_post/get_blob_for_post for transparent decryption on retrieval

Blob prefetch:
- Pull cycle and sync_with eagerly fetch missing blobs after post sync
- prefetch_blobs_from_peer scans for missing attachments, fetches via fallback chain
- Runs outside conn_mgr lock at Node level

Crypto refactoring:
- Extracted: encrypt/decrypt_bytes_with_cek, wrap/unwrap_cek_for_recipients
- unwrap_cek_for_recipient, unwrap_group_cek, random_cek
- encrypt_post_with_cek, encrypt_post_for_group_with_cek variants
- All existing functions refactored to delegate, 19 crypto tests pass

Intent-based filtering:
- intent_kind field on PostDto ("public"/"friends"/"circle"/"direct"/"unknown")
- Feed/MyPosts filter on intentKind !== 'direct' instead of visibility
- Messages filter with backward-compatible fallback for pre-intent posts
- get_post_intent storage method

IPC updates:
- resolve_blob_data helper using get_blob_for_post with network fallback
- sanitize_download_filename prevents path traversal
- get_blob_path accepts optional post_id_hex

Website:
- Mobile hamburger nav on all pages
- Mesh/Non-mesh N1 labels in network diagnostics

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-03-20 12:44:07 -04:00
parent 0abc244ee9
commit a41b11c0b8
14 changed files with 562 additions and 325 deletions

View file

@ -1,6 +1,6 @@
[package]
name = "itsgoin-desktop"
version = "0.3.4"
version = "0.3.5"
edition = "2021"
[lib]

View file

@ -30,6 +30,8 @@ struct PostDto {
is_me: bool,
/// "public", "encrypted", or "encrypted-for-me"
visibility: String,
/// The original intent: "public", "friends", "circle", "direct", or "unknown"
intent_kind: String,
/// Decrypted plaintext if we can decrypt; None for public or if we're not a recipient
decrypted_content: Option<String>,
attachments: Vec<AttachmentDto>,
@ -158,13 +160,32 @@ async fn post_to_dto(
decrypted: Option<&str>,
node: &Node,
) -> PostDto {
let is_me = &post.author == &node.node_id;
let author_name = match node.resolve_display_name(&post.author).await {
Ok((name, _, _)) if !name.is_empty() => Some(name),
_ => None,
};
// Resolve intent kind from storage
let intent_kind = {
let storage = node.storage.lock().await;
match storage.get_post_intent(id) {
Ok(Some(intent)) => match intent {
VisibilityIntent::Public => "public".to_string(),
VisibilityIntent::Friends => "friends".to_string(),
VisibilityIntent::Circle(_) => "circle".to_string(),
VisibilityIntent::Direct(_) => "direct".to_string(),
},
_ => "unknown".to_string(),
}
};
// For own encrypted posts, show "encrypted" not "encrypted-for-me"
// since intent_kind handles DM filtering now
let (visibility, decrypted_content) = match vis {
PostVisibility::Public => ("public".to_string(), None),
PostVisibility::Encrypted { .. } | PostVisibility::GroupEncrypted { .. } => match decrypted {
Some(text) if is_me => ("encrypted".to_string(), Some(text.to_string())),
Some(text) => ("encrypted-for-me".to_string(), Some(text.to_string())),
None => ("encrypted".to_string(), None),
},
@ -203,8 +224,9 @@ async fn post_to_dto(
author_name,
content: post.content.clone(),
timestamp_ms: post.timestamp_ms,
is_me: &post.author == &node.node_id,
is_me,
visibility,
intent_kind,
decrypted_content,
attachments,
recipients,
@ -394,16 +416,95 @@ async fn create_post_with_files(
}
/// Return the filesystem path of a blob if it exists locally (for streaming video/media).
/// For non-public (encrypted) posts, returns None since raw encrypted blobs can't be served
/// via the asset protocol — the frontend must use IPC-based get_blob instead.
#[tauri::command]
async fn get_blob_path(
state: State<'_, AppState>,
cid_hex: String,
post_id_hex: Option<String>,
) -> Result<Option<String>, String> {
let node = state.inner();
let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?;
let cid: [u8; 32] = cid_bytes
.try_into()
.map_err(|_| "CID must be 32 bytes".to_string())?;
Ok(state.blob_store.file_path(&cid).map(|p| p.to_string_lossy().to_string()))
// If a post_id is provided, check if the post is encrypted — if so, can't serve raw file
if let Some(ref pid_hex) = post_id_hex {
if let Ok(pid_bytes) = hex::decode(pid_hex) {
if let Ok(post_id) = <[u8; 32]>::try_from(pid_bytes.as_slice()) {
let storage = node.storage.lock().await;
if let Ok(Some((_post, vis))) = storage.get_post_with_visibility(&post_id) {
if !matches!(vis, PostVisibility::Public) {
return Ok(None);
}
}
}
}
}
Ok(node.blob_store.file_path(&cid).map(|p| p.to_string_lossy().to_string()))
}
/// Sanitize a filename for safe download: strip path separators and control characters.
fn sanitize_download_filename(filename: &str) -> String {
filename
.replace(['/', '\\', '\0'], "_")
.chars()
.filter(|c| !c.is_control())
.collect::<String>()
}
/// Helper: resolve a blob with optional decryption via post context.
/// First tries local (with post-based decryption), then falls back to network fetch + decrypt.
async fn resolve_blob_data(
node: &Node,
cid: &[u8; 32],
post_id_hex: Option<&str>,
) -> Result<Vec<u8>, String> {
// Parse post_id if provided
let post_id = if let Some(pid_hex) = post_id_hex {
let pid_bytes = hex::decode(pid_hex).map_err(|e| e.to_string())?;
Some(<[u8; 32]>::try_from(pid_bytes.as_slice()).map_err(|_| "bad post_id".to_string())?)
} else {
None
};
// Try local blob with decryption
if let Some(ref pid) = post_id {
if let Some(data) = node.get_blob_for_post(cid, pid).await.map_err(|e| e.to_string())? {
return Ok(data);
}
} else if let Some(data) = node.get_blob(cid).await.map_err(|e| e.to_string())? {
return Ok(data);
}
// Try fetching from network if post_id provided
if let Some(pid) = post_id {
let post = {
let storage = node.storage.lock().await;
storage.get_post(&pid).map_err(|e| e.to_string())?
};
if let Some(post) = post {
let mime = post.attachments.iter()
.find(|a| a.cid == *cid)
.map(|a| a.mime_type.as_str())
.unwrap_or("application/octet-stream");
if let Some(_fetched) = node
.fetch_blob_with_fallback(cid, &pid, &post.author, mime, post.timestamp_ms)
.await
.map_err(|e| e.to_string())?
{
// Re-read with decryption
if let Some(data) = node.get_blob_for_post(cid, &pid).await.map_err(|e| e.to_string())? {
return Ok(data);
}
}
}
}
Err("blob not found".to_string())
}
/// Save a blob to the Downloads folder and open it with the system handler.
@ -420,36 +521,14 @@ async fn save_and_open_blob(
.try_into()
.map_err(|_| "CID must be 32 bytes".to_string())?;
// Get blob data (local or fetch from network)
let data = if let Some(d) = node.get_blob(&cid).await.map_err(|e| e.to_string())? {
d
} else if let Some(pid_hex) = post_id_hex {
let pid_bytes = hex::decode(&pid_hex).map_err(|e| e.to_string())?;
let post_id: [u8; 32] = pid_bytes.try_into().map_err(|_| "bad post_id".to_string())?;
let post = {
let storage = node.storage.lock().await;
storage.get_post(&post_id).map_err(|e| e.to_string())?
};
if let Some(post) = post {
let mime = post.attachments.iter()
.find(|a| a.cid == cid)
.map(|a| a.mime_type.as_str())
.unwrap_or("application/octet-stream");
node.fetch_blob_with_fallback(&cid, &post_id, &post.author, mime, post.timestamp_ms)
.await.map_err(|e| e.to_string())?
.ok_or_else(|| "blob not found".to_string())?
} else {
return Err("post not found".to_string());
}
} else {
return Err("blob not found".to_string());
};
let data = resolve_blob_data(node, &cid, post_id_hex.as_deref()).await?;
let safe_name = sanitize_download_filename(&filename);
// Save to Downloads
let downloads = dirs::download_dir()
.or_else(|| dirs::home_dir().map(|h| h.join("Downloads")))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
let dest = downloads.join(&filename);
let dest = downloads.join(&safe_name);
tokio::fs::write(&dest, &data).await.map_err(|e| e.to_string())?;
// Open with system handler
@ -472,34 +551,13 @@ async fn save_blob(
.try_into()
.map_err(|_| "CID must be 32 bytes".to_string())?;
let data = if let Some(d) = node.get_blob(&cid).await.map_err(|e| e.to_string())? {
d
} else if let Some(pid_hex) = post_id_hex {
let pid_bytes = hex::decode(&pid_hex).map_err(|e| e.to_string())?;
let post_id: [u8; 32] = pid_bytes.try_into().map_err(|_| "bad post_id".to_string())?;
let post = {
let storage = node.storage.lock().await;
storage.get_post(&post_id).map_err(|e| e.to_string())?
};
if let Some(post) = post {
let mime = post.attachments.iter()
.find(|a| a.cid == cid)
.map(|a| a.mime_type.as_str())
.unwrap_or("application/octet-stream");
node.fetch_blob_with_fallback(&cid, &post_id, &post.author, mime, post.timestamp_ms)
.await.map_err(|e| e.to_string())?
.ok_or_else(|| "blob not found".to_string())?
} else {
return Err("post not found".to_string());
}
} else {
return Err("blob not found".to_string());
};
let data = resolve_blob_data(node, &cid, post_id_hex.as_deref()).await?;
let safe_name = sanitize_download_filename(&filename);
let downloads = dirs::download_dir()
.or_else(|| dirs::home_dir().map(|h| h.join("Downloads")))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
let dest = downloads.join(&filename);
let dest = downloads.join(&safe_name);
tokio::fs::write(&dest, &data).await.map_err(|e| e.to_string())?;
Ok(dest.to_string_lossy().to_string())
@ -517,41 +575,9 @@ async fn get_blob(
.try_into()
.map_err(|_| "CID must be 32 bytes".to_string())?;
// Check local first (also touches last_accessed_at)
if let Some(data) = node.get_blob(&cid).await.map_err(|e| e.to_string())? {
use base64::Engine;
return Ok(base64::engine::general_purpose::STANDARD.encode(&data));
}
// Try fetching from author → replica peers if post_id provided
if let Some(pid_hex) = post_id_hex {
let pid_bytes = hex::decode(&pid_hex).map_err(|e| e.to_string())?;
let post_id: [u8; 32] = pid_bytes
.try_into()
.map_err(|_| "post_id must be 32 bytes".to_string())?;
let post = {
let storage = node.storage.lock().await;
storage.get_post(&post_id).map_err(|e| e.to_string())?
};
if let Some(post) = post {
// Find the mime type from the post's attachments
let mime_type = post.attachments.iter()
.find(|a| a.cid == cid)
.map(|a| a.mime_type.as_str())
.unwrap_or("application/octet-stream");
if let Some(data) = node
.fetch_blob_with_fallback(&cid, &post_id, &post.author, mime_type, post.timestamp_ms)
.await
.map_err(|e| e.to_string())?
{
use base64::Engine;
return Ok(base64::engine::general_purpose::STANDARD.encode(&data));
}
}
}
Err("blob not found".to_string())
let data = resolve_blob_data(node, &cid, post_id_hex.as_deref()).await?;
use base64::Engine;
Ok(base64::engine::general_purpose::STANDARD.encode(&data))
}
#[tauri::command]

View file

@ -1,6 +1,6 @@
{
"productName": "itsgoin",
"version": "0.3.4",
"version": "0.3.5",
"identifier": "com.itsgoin.app",
"build": {
"frontendDist": "../../frontend",