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:
parent
0abc244ee9
commit
a41b11c0b8
14 changed files with 562 additions and 325 deletions
|
|
@ -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)?)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -644,6 +644,23 @@ impl Storage {
|
|||
}
|
||||
}
|
||||
|
||||
/// Get the visibility intent for a post (if stored).
|
||||
pub fn get_post_intent(&self, id: &PostId) -> anyhow::Result<Option<VisibilityIntent>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT visibility_intent FROM posts WHERE id = ?1",
|
||||
)?;
|
||||
let mut rows = stmt.query(params![id.as_slice()])?;
|
||||
if let Some(row) = rows.next()? {
|
||||
let intent_json: Option<String> = row.get(0)?;
|
||||
match intent_json {
|
||||
Some(json) => Ok(serde_json::from_str(&json).ok()),
|
||||
None => Ok(None),
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_post_ids(&self) -> anyhow::Result<Vec<PostId>> {
|
||||
let mut stmt = self.conn.prepare("SELECT id FROM posts")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue