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

@ -522,24 +522,20 @@ impl Node {
}
}
// Store blob files and build attachment metadata (DB records deferred until post_id known)
let mut attachments = Vec::with_capacity(attachment_data.len());
for (data, mime) in &attachment_data {
let cid = crate::blob::compute_blob_id(data);
self.blob_store.store(&cid, data)?;
attachments.push(Attachment {
cid,
mime_type: mime.clone(),
size_bytes: data.len() as u64,
});
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
let (final_content, visibility) = match &intent {
VisibilityIntent::Public => (content, PostVisibility::Public),
// Determine encryption parameters and generate CEK if needed.
// The CEK is generated BEFORE both content and blob encryption so they share the same key.
enum EncryptionMode {
Public,
Recipient { cek: [u8; 32], recipients: Vec<NodeId> },
Group { cek: [u8; 32], group_id: [u8; 32], epoch: u64, group_seed: [u8; 32], group_pubkey: [u8; 32] },
}
let mode = match &intent {
VisibilityIntent::Public => EncryptionMode::Public,
VisibilityIntent::Circle(circle_name) => {
// Try group encryption first
let group_info = {
@ -551,30 +547,17 @@ impl Node {
})
};
if let Some((group_id, epoch, group_seed, group_pubkey)) = group_info {
let (encrypted, wrapped_cek) =
crypto::encrypt_post_for_group(&content, &group_seed, &group_pubkey)?;
(
encrypted,
PostVisibility::GroupEncrypted {
group_id,
epoch,
wrapped_cek,
},
)
let mut cek = [0u8; 32];
rand::RngCore::fill_bytes(&mut rand::rng(), &mut cek);
EncryptionMode::Group { cek, group_id, epoch, group_seed, group_pubkey }
} else {
// Fallback to per-recipient encryption
let recipients = self.resolve_recipients(&intent).await?;
if recipients.is_empty() {
anyhow::bail!("no recipients resolved for this visibility");
}
let (encrypted, wrapped_keys) =
crypto::encrypt_post(&content, &self.secret_seed, &self.node_id, &recipients)?;
(
encrypted,
PostVisibility::Encrypted {
recipients: wrapped_keys,
},
)
let mut cek = [0u8; 32];
rand::RngCore::fill_bytes(&mut rand::rng(), &mut cek);
EncryptionMode::Recipient { cek, recipients }
}
}
_ => {
@ -582,8 +565,41 @@ impl Node {
if recipients.is_empty() {
anyhow::bail!("no recipients resolved for this visibility");
}
let mut cek = [0u8; 32];
rand::RngCore::fill_bytes(&mut rand::rng(), &mut cek);
EncryptionMode::Recipient { cek, recipients }
}
};
// Store blob files — for encrypted posts, encrypt each blob with the shared CEK.
// CID is computed on the ciphertext so peers can verify what they store.
let mut attachments = Vec::with_capacity(attachment_data.len());
for (data, mime) in &attachment_data {
let (store_data, size) = match &mode {
EncryptionMode::Public => {
(data.clone(), data.len() as u64)
}
EncryptionMode::Recipient { cek, .. } | EncryptionMode::Group { cek, .. } => {
let encrypted = crypto::encrypt_bytes_with_cek(data, cek)?;
let sz = encrypted.len() as u64;
(encrypted, sz)
}
};
let cid = crate::blob::compute_blob_id(&store_data);
self.blob_store.store(&cid, &store_data)?;
attachments.push(Attachment {
cid,
mime_type: mime.clone(),
size_bytes: size,
});
}
// Encrypt content and build visibility
let (final_content, visibility) = match mode {
EncryptionMode::Public => (content, PostVisibility::Public),
EncryptionMode::Recipient { cek, recipients } => {
let (encrypted, wrapped_keys) =
crypto::encrypt_post(&content, &self.secret_seed, &self.node_id, &recipients)?;
crypto::encrypt_post_with_cek(&content, &cek, &self.secret_seed, &self.node_id, &recipients)?;
(
encrypted,
PostVisibility::Encrypted {
@ -591,6 +607,18 @@ impl Node {
},
)
}
EncryptionMode::Group { cek, group_id, epoch, group_seed, group_pubkey } => {
let (encrypted, wrapped_cek) =
crypto::encrypt_post_for_group_with_cek(&content, &cek, &group_seed, &group_pubkey)?;
(
encrypted,
PostVisibility::GroupEncrypted {
group_id,
epoch,
wrapped_cek,
},
)
}
};
let post = Post {
@ -969,6 +997,136 @@ impl Node {
Ok(data)
}
/// Decrypt a blob in the context of a post's visibility.
/// Public posts pass through unchanged. Encrypted/group-encrypted posts decrypt with the CEK.
fn decrypt_blob_for_post(
&self,
data: Vec<u8>,
post: &Post,
visibility: &PostVisibility,
group_seeds: &std::collections::HashMap<([u8; 32], u64), ([u8; 32], [u8; 32])>,
) -> anyhow::Result<Option<Vec<u8>>> {
match visibility {
PostVisibility::Public => Ok(Some(data)),
PostVisibility::Encrypted { recipients } => {
let cek = crypto::unwrap_cek_for_recipient(
&self.secret_seed,
&self.node_id,
&post.author,
recipients,
)?;
match cek {
Some(cek) => {
let plaintext = crypto::decrypt_bytes_with_cek(&data, &cek)?;
Ok(Some(plaintext))
}
None => Ok(None),
}
}
PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek } => {
if let Some((seed, pubkey)) = group_seeds.get(&(*group_id, *epoch)) {
let cek = crypto::unwrap_group_cek(seed, pubkey, wrapped_cek)?;
let plaintext = crypto::decrypt_bytes_with_cek(&data, &cek)?;
Ok(Some(plaintext))
} else {
Ok(None)
}
}
}
}
/// Get a blob by CID, decrypting it in the context of the given post.
/// For public posts, returns raw blob data. For encrypted posts, decrypts with the post's CEK.
pub async fn get_blob_for_post(
&self,
cid: &[u8; 32],
post_id: &PostId,
) -> anyhow::Result<Option<Vec<u8>>> {
// Get raw blob data (local)
let raw_data = match self.blob_store.get(cid)? {
Some(d) => d,
None => return Ok(None),
};
{
let storage = self.storage.lock().await;
let _ = storage.touch_blob_access(cid);
}
// Get post + visibility
let (post, visibility) = {
let storage = self.storage.lock().await;
match storage.get_post_with_visibility(post_id)? {
Some(pv) => pv,
None => return Ok(Some(raw_data)), // No post context — return raw
}
};
match &visibility {
PostVisibility::Public => Ok(Some(raw_data)),
PostVisibility::Encrypted { .. } => {
let empty_map = std::collections::HashMap::new();
self.decrypt_blob_for_post(raw_data, &post, &visibility, &empty_map)
}
PostVisibility::GroupEncrypted { .. } => {
let group_seeds = {
let storage = self.storage.lock().await;
storage.get_all_group_seeds_map().unwrap_or_default()
};
self.decrypt_blob_for_post(raw_data, &post, &visibility, &group_seeds)
}
}
}
/// Prefetch blobs for recently synced posts from a peer.
/// Queries storage for posts with attachments missing from the local blob store,
/// then fetches each missing blob. Runs outside any locks.
pub async fn prefetch_blobs_from_peer(&self, peer_id: &NodeId) {
// Gather posts with missing blobs
let missing: Vec<(PostId, NodeId, Vec<crate::types::Attachment>)> = {
let storage = self.storage.lock().await;
let post_ids = storage.list_post_ids().unwrap_or_default();
let mut result = Vec::new();
for pid in post_ids {
if let Ok(Some(post)) = storage.get_post(&pid) {
let missing_atts: Vec<_> = post.attachments.iter()
.filter(|a| !self.blob_store.has(&a.cid))
.cloned()
.collect();
if !missing_atts.is_empty() {
result.push((pid, post.author, missing_atts));
}
}
}
result
};
if missing.is_empty() {
return;
}
let mut fetched = 0usize;
for (post_id, author, attachments) in &missing {
for att in attachments {
match self.fetch_blob_with_fallback(
&att.cid, post_id, author, &att.mime_type, 0,
).await {
Ok(Some(_)) => { fetched += 1; }
Ok(None) => {}
Err(e) => {
tracing::debug!(
cid = hex::encode(att.cid),
error = %e,
"Blob prefetch failed"
);
}
}
}
}
if fetched > 0 {
tracing::info!(fetched, peer = hex::encode(peer_id), "Prefetched blobs after sync");
}
}
/// Check if a blob exists locally.
pub fn has_blob(&self, cid: &[u8; 32]) -> bool {
self.blob_store.has(cid)
@ -2049,6 +2207,10 @@ impl Node {
engagement_headers = engagement,
"Sync complete"
);
// Prefetch blobs for posts we just received
if stats.posts_received > 0 {
self.prefetch_blobs_from_peer(&peer_id).await;
}
Ok(())
}
@ -2115,15 +2277,15 @@ impl Node {
tokio::spawn(async move { network.run_accept_loop().await })
}
/// Start pull cycle: every interval_secs, pull from connected peers.
pub fn start_pull_cycle(&self, interval_secs: u64) -> tokio::task::JoinHandle<()> {
let network = Arc::clone(&self.network);
/// Start pull cycle: every interval_secs, pull from connected peers + prefetch blobs.
pub fn start_pull_cycle(self: &Arc<Self>, interval_secs: u64) -> tokio::task::JoinHandle<()> {
let node = Arc::clone(self);
tokio::spawn(async move {
let mut interval =
tokio::time::interval(std::time::Duration::from_secs(interval_secs));
loop {
interval.tick().await;
match network.pull_from_all().await {
match node.network.pull_from_all().await {
Ok(stats) => {
if stats.posts_received > 0 {
tracing::debug!(
@ -2131,6 +2293,11 @@ impl Node {
peers = stats.peers_pulled,
"Pull cycle complete"
);
// Prefetch blobs for newly received posts
let peers = node.network.conn_handle().connected_peers().await;
for peer_id in peers {
node.prefetch_blobs_from_peer(&peer_id).await;
}
}
}
Err(e) => {