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

@ -37,40 +37,55 @@ fn derive_wrapping_key(shared_secret: &[u8; 32]) -> [u8; 32] {
blake3::derive_key(CEK_WRAP_CONTEXT, shared_secret)
}
/// Encrypt a post's plaintext content for the given recipients.
///
/// Returns `(base64_ciphertext, Vec<WrappedKey>)` where:
/// - base64_ciphertext is `base64(nonce(12) || ciphertext || tag(16))` for the content
/// - Each WrappedKey contains the CEK encrypted for one recipient
///
/// The author (our_seed's corresponding NodeId) is always included as a recipient.
pub fn encrypt_post(
plaintext: &str,
// --- Crypto primitives ---
/// Generate a random 32-byte Content Encryption Key (CEK).
fn random_cek() -> [u8; 32] {
let mut cek = [0u8; 32];
rand::rng().fill_bytes(&mut cek);
cek
}
/// Encrypt arbitrary bytes with a CEK using ChaCha20-Poly1305.
/// Returns `nonce(12) || ciphertext || tag(16)`.
pub fn encrypt_bytes_with_cek(bytes: &[u8], cek: &[u8; 32]) -> Result<Vec<u8>> {
let cipher = ChaCha20Poly1305::new_from_slice(cek)
.map_err(|e| anyhow::anyhow!("cipher init: {}", e))?;
let mut nonce_bytes = [0u8; 12];
rand::rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, bytes)
.map_err(|e| anyhow::anyhow!("encrypt: {}", e))?;
let mut payload = Vec::with_capacity(12 + ciphertext.len());
payload.extend_from_slice(&nonce_bytes);
payload.extend_from_slice(&ciphertext);
Ok(payload)
}
/// Decrypt bytes that were encrypted with `encrypt_bytes_with_cek`.
/// Expects `nonce(12) || ciphertext || tag(16)`.
pub fn decrypt_bytes_with_cek(payload: &[u8], cek: &[u8; 32]) -> Result<Vec<u8>> {
if payload.len() < 12 + 16 {
bail!("encrypted payload too short");
}
let nonce = Nonce::from_slice(&payload[..12]);
let cipher = ChaCha20Poly1305::new_from_slice(cek)
.map_err(|e| anyhow::anyhow!("cipher init: {}", e))?;
let plaintext = cipher
.decrypt(nonce, &payload[12..])
.map_err(|e| anyhow::anyhow!("decrypt: {}", e))?;
Ok(plaintext)
}
/// Wrap a CEK for a set of recipients using X25519 DH.
/// The author (our_node_id) is always included.
fn wrap_cek_for_recipients(
cek: &[u8; 32],
our_seed: &[u8; 32],
our_node_id: &NodeId,
recipients: &[NodeId],
) -> Result<(String, Vec<WrappedKey>)> {
// Generate random 32-byte Content Encryption Key
let mut cek = [0u8; 32];
rand::rng().fill_bytes(&mut cek);
// Encrypt content with CEK using ChaCha20-Poly1305
let content_cipher = ChaCha20Poly1305::new_from_slice(&cek)
.map_err(|e| anyhow::anyhow!("cipher init: {}", e))?;
let mut content_nonce_bytes = [0u8; 12];
rand::rng().fill_bytes(&mut content_nonce_bytes);
let content_nonce = Nonce::from_slice(&content_nonce_bytes);
let ciphertext = content_cipher
.encrypt(content_nonce, plaintext.as_bytes())
.map_err(|e| anyhow::anyhow!("encrypt: {}", e))?;
// base64(nonce || ciphertext_with_tag)
let mut payload = Vec::with_capacity(12 + ciphertext.len());
payload.extend_from_slice(&content_nonce_bytes);
payload.extend_from_slice(&ciphertext);
let encoded = base64::engine::general_purpose::STANDARD.encode(&payload);
// Get our X25519 private key
) -> Result<Vec<WrappedKey>> {
let our_x25519_private = ed25519_seed_to_x25519_private(our_seed);
// Build recipient set (always include ourselves)
@ -79,7 +94,6 @@ pub fn encrypt_post(
all_recipients.push(*our_node_id);
}
// Wrap CEK for each recipient
let mut wrapped_keys = Vec::with_capacity(all_recipients.len());
for recipient in &all_recipients {
let their_x25519_pub = ed25519_pubkey_to_x25519_public(recipient)?;
@ -106,20 +120,17 @@ pub fn encrypt_post(
});
}
Ok((encoded, wrapped_keys))
Ok(wrapped_keys)
}
/// Decrypt a post's content if we are among the recipients.
///
/// Returns `Ok(Some(plaintext))` if we can decrypt, `Ok(None)` if we're not a recipient.
pub fn decrypt_post(
encrypted_content_b64: &str,
/// Unwrap a CEK from wrapped keys if we are a recipient.
/// Returns `Ok(Some(cek))` if we can unwrap, `Ok(None)` if we're not a recipient.
pub fn unwrap_cek_for_recipient(
our_seed: &[u8; 32],
our_node_id: &NodeId,
sender_pubkey: &NodeId,
wrapped_keys: &[WrappedKey],
) -> Result<Option<String>> {
// Find our wrapped key
) -> Result<Option<[u8; 32]>> {
let our_wk = match wrapped_keys.iter().find(|wk| &wk.recipient == our_node_id) {
Some(wk) => wk,
None => return Ok(None),
@ -132,40 +143,142 @@ pub fn decrypt_post(
);
}
// DH with sender to get wrapping key
let our_x25519_private = ed25519_seed_to_x25519_private(our_seed);
let sender_x25519_pub = ed25519_pubkey_to_x25519_public(sender_pubkey)?;
let shared_secret = x25519_dh(&our_x25519_private, &sender_x25519_pub);
let wrapping_key = derive_wrapping_key(&shared_secret);
// Unwrap CEK
let wrap_nonce = Nonce::from_slice(&our_wk.wrapped_cek[..12]);
let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key)
.map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?;
let cek = wrap_cipher
let cek_vec = wrap_cipher
.decrypt(wrap_nonce, &our_wk.wrapped_cek[12..])
.map_err(|e| anyhow::anyhow!("unwrap CEK: {}", e))?;
if cek.len() != 32 {
bail!("unwrapped CEK wrong length: {}", cek.len());
if cek_vec.len() != 32 {
bail!("unwrapped CEK wrong length: {}", cek_vec.len());
}
let mut cek = [0u8; 32];
cek.copy_from_slice(&cek_vec);
Ok(Some(cek))
}
/// Unwrap a group-encrypted CEK using the group seed and public key.
pub fn unwrap_group_cek(
group_seed: &[u8; 32],
group_public_key: &[u8; 32],
wrapped_cek: &[u8],
) -> Result<[u8; 32]> {
if wrapped_cek.len() != 60 {
bail!("invalid wrapped_cek length: expected 60, got {}", wrapped_cek.len());
}
let group_x25519_private = ed25519_seed_to_x25519_private(group_seed);
let group_x25519_public = ed25519_pubkey_to_x25519_public(group_public_key)?;
let shared_secret = x25519_dh(&group_x25519_private, &group_x25519_public);
let wrapping_key = derive_group_cek_wrapping_key(&shared_secret);
let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key)
.map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?;
let wrap_nonce = Nonce::from_slice(&wrapped_cek[..12]);
let cek_vec = wrap_cipher
.decrypt(wrap_nonce, &wrapped_cek[12..])
.map_err(|e| anyhow::anyhow!("unwrap CEK: {}", e))?;
if cek_vec.len() != 32 {
bail!("unwrapped CEK wrong length: {}", cek_vec.len());
}
let mut cek = [0u8; 32];
cek.copy_from_slice(&cek_vec);
Ok(cek)
}
/// Encrypt a post with a provided CEK, wrapping for recipients.
/// Returns `(base64_ciphertext, Vec<WrappedKey>)`.
pub fn encrypt_post_with_cek(
plaintext: &str,
cek: &[u8; 32],
our_seed: &[u8; 32],
our_node_id: &NodeId,
recipients: &[NodeId],
) -> Result<(String, Vec<WrappedKey>)> {
let payload = encrypt_bytes_with_cek(plaintext.as_bytes(), cek)?;
let encoded = base64::engine::general_purpose::STANDARD.encode(&payload);
let wrapped_keys = wrap_cek_for_recipients(cek, our_seed, our_node_id, recipients)?;
Ok((encoded, wrapped_keys))
}
/// Encrypt a post for a group with a provided CEK.
/// Returns `(base64_ciphertext, wrapped_cek_bytes)`.
pub fn encrypt_post_for_group_with_cek(
plaintext: &str,
cek: &[u8; 32],
group_seed: &[u8; 32],
group_public_key: &[u8; 32],
) -> Result<(String, Vec<u8>)> {
let payload = encrypt_bytes_with_cek(plaintext.as_bytes(), cek)?;
let encoded = base64::engine::general_purpose::STANDARD.encode(&payload);
// Wrap CEK using group DH
let group_x25519_private = ed25519_seed_to_x25519_private(group_seed);
let group_x25519_public = ed25519_pubkey_to_x25519_public(group_public_key)?;
let shared_secret = x25519_dh(&group_x25519_private, &group_x25519_public);
let wrapping_key = derive_group_cek_wrapping_key(&shared_secret);
let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key)
.map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?;
let mut wrap_nonce_bytes = [0u8; 12];
rand::rng().fill_bytes(&mut wrap_nonce_bytes);
let wrap_nonce = Nonce::from_slice(&wrap_nonce_bytes);
let wrapped = wrap_cipher
.encrypt(wrap_nonce, cek.as_slice())
.map_err(|e| anyhow::anyhow!("wrap CEK: {}", e))?;
let mut wrapped_cek = Vec::with_capacity(60);
wrapped_cek.extend_from_slice(&wrap_nonce_bytes);
wrapped_cek.extend_from_slice(&wrapped);
Ok((encoded, wrapped_cek))
}
/// Encrypt a post's plaintext content for the given recipients.
///
/// Returns `(base64_ciphertext, Vec<WrappedKey>)` where:
/// - base64_ciphertext is `base64(nonce(12) || ciphertext || tag(16))` for the content
/// - Each WrappedKey contains the CEK encrypted for one recipient
///
/// The author (our_seed's corresponding NodeId) is always included as a recipient.
pub fn encrypt_post(
plaintext: &str,
our_seed: &[u8; 32],
our_node_id: &NodeId,
recipients: &[NodeId],
) -> Result<(String, Vec<WrappedKey>)> {
let cek = random_cek();
encrypt_post_with_cek(plaintext, &cek, our_seed, our_node_id, recipients)
}
/// Decrypt a post's content if we are among the recipients.
///
/// Returns `Ok(Some(plaintext))` if we can decrypt, `Ok(None)` if we're not a recipient.
pub fn decrypt_post(
encrypted_content_b64: &str,
our_seed: &[u8; 32],
our_node_id: &NodeId,
sender_pubkey: &NodeId,
wrapped_keys: &[WrappedKey],
) -> Result<Option<String>> {
let cek = match unwrap_cek_for_recipient(our_seed, our_node_id, sender_pubkey, wrapped_keys)? {
Some(cek) => cek,
None => return Ok(None),
};
// Decode base64 content
let payload = base64::engine::general_purpose::STANDARD
.decode(encrypted_content_b64)
.map_err(|e| anyhow::anyhow!("base64 decode: {}", e))?;
if payload.len() < 12 + 16 {
bail!("encrypted payload too short");
}
// Decrypt content
let content_nonce = Nonce::from_slice(&payload[..12]);
let content_cipher = ChaCha20Poly1305::new_from_slice(&cek)
.map_err(|e| anyhow::anyhow!("content cipher init: {}", e))?;
let plaintext = content_cipher
.decrypt(content_nonce, &payload[12..])
.map_err(|e| anyhow::anyhow!("decrypt content: {}", e))?;
let plaintext = decrypt_bytes_with_cek(&payload, &cek)?;
Ok(Some(String::from_utf8(plaintext)?))
}
@ -202,63 +315,12 @@ pub fn rewrap_visibility(
existing_recipients: &[WrappedKey],
new_recipient_ids: &[NodeId],
) -> Result<Vec<WrappedKey>> {
// Find our wrapped key
let our_wk = existing_recipients
.iter()
.find(|wk| &wk.recipient == our_node_id)
// Unwrap CEK using DH with ourselves (we are both sender and recipient here)
let cek = unwrap_cek_for_recipient(our_seed, our_node_id, our_node_id, existing_recipients)?
.ok_or_else(|| anyhow::anyhow!("we are not a recipient of this post"))?;
if our_wk.wrapped_cek.len() != 60 {
bail!(
"invalid wrapped_cek length: expected 60, got {}",
our_wk.wrapped_cek.len()
);
}
// DH with ourselves (author DH with self) to unwrap CEK
let our_x25519_private = ed25519_seed_to_x25519_private(our_seed);
let our_x25519_pub = ed25519_pubkey_to_x25519_public(our_node_id)?;
let shared_secret = x25519_dh(&our_x25519_private, &our_x25519_pub);
let wrapping_key = derive_wrapping_key(&shared_secret);
let wrap_nonce = Nonce::from_slice(&our_wk.wrapped_cek[..12]);
let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key)
.map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?;
let cek = wrap_cipher
.decrypt(wrap_nonce, &our_wk.wrapped_cek[12..])
.map_err(|e| anyhow::anyhow!("unwrap CEK: {}", e))?;
if cek.len() != 32 {
bail!("unwrapped CEK wrong length: {}", cek.len());
}
// Re-wrap for each new recipient
let mut wrapped_keys = Vec::with_capacity(new_recipient_ids.len());
for recipient in new_recipient_ids {
let their_x25519_pub = ed25519_pubkey_to_x25519_public(recipient)?;
let shared_secret = x25519_dh(&our_x25519_private, &their_x25519_pub);
let wrapping_key = derive_wrapping_key(&shared_secret);
let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key)
.map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?;
let mut wrap_nonce_bytes = [0u8; 12];
rand::rng().fill_bytes(&mut wrap_nonce_bytes);
let wrap_nonce = Nonce::from_slice(&wrap_nonce_bytes);
let wrapped = wrap_cipher
.encrypt(wrap_nonce, cek.as_slice())
.map_err(|e| anyhow::anyhow!("wrap: {}", e))?;
let mut wrapped_cek = Vec::with_capacity(60);
wrapped_cek.extend_from_slice(&wrap_nonce_bytes);
wrapped_cek.extend_from_slice(&wrapped);
wrapped_keys.push(WrappedKey {
recipient: *recipient,
wrapped_cek,
});
}
Ok(wrapped_keys)
// Re-wrap for each new recipient (don't auto-add ourselves — caller controls the list)
wrap_cek_for_recipients(&cek, our_seed, our_node_id, new_recipient_ids)
}
// --- Group Key Encryption ---
@ -352,45 +414,8 @@ pub fn encrypt_post_for_group(
group_seed: &[u8; 32],
group_public_key: &[u8; 32],
) -> Result<(String, Vec<u8>)> {
// Generate random CEK
let mut cek = [0u8; 32];
rand::rng().fill_bytes(&mut cek);
// Encrypt content with CEK
let content_cipher = ChaCha20Poly1305::new_from_slice(&cek)
.map_err(|e| anyhow::anyhow!("cipher init: {}", e))?;
let mut content_nonce_bytes = [0u8; 12];
rand::rng().fill_bytes(&mut content_nonce_bytes);
let content_nonce = Nonce::from_slice(&content_nonce_bytes);
let ciphertext = content_cipher
.encrypt(content_nonce, plaintext.as_bytes())
.map_err(|e| anyhow::anyhow!("encrypt: {}", e))?;
let mut payload = Vec::with_capacity(12 + ciphertext.len());
payload.extend_from_slice(&content_nonce_bytes);
payload.extend_from_slice(&ciphertext);
let encoded = base64::engine::general_purpose::STANDARD.encode(&payload);
// Wrap CEK using group DH: group_seed (as X25519 private) × group_public_key (as X25519 public)
let group_x25519_private = ed25519_seed_to_x25519_private(group_seed);
let group_x25519_public = ed25519_pubkey_to_x25519_public(group_public_key)?;
let shared_secret = x25519_dh(&group_x25519_private, &group_x25519_public);
let wrapping_key = derive_group_cek_wrapping_key(&shared_secret);
let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key)
.map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?;
let mut wrap_nonce_bytes = [0u8; 12];
rand::rng().fill_bytes(&mut wrap_nonce_bytes);
let wrap_nonce = Nonce::from_slice(&wrap_nonce_bytes);
let wrapped = wrap_cipher
.encrypt(wrap_nonce, cek.as_slice())
.map_err(|e| anyhow::anyhow!("wrap CEK: {}", e))?;
let mut wrapped_cek = Vec::with_capacity(60);
wrapped_cek.extend_from_slice(&wrap_nonce_bytes);
wrapped_cek.extend_from_slice(&wrapped);
Ok((encoded, wrapped_cek))
let cek = random_cek();
encrypt_post_for_group_with_cek(plaintext, &cek, group_seed, group_public_key)
}
/// Decrypt a group-encrypted post using the group seed and public key.
@ -400,42 +425,14 @@ pub fn decrypt_group_post(
group_public_key: &[u8; 32],
wrapped_cek: &[u8],
) -> Result<String> {
if wrapped_cek.len() != 60 {
bail!("invalid wrapped_cek length: expected 60, got {}", wrapped_cek.len());
}
// Unwrap CEK using group DH
let group_x25519_private = ed25519_seed_to_x25519_private(group_seed);
let group_x25519_public = ed25519_pubkey_to_x25519_public(group_public_key)?;
let shared_secret = x25519_dh(&group_x25519_private, &group_x25519_public);
let wrapping_key = derive_group_cek_wrapping_key(&shared_secret);
let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key)
.map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?;
let wrap_nonce = Nonce::from_slice(&wrapped_cek[..12]);
let cek = wrap_cipher
.decrypt(wrap_nonce, &wrapped_cek[12..])
.map_err(|e| anyhow::anyhow!("unwrap CEK: {}", e))?;
if cek.len() != 32 {
bail!("unwrapped CEK wrong length: {}", cek.len());
}
let cek = unwrap_group_cek(group_seed, group_public_key, wrapped_cek)?;
// Decode and decrypt content
let payload = base64::engine::general_purpose::STANDARD
.decode(encrypted_b64)
.map_err(|e| anyhow::anyhow!("base64 decode: {}", e))?;
if payload.len() < 12 + 16 {
bail!("encrypted payload too short");
}
let content_nonce = Nonce::from_slice(&payload[..12]);
let content_cipher = ChaCha20Poly1305::new_from_slice(&cek)
.map_err(|e| anyhow::anyhow!("content cipher init: {}", e))?;
let plaintext = content_cipher
.decrypt(content_nonce, &payload[12..])
.map_err(|e| anyhow::anyhow!("decrypt content: {}", e))?;
let plaintext = decrypt_bytes_with_cek(&payload, &cek)?;
Ok(String::from_utf8(plaintext)?)
}