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

2
Cargo.lock generated
View file

@ -2746,7 +2746,7 @@ dependencies = [
[[package]] [[package]]
name = "itsgoin-desktop" name = "itsgoin-desktop"
version = "0.3.4" version = "0.3.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.22.1",

View file

@ -37,40 +37,55 @@ fn derive_wrapping_key(shared_secret: &[u8; 32]) -> [u8; 32] {
blake3::derive_key(CEK_WRAP_CONTEXT, shared_secret) blake3::derive_key(CEK_WRAP_CONTEXT, shared_secret)
} }
/// Encrypt a post's plaintext content for the given recipients. // --- Crypto primitives ---
///
/// Returns `(base64_ciphertext, Vec<WrappedKey>)` where: /// Generate a random 32-byte Content Encryption Key (CEK).
/// - base64_ciphertext is `base64(nonce(12) || ciphertext || tag(16))` for the content fn random_cek() -> [u8; 32] {
/// - Each WrappedKey contains the CEK encrypted for one recipient let mut cek = [0u8; 32];
/// rand::rng().fill_bytes(&mut cek);
/// The author (our_seed's corresponding NodeId) is always included as a recipient. cek
pub fn encrypt_post( }
plaintext: &str,
/// 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_seed: &[u8; 32],
our_node_id: &NodeId, our_node_id: &NodeId,
recipients: &[NodeId], recipients: &[NodeId],
) -> Result<(String, Vec<WrappedKey>)> { ) -> Result<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
let our_x25519_private = ed25519_seed_to_x25519_private(our_seed); let our_x25519_private = ed25519_seed_to_x25519_private(our_seed);
// Build recipient set (always include ourselves) // Build recipient set (always include ourselves)
@ -79,7 +94,6 @@ pub fn encrypt_post(
all_recipients.push(*our_node_id); all_recipients.push(*our_node_id);
} }
// Wrap CEK for each recipient
let mut wrapped_keys = Vec::with_capacity(all_recipients.len()); let mut wrapped_keys = Vec::with_capacity(all_recipients.len());
for recipient in &all_recipients { for recipient in &all_recipients {
let their_x25519_pub = ed25519_pubkey_to_x25519_public(recipient)?; 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. /// 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.
/// Returns `Ok(Some(plaintext))` if we can decrypt, `Ok(None)` if we're not a recipient. pub fn unwrap_cek_for_recipient(
pub fn decrypt_post(
encrypted_content_b64: &str,
our_seed: &[u8; 32], our_seed: &[u8; 32],
our_node_id: &NodeId, our_node_id: &NodeId,
sender_pubkey: &NodeId, sender_pubkey: &NodeId,
wrapped_keys: &[WrappedKey], wrapped_keys: &[WrappedKey],
) -> Result<Option<String>> { ) -> Result<Option<[u8; 32]>> {
// Find our wrapped key
let our_wk = match wrapped_keys.iter().find(|wk| &wk.recipient == our_node_id) { let our_wk = match wrapped_keys.iter().find(|wk| &wk.recipient == our_node_id) {
Some(wk) => wk, Some(wk) => wk,
None => return Ok(None), 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 our_x25519_private = ed25519_seed_to_x25519_private(our_seed);
let sender_x25519_pub = ed25519_pubkey_to_x25519_public(sender_pubkey)?; let sender_x25519_pub = ed25519_pubkey_to_x25519_public(sender_pubkey)?;
let shared_secret = x25519_dh(&our_x25519_private, &sender_x25519_pub); let shared_secret = x25519_dh(&our_x25519_private, &sender_x25519_pub);
let wrapping_key = derive_wrapping_key(&shared_secret); let wrapping_key = derive_wrapping_key(&shared_secret);
// Unwrap CEK
let wrap_nonce = Nonce::from_slice(&our_wk.wrapped_cek[..12]); let wrap_nonce = Nonce::from_slice(&our_wk.wrapped_cek[..12]);
let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key) let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key)
.map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?; .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..]) .decrypt(wrap_nonce, &our_wk.wrapped_cek[12..])
.map_err(|e| anyhow::anyhow!("unwrap CEK: {}", e))?; .map_err(|e| anyhow::anyhow!("unwrap CEK: {}", e))?;
if cek.len() != 32 { if cek_vec.len() != 32 {
bail!("unwrapped CEK wrong length: {}", cek.len()); 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 // Decode base64 content
let payload = base64::engine::general_purpose::STANDARD let payload = base64::engine::general_purpose::STANDARD
.decode(encrypted_content_b64) .decode(encrypted_content_b64)
.map_err(|e| anyhow::anyhow!("base64 decode: {}", e))?; .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)?)) Ok(Some(String::from_utf8(plaintext)?))
} }
@ -202,63 +315,12 @@ pub fn rewrap_visibility(
existing_recipients: &[WrappedKey], existing_recipients: &[WrappedKey],
new_recipient_ids: &[NodeId], new_recipient_ids: &[NodeId],
) -> Result<Vec<WrappedKey>> { ) -> Result<Vec<WrappedKey>> {
// Find our wrapped key // Unwrap CEK using DH with ourselves (we are both sender and recipient here)
let our_wk = existing_recipients let cek = unwrap_cek_for_recipient(our_seed, our_node_id, our_node_id, existing_recipients)?
.iter()
.find(|wk| &wk.recipient == our_node_id)
.ok_or_else(|| anyhow::anyhow!("we are not a recipient of this post"))?; .ok_or_else(|| anyhow::anyhow!("we are not a recipient of this post"))?;
if our_wk.wrapped_cek.len() != 60 { // Re-wrap for each new recipient (don't auto-add ourselves — caller controls the list)
bail!( wrap_cek_for_recipients(&cek, our_seed, our_node_id, new_recipient_ids)
"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)
} }
// --- Group Key Encryption --- // --- Group Key Encryption ---
@ -352,45 +414,8 @@ pub fn encrypt_post_for_group(
group_seed: &[u8; 32], group_seed: &[u8; 32],
group_public_key: &[u8; 32], group_public_key: &[u8; 32],
) -> Result<(String, Vec<u8>)> { ) -> Result<(String, Vec<u8>)> {
// Generate random CEK let cek = random_cek();
let mut cek = [0u8; 32]; encrypt_post_for_group_with_cek(plaintext, &cek, group_seed, group_public_key)
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))
} }
/// Decrypt a group-encrypted post using the group seed and 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], group_public_key: &[u8; 32],
wrapped_cek: &[u8], wrapped_cek: &[u8],
) -> Result<String> { ) -> Result<String> {
if wrapped_cek.len() != 60 { let cek = unwrap_group_cek(group_seed, group_public_key, wrapped_cek)?;
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());
}
// Decode and decrypt content // Decode and decrypt content
let payload = base64::engine::general_purpose::STANDARD let payload = base64::engine::general_purpose::STANDARD
.decode(encrypted_b64) .decode(encrypted_b64)
.map_err(|e| anyhow::anyhow!("base64 decode: {}", e))?; .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)?) Ok(String::from_utf8(plaintext)?)
} }

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() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)? .duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64; .as_millis() as u64;
let (final_content, visibility) = match &intent { // Determine encryption parameters and generate CEK if needed.
VisibilityIntent::Public => (content, PostVisibility::Public), // 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) => { VisibilityIntent::Circle(circle_name) => {
// Try group encryption first // Try group encryption first
let group_info = { let group_info = {
@ -551,30 +547,17 @@ impl Node {
}) })
}; };
if let Some((group_id, epoch, group_seed, group_pubkey)) = group_info { if let Some((group_id, epoch, group_seed, group_pubkey)) = group_info {
let (encrypted, wrapped_cek) = let mut cek = [0u8; 32];
crypto::encrypt_post_for_group(&content, &group_seed, &group_pubkey)?; rand::RngCore::fill_bytes(&mut rand::rng(), &mut cek);
( EncryptionMode::Group { cek, group_id, epoch, group_seed, group_pubkey }
encrypted,
PostVisibility::GroupEncrypted {
group_id,
epoch,
wrapped_cek,
},
)
} else { } else {
// Fallback to per-recipient encryption
let recipients = self.resolve_recipients(&intent).await?; let recipients = self.resolve_recipients(&intent).await?;
if recipients.is_empty() { if recipients.is_empty() {
anyhow::bail!("no recipients resolved for this visibility"); anyhow::bail!("no recipients resolved for this visibility");
} }
let (encrypted, wrapped_keys) = let mut cek = [0u8; 32];
crypto::encrypt_post(&content, &self.secret_seed, &self.node_id, &recipients)?; rand::RngCore::fill_bytes(&mut rand::rng(), &mut cek);
( EncryptionMode::Recipient { cek, recipients }
encrypted,
PostVisibility::Encrypted {
recipients: wrapped_keys,
},
)
} }
} }
_ => { _ => {
@ -582,8 +565,41 @@ impl Node {
if recipients.is_empty() { if recipients.is_empty() {
anyhow::bail!("no recipients resolved for this visibility"); 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) = 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, encrypted,
PostVisibility::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 { let post = Post {
@ -969,6 +997,136 @@ impl Node {
Ok(data) 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. /// Check if a blob exists locally.
pub fn has_blob(&self, cid: &[u8; 32]) -> bool { pub fn has_blob(&self, cid: &[u8; 32]) -> bool {
self.blob_store.has(cid) self.blob_store.has(cid)
@ -2049,6 +2207,10 @@ impl Node {
engagement_headers = engagement, engagement_headers = engagement,
"Sync complete" "Sync complete"
); );
// Prefetch blobs for posts we just received
if stats.posts_received > 0 {
self.prefetch_blobs_from_peer(&peer_id).await;
}
Ok(()) Ok(())
} }
@ -2115,15 +2277,15 @@ impl Node {
tokio::spawn(async move { network.run_accept_loop().await }) tokio::spawn(async move { network.run_accept_loop().await })
} }
/// Start pull cycle: every interval_secs, pull from connected peers. /// Start pull cycle: every interval_secs, pull from connected peers + prefetch blobs.
pub fn start_pull_cycle(&self, interval_secs: u64) -> tokio::task::JoinHandle<()> { pub fn start_pull_cycle(self: &Arc<Self>, interval_secs: u64) -> tokio::task::JoinHandle<()> {
let network = Arc::clone(&self.network); let node = Arc::clone(self);
tokio::spawn(async move { tokio::spawn(async move {
let mut interval = let mut interval =
tokio::time::interval(std::time::Duration::from_secs(interval_secs)); tokio::time::interval(std::time::Duration::from_secs(interval_secs));
loop { loop {
interval.tick().await; interval.tick().await;
match network.pull_from_all().await { match node.network.pull_from_all().await {
Ok(stats) => { Ok(stats) => {
if stats.posts_received > 0 { if stats.posts_received > 0 {
tracing::debug!( tracing::debug!(
@ -2131,6 +2293,11 @@ impl Node {
peers = stats.peers_pulled, peers = stats.peers_pulled,
"Pull cycle complete" "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) => { Err(e) => {

View file

@ -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>> { pub fn list_post_ids(&self) -> anyhow::Result<Vec<PostId>> {
let mut stmt = self.conn.prepare("SELECT id FROM posts")?; let mut stmt = self.conn.prepare("SELECT id FROM posts")?;
let rows = stmt.query_map([], |row| { let rows = stmt.query_map([], |row| {

View file

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

View file

@ -30,6 +30,8 @@ struct PostDto {
is_me: bool, is_me: bool,
/// "public", "encrypted", or "encrypted-for-me" /// "public", "encrypted", or "encrypted-for-me"
visibility: String, 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 plaintext if we can decrypt; None for public or if we're not a recipient
decrypted_content: Option<String>, decrypted_content: Option<String>,
attachments: Vec<AttachmentDto>, attachments: Vec<AttachmentDto>,
@ -158,13 +160,32 @@ async fn post_to_dto(
decrypted: Option<&str>, decrypted: Option<&str>,
node: &Node, node: &Node,
) -> PostDto { ) -> PostDto {
let is_me = &post.author == &node.node_id;
let author_name = match node.resolve_display_name(&post.author).await { let author_name = match node.resolve_display_name(&post.author).await {
Ok((name, _, _)) if !name.is_empty() => Some(name), Ok((name, _, _)) if !name.is_empty() => Some(name),
_ => None, _ => 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 { let (visibility, decrypted_content) = match vis {
PostVisibility::Public => ("public".to_string(), None), PostVisibility::Public => ("public".to_string(), None),
PostVisibility::Encrypted { .. } | PostVisibility::GroupEncrypted { .. } => match decrypted { 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())), Some(text) => ("encrypted-for-me".to_string(), Some(text.to_string())),
None => ("encrypted".to_string(), None), None => ("encrypted".to_string(), None),
}, },
@ -203,8 +224,9 @@ async fn post_to_dto(
author_name, author_name,
content: post.content.clone(), content: post.content.clone(),
timestamp_ms: post.timestamp_ms, timestamp_ms: post.timestamp_ms,
is_me: &post.author == &node.node_id, is_me,
visibility, visibility,
intent_kind,
decrypted_content, decrypted_content,
attachments, attachments,
recipients, 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). /// 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] #[tauri::command]
async fn get_blob_path( async fn get_blob_path(
state: State<'_, AppState>, state: State<'_, AppState>,
cid_hex: String, cid_hex: String,
post_id_hex: Option<String>,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
let node = state.inner();
let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?; let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?;
let cid: [u8; 32] = cid_bytes let cid: [u8; 32] = cid_bytes
.try_into() .try_into()
.map_err(|_| "CID must be 32 bytes".to_string())?; .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. /// 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() .try_into()
.map_err(|_| "CID must be 32 bytes".to_string())?; .map_err(|_| "CID must be 32 bytes".to_string())?;
// Get blob data (local or fetch from network) let data = resolve_blob_data(node, &cid, post_id_hex.as_deref()).await?;
let data = if let Some(d) = node.get_blob(&cid).await.map_err(|e| e.to_string())? { let safe_name = sanitize_download_filename(&filename);
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());
};
// Save to Downloads // Save to Downloads
let downloads = dirs::download_dir() let downloads = dirs::download_dir()
.or_else(|| dirs::home_dir().map(|h| h.join("Downloads"))) .or_else(|| dirs::home_dir().map(|h| h.join("Downloads")))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp")); .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())?; tokio::fs::write(&dest, &data).await.map_err(|e| e.to_string())?;
// Open with system handler // Open with system handler
@ -472,34 +551,13 @@ async fn save_blob(
.try_into() .try_into()
.map_err(|_| "CID must be 32 bytes".to_string())?; .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())? { let data = resolve_blob_data(node, &cid, post_id_hex.as_deref()).await?;
d let safe_name = sanitize_download_filename(&filename);
} 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 downloads = dirs::download_dir() let downloads = dirs::download_dir()
.or_else(|| dirs::home_dir().map(|h| h.join("Downloads"))) .or_else(|| dirs::home_dir().map(|h| h.join("Downloads")))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp")); .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())?; tokio::fs::write(&dest, &data).await.map_err(|e| e.to_string())?;
Ok(dest.to_string_lossy().to_string()) Ok(dest.to_string_lossy().to_string())
@ -517,41 +575,9 @@ async fn get_blob(
.try_into() .try_into()
.map_err(|_| "CID must be 32 bytes".to_string())?; .map_err(|_| "CID must be 32 bytes".to_string())?;
// Check local first (also touches last_accessed_at) let data = resolve_blob_data(node, &cid, post_id_hex.as_deref()).await?;
if let Some(data) = node.get_blob(&cid).await.map_err(|e| e.to_string())? { use base64::Engine;
use base64::Engine; Ok(base64::engine::general_purpose::STANDARD.encode(&data))
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())
} }
#[tauri::command] #[tauri::command]

View file

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

View file

@ -628,7 +628,7 @@ async function loadStats() {
async function loadFeed(force) { async function loadFeed(force) {
try { try {
const allPosts = await invoke('get_feed'); const allPosts = await invoke('get_feed');
const posts = allPosts.filter(p => p.visibility !== 'encrypted-for-me'); const posts = allPosts.filter(p => p.intentKind !== 'direct');
// Fingerprint: post IDs + reaction counts + comment counts // Fingerprint: post IDs + reaction counts + comment counts
const fp = posts.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|'); const fp = posts.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|');
if (!force && fp === _feedFingerprint) return; if (!force && fp === _feedFingerprint) return;
@ -705,7 +705,7 @@ async function loadFeed(force) {
async function loadMyPosts(force) { async function loadMyPosts(force) {
try { try {
const posts = await invoke('get_all_posts'); const posts = await invoke('get_all_posts');
const mine = posts.filter(p => p.isMe && p.visibility !== 'encrypted-for-me' && !(p.recipients && p.recipients.length > 0)); const mine = posts.filter(p => p.isMe && p.intentKind !== 'direct');
const fp = mine.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|'); const fp = mine.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|');
if (!force && fp === _myPostsFingerprint) return; if (!force && fp === _myPostsFingerprint) return;
_myPostsFingerprint = fp; _myPostsFingerprint = fp;
@ -743,10 +743,12 @@ async function loadMessages(force) {
]); ]);
const followSet = new Set(follows.map(f => f.nodeId)); const followSet = new Set(follows.map(f => f.nodeId));
// Collect DMs: received encrypted-for-me OR my sent encrypted with recipients // Collect DMs: intent-based with fallback for old posts without intentKind
const dms = posts.filter(p => { const dms = posts.filter(p => {
if (!p.isMe && p.visibility === 'encrypted-for-me') return true; if (p.intentKind === 'direct') return true;
if (p.isMe && p.recipients && p.recipients.length > 0) return true; // Fallback for pre-intent posts
if (p.intentKind === 'unknown' && !p.isMe && p.visibility === 'encrypted-for-me') return true;
if (p.intentKind === 'unknown' && p.isMe && p.recipients && p.recipients.length > 0) return true;
return false; return false;
}); });
@ -1402,8 +1404,8 @@ async function loadNetworkSummary() {
networkSummaryEl.innerHTML = `<div class="diag-grid"> networkSummaryEl.innerHTML = `<div class="diag-grid">
<div class="diag-item"><span class="diag-value">${s.totalConnections}</span><span class="diag-label">Connections</span></div> <div class="diag-item"><span class="diag-value">${s.totalConnections}</span><span class="diag-label">Connections</span></div>
<div class="diag-item"><span class="diag-value">${s.preferredCount}</span><span class="diag-label">Preferred</span></div> <div class="diag-item"><span class="diag-value">${s.preferredCount}</span><span class="diag-label">Preferred</span></div>
<div class="diag-item"><span class="diag-value">${s.localCount}</span><span class="diag-label">Local</span></div> <div class="diag-item"><span class="diag-value">${s.localCount}</span><span class="diag-label">Mesh</span></div>
<div class="diag-item"><span class="diag-value">${s.wideCount}</span><span class="diag-label">Wide</span></div> <div class="diag-item"><span class="diag-value">${s.wideCount}</span><span class="diag-label">Non-mesh N1</span></div>
<div class="diag-item"><span class="diag-value">${s.n2Distinct}</span><span class="diag-label">N2 Reach</span></div> <div class="diag-item"><span class="diag-value">${s.n2Distinct}</span><span class="diag-label">N2 Reach</span></div>
<div class="diag-item"><span class="diag-value">${s.n3Distinct}</span><span class="diag-label">N3 Reach</span></div> <div class="diag-item"><span class="diag-value">${s.n3Distinct}</span><span class="diag-label">N3 Reach</span></div>
</div>`; </div>`;
@ -1424,9 +1426,11 @@ async function loadConnections() {
const icon = generateIdenticon(c.nodeId, 18); const icon = generateIdenticon(c.nodeId, 18);
const slotClass = c.slotKind === 'Preferred' ? 'slot-preferred' const slotClass = c.slotKind === 'Preferred' ? 'slot-preferred'
: c.slotKind === 'Wide' ? 'slot-wide' : 'slot-local'; : c.slotKind === 'Wide' ? 'slot-wide' : 'slot-local';
const slotLabel = c.slotKind === 'Local' ? 'Mesh'
: c.slotKind === 'Wide' ? 'Non-mesh N1' : c.slotKind;
const duration = c.connectedAt ? relativeTime(c.connectedAt) : ''; const duration = c.connectedAt ? relativeTime(c.connectedAt) : '';
return `<div class="peer-card"> return `<div class="peer-card">
<div class="peer-card-row">${icon} ${label} <span class="slot-badge ${slotClass}">${c.slotKind}</span></div> <div class="peer-card-row">${icon} ${label} <span class="slot-badge ${slotClass}">${slotLabel}</span></div>
<div class="peer-card-meta"><span>${duration}</span></div> <div class="peer-card-meta"><span>${duration}</span></div>
</div>`; </div>`;
}).join(''); }).join('');
@ -1707,7 +1711,7 @@ async function loadPostMedia(container) {
const postId = img.dataset.postId; const postId = img.dataset.postId;
const mime = img.dataset.mime || 'image/jpeg'; const mime = img.dataset.mime || 'image/jpeg';
try { try {
const filePath = await invoke('get_blob_path', { cidHex: cid }); const filePath = await invoke('get_blob_path', { cidHex: cid, postIdHex: postId });
if (filePath && window.__TAURI__?.core?.convertFileSrc) { if (filePath && window.__TAURI__?.core?.convertFileSrc) {
const assetUrl = window.__TAURI__.core.convertFileSrc(filePath); const assetUrl = window.__TAURI__.core.convertFileSrc(filePath);
img.onerror = async () => { img.onerror = async () => {

View file

@ -10,6 +10,7 @@
<body> <body>
<nav> <nav>
<a href="index.html" class="logo">ItsGoin</a> <a href="index.html" class="logo">ItsGoin</a>
<button class="menu-toggle" onclick="this.parentElement.querySelector('.links').classList.toggle('open')" aria-label="Menu">&#9776;</button>
<div class="links"> <div class="links">
<a href="index.html">About</a> <a href="index.html">About</a>
<a href="tech.html">How It Works</a> <a href="tech.html">How It Works</a>

View file

@ -26,6 +26,7 @@
<body> <body>
<nav> <nav>
<a href="index.html" class="logo">ItsGoin</a> <a href="index.html" class="logo">ItsGoin</a>
<button class="menu-toggle" onclick="this.parentElement.querySelector('.links').classList.toggle('open')" aria-label="Menu">&#9776;</button>
<div class="links"> <div class="links">
<a href="index.html">About</a> <a href="index.html">About</a>
<a href="tech.html">How It Works</a> <a href="tech.html">How It Works</a>
@ -43,7 +44,9 @@
<p>This is the canonical technical reference for ItsGoin. It describes the vision, the architecture, and the current state of every subsystem &mdash; with full implementation detail. This document is versioned; each update records what changed.</p> <p>This is the canonical technical reference for ItsGoin. It describes the vision, the architecture, and the current state of every subsystem &mdash; with full implementation detail. This document is versioned; each update records what changed.</p>
<div class="card" style="margin-top: 1rem;"> <div class="card" style="margin-top: 1rem;">
<strong style="font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;">Changelog</strong> <strong style="font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;">Changelog</strong>
<p style="margin-top: 0.5rem;"><strong>v0.3.3</strong> (2026-03-16): Connection rate limiting &mdash; incoming auth failures rate-limited per source IP (3 attempts, exponential backoff to ~256s). Schema versioning &mdash; PRAGMA user_version tracks DB version with migration framework. N2/N3 freshness &mdash; TTL 7d&rarr;5h, full N1/N2 re-broadcast every 4h, startup sweep clears stale entries. Bootstrap isolation recovery &mdash; 24h check verifies bootstrap is in N1/N2/N3, reconnects + sticky N1 advertisement if absent. IPv6 HTTP address fix &mdash; nodes advertise actual public IPv6 (not 0.0.0.0) for share link redirects. Upstream tracking &mdash; post_upstream table records post source for engagement diff routing toward author. Video preload fix &mdash; share links and in-app videos use preload=auto. Following Online/Offline split. DM filter from My Posts. Any-type file attachments with download prompt + trust warning. Image lightbox. Audio player.</p> <p style="margin-top: 0.5rem;"><strong>v0.3.5</strong> (2026-03-20): Private blob encryption &mdash; attachments on encrypted posts (Friends/Circle/Direct) now encrypted with same CEK as post text; public blobs unchanged; CID on ciphertext. Blob prefetch on sync &mdash; attachments eagerly fetched after post pull for offline availability. Crypto refactoring &mdash; extracted reusable primitives (encrypt/decrypt_bytes_with_cek, unwrap_cek_for_recipient, unwrap_group_cek). Intent-based post filtering &mdash; feed/myposts/messages filter on intentKind instead of encryption state. Blob decryption API (get_blob_for_post). Download filename sanitization.</p>
<p><strong>v0.3.4</strong> (2026-03-18): Comment edit &amp; delete with trust-based propagation. Native notifications via Tauri plugin (messages, posts, reactions, comments). Forward-compatible BlobHeaderDiffOp::Unknown variant. Following Online/Offline lightbox. Comment threading scoping fix. Dropdown text legibility fix. Mobile hamburger nav for website.</p>
<p><strong>v0.3.3</strong> (2026-03-16): Connection rate limiting &mdash; incoming auth failures rate-limited per source IP (3 attempts, exponential backoff to ~256s). Schema versioning &mdash; PRAGMA user_version tracks DB version with migration framework. N2/N3 freshness &mdash; TTL 7d&rarr;5h, full N1/N2 re-broadcast every 4h, startup sweep clears stale entries. Bootstrap isolation recovery &mdash; 24h check verifies bootstrap is in N1/N2/N3, reconnects + sticky N1 advertisement if absent. IPv6 HTTP address fix &mdash; nodes advertise actual public IPv6 (not 0.0.0.0) for share link redirects. Upstream tracking &mdash; post_upstream table records post source for engagement diff routing toward author. Video preload fix &mdash; share links and in-app videos use preload=auto. Following Online/Offline split. DM filter from My Posts. Any-type file attachments with download prompt + trust warning. Image lightbox. Audio player.</p>
<p><strong>v0.3.2</strong> (2026-03-14): Bidirectional engagement propagation &mdash; BlobHeaderDiff flows upstream + downstream through CDN tree. Auto downstream registration on pull sync/push notification. TCP hole punch protocol (TcpPunchRequest/Result 0xD6/0xD7). Tiered web serving (redirect &rarr; TCP punch &rarr; QUIC proxy). Video playback fix (asset protocol + blob URL fallback). On-demand blob fetch for synced posts missing blob data.</p> <p><strong>v0.3.2</strong> (2026-03-14): Bidirectional engagement propagation &mdash; BlobHeaderDiff flows upstream + downstream through CDN tree. Auto downstream registration on pull sync/push notification. TCP hole punch protocol (TcpPunchRequest/Result 0xD6/0xD7). Tiered web serving (redirect &rarr; TCP punch &rarr; QUIC proxy). Video playback fix (asset protocol + blob URL fallback). On-demand blob fetch for synced posts missing blob data.</p>
<p><strong>v0.3.1</strong> (2026-03-13): Share links + QUIC proxy + content search. Share link format: <code>itsgoin.net/p/&lt;postid_hex&gt;/&lt;author_nodeid_hex&gt;</code> &mdash; simple, no host encoding needed. itsgoin.net web handler acts as QUIC proxy: receives browser request, searches the network for the post, fetches it on-demand via PostFetch (0xD4/0xD5), renders HTML, serves to browser. No permanent storage of fetched content. Extended worm search &mdash; <code>WormQuery</code> now carries optional <code>post_id</code> and <code>blob_id</code> fields for unified node/post/blob search. Each peer checks local storage, CDN downstream tree (up to 100 hosts per post), and blob store. <code>WormResponse</code> gains <code>post_holder</code> and <code>blob_holder</code> fields. Nova fan-out pattern &mdash; burst peers include one N2 wide referral; referred peer does its own 101-burst, reaching ~10K nodes with ~202 relay hops. PostFetch (0xD4/0xD5) &mdash; lightweight single-post retrieval after worm finds a holder, much lighter than full PullSync. itsgoin.net node deployed as anchor + web handler (<code>--web 8080</code>). &ldquo;Unavailable&rdquo; page with honest network model explanation + install CTA. Universal Links / App Links planned for native app interception. | Engagement sync &mdash; pull sync now fetches reactions, comments, and policies via BlobHeaderRequest/Response after every sync. Profile push fix &mdash; profile updates now sent to all connected mesh peers (not just audience). Auto-sync on follow &mdash; following a peer triggers immediate post pull + engagement fetch. Popover UI &mdash; notifications settings, network diagnostics, and message threads now open as popovers. Notification settings &mdash; per-key settings table in SQLite, configurable message/post/nearby notifications with JS Notification API. Tiered DM polling &mdash; smart message refresh based on conversation recency. Reaction display &mdash; posts show top 5 most popular emoji + total response count. UI cleanup &mdash; removed Suggested Peers and Find Nearby sections, placeholder text changed to &ldquo;How&rsquo;s it goin?&rdquo;, clickable node IDs in activity log.</p> <p><strong>v0.3.1</strong> (2026-03-13): Share links + QUIC proxy + content search. Share link format: <code>itsgoin.net/p/&lt;postid_hex&gt;/&lt;author_nodeid_hex&gt;</code> &mdash; simple, no host encoding needed. itsgoin.net web handler acts as QUIC proxy: receives browser request, searches the network for the post, fetches it on-demand via PostFetch (0xD4/0xD5), renders HTML, serves to browser. No permanent storage of fetched content. Extended worm search &mdash; <code>WormQuery</code> now carries optional <code>post_id</code> and <code>blob_id</code> fields for unified node/post/blob search. Each peer checks local storage, CDN downstream tree (up to 100 hosts per post), and blob store. <code>WormResponse</code> gains <code>post_holder</code> and <code>blob_holder</code> fields. Nova fan-out pattern &mdash; burst peers include one N2 wide referral; referred peer does its own 101-burst, reaching ~10K nodes with ~202 relay hops. PostFetch (0xD4/0xD5) &mdash; lightweight single-post retrieval after worm finds a holder, much lighter than full PullSync. itsgoin.net node deployed as anchor + web handler (<code>--web 8080</code>). &ldquo;Unavailable&rdquo; page with honest network model explanation + install CTA. Universal Links / App Links planned for native app interception. | Engagement sync &mdash; pull sync now fetches reactions, comments, and policies via BlobHeaderRequest/Response after every sync. Profile push fix &mdash; profile updates now sent to all connected mesh peers (not just audience). Auto-sync on follow &mdash; following a peer triggers immediate post pull + engagement fetch. Popover UI &mdash; notifications settings, network diagnostics, and message threads now open as popovers. Notification settings &mdash; per-key settings table in SQLite, configurable message/post/nearby notifications with JS Notification API. Tiered DM polling &mdash; smart message refresh based on conversation recency. Reaction display &mdash; posts show top 5 most popular emoji + total response count. UI cleanup &mdash; removed Suggested Peers and Find Nearby sections, placeholder text changed to &ldquo;How&rsquo;s it goin?&rdquo;, clickable node IDs in activity log.</p>
<p><strong>v0.3.0</strong> (2026-03-12): Full rename distsoc &rarr; ItsGoin. ALPN, crypto contexts, data paths, Android package ID all changed. Clean break &mdash; incompatible with prior versions.</p> <p><strong>v0.3.0</strong> (2026-03-12): Full rename distsoc &rarr; ItsGoin. ALPN, crypto contexts, data paths, Android package ID all changed. Clean break &mdash; incompatible with prior versions.</p>

View file

@ -10,6 +10,7 @@
<body> <body>
<nav> <nav>
<a href="index.html" class="logo">ItsGoin</a> <a href="index.html" class="logo">ItsGoin</a>
<button class="menu-toggle" onclick="this.parentElement.querySelector('.links').classList.toggle('open')" aria-label="Menu">&#9776;</button>
<div class="links"> <div class="links">
<a href="index.html">About</a> <a href="index.html">About</a>
<a href="tech.html">How It Works</a> <a href="tech.html">How It Works</a>
@ -24,16 +25,16 @@
<section> <section>
<h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.25rem;">Download ItsGoin</h1> <h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.25rem;">Download ItsGoin</h1>
<p>Available for Android and Linux. Free and open source.</p> <p>Available for Android and Linux. Free and open source.</p>
<p style="color: var(--text-muted); font-size: 0.85rem;">Version 0.3.4 &mdash; March 15, 2026</p> <p style="color: var(--text-muted); font-size: 0.85rem;">Version 0.3.5 &mdash; March 15, 2026</p>
<div class="downloads"> <div class="downloads">
<a href="itsgoin-0.3.4.apk" class="download-btn btn-android"> <a href="itsgoin-0.3.5.apk" class="download-btn btn-android">
Android APK Android APK
<span class="sub">v0.3.4</span> <span class="sub">v0.3.5</span>
</a> </a>
<a href="itsgoin_0.3.4_amd64.AppImage" class="download-btn btn-linux"> <a href="itsgoin_0.3.5_amd64.AppImage" class="download-btn btn-linux">
Linux AppImage Linux AppImage
<span class="sub">v0.3.4</span> <span class="sub">v0.3.5</span>
</a> </a>
</div> </div>
</section> </section>
@ -45,7 +46,7 @@
<h3 style="color: var(--accent);">Android</h3> <h3 style="color: var(--accent);">Android</h3>
<ol class="steps"> <ol class="steps">
<li><strong>Download the APK</strong> &mdash; Tap the button above. Your browser may warn that this type of file can be harmful &mdash; tap <strong>Download anyway</strong>.</li> <li><strong>Download the APK</strong> &mdash; Tap the button above. Your browser may warn that this type of file can be harmful &mdash; tap <strong>Download anyway</strong>.</li>
<li><strong>Open the file</strong> &mdash; When the download finishes, tap the notification or find <code>itsgoin-0.3.4.apk</code> in your Downloads folder and tap it.</li> <li><strong>Open the file</strong> &mdash; When the download finishes, tap the notification or find <code>itsgoin-0.3.5.apk</code> in your Downloads folder and tap it.</li>
<li><strong>Allow installation</strong> &mdash; Android will ask you to allow installs from this source. Tap <strong>Settings</strong>, toggle <strong>"Allow from this source"</strong>, then go back and tap <strong>Install</strong>.</li> <li><strong>Allow installation</strong> &mdash; Android will ask you to allow installs from this source. Tap <strong>Settings</strong>, toggle <strong>"Allow from this source"</strong>, then go back and tap <strong>Install</strong>.</li>
<li><strong>Launch the app</strong> &mdash; Once installed, tap <strong>Open</strong> or find ItsGoin in your app drawer.</li> <li><strong>Launch the app</strong> &mdash; Once installed, tap <strong>Open</strong> or find ItsGoin in your app drawer.</li>
</ol> </ol>
@ -58,8 +59,8 @@
<h3 style="color: var(--green);">Linux (AppImage)</h3> <h3 style="color: var(--green);">Linux (AppImage)</h3>
<ol class="steps"> <ol class="steps">
<li><strong>Download the AppImage</strong> &mdash; Click the button above to download.</li> <li><strong>Download the AppImage</strong> &mdash; Click the button above to download.</li>
<li><strong>Make it executable</strong> &mdash; Open a terminal and run:<br><code>chmod +x itsgoin_0.3.4_amd64.AppImage</code></li> <li><strong>Make it executable</strong> &mdash; Open a terminal and run:<br><code>chmod +x itsgoin_0.3.5_amd64.AppImage</code></li>
<li><strong>Run it</strong> &mdash; Double-click the file, or from the terminal:<br><code>./itsgoin_0.3.4_amd64.AppImage</code></li> <li><strong>Run it</strong> &mdash; Double-click the file, or from the terminal:<br><code>./itsgoin_0.3.5_amd64.AppImage</code></li>
</ol> </ol>
<div class="note"> <div class="note">
<strong>Note:</strong> If it doesn't launch, you may need to install FUSE:<br><code>sudo apt install libfuse2</code> (Debian/Ubuntu) or <code>sudo dnf install fuse</code> (Fedora). <strong>Note:</strong> If it doesn't launch, you may need to install FUSE:<br><code>sudo apt install libfuse2</code> (Debian/Ubuntu) or <code>sudo dnf install fuse</code> (Fedora).
@ -70,6 +71,16 @@
<section> <section>
<h2>Changelog</h2> <h2>Changelog</h2>
<div class="changelog"> <div class="changelog">
<div class="changelog-date">v0.3.5 &mdash; March 20, 2026</div>
<ul>
<li><strong>Private blob encryption</strong> &mdash; Attachments on encrypted posts (Friends, Circle, Direct) are now encrypted with the same CEK as the post text. Public blobs remain plaintext. CID computed on ciphertext preserves content addressing.</li>
<li><strong>Blob prefetch on sync</strong> &mdash; When posts are pulled from peers, their attachments are eagerly fetched for offline availability. Previously blobs were only fetched on view.</li>
<li><strong>Crypto refactoring</strong> &mdash; Extracted reusable primitives: <code>encrypt_bytes_with_cek</code>, <code>decrypt_bytes_with_cek</code>, <code>unwrap_cek_for_recipient</code>, <code>unwrap_group_cek</code>. Foundation for encrypted blob storage and future chunk-level encryption.</li>
<li><strong>Intent-based post filtering</strong> &mdash; Feed, My Posts, and Messages now filter on the author's original visibility intent (<code>intentKind</code>) rather than encryption state. Direct messages are identified by intent, not by being &ldquo;encrypted-for-me.&rdquo; Backward-compatible with pre-intent posts.</li>
<li><strong>Blob decryption on retrieval</strong> &mdash; New <code>get_blob_for_post</code> API decrypts private blobs in context of their post&rsquo;s visibility. Public blobs pass through unchanged.</li>
<li><strong>Download filename sanitization</strong> &mdash; Prevents path traversal in downloaded file names.</li>
</ul>
<div class="changelog-date">v0.3.4 &mdash; March 18, 2026</div> <div class="changelog-date">v0.3.4 &mdash; March 18, 2026</div>
<ul> <ul>
<li><strong>Comment edit &amp; delete</strong> &mdash; Edit or delete your own comments. Trust-based: post authors can also delete comments on their posts. Propagates via BlobHeaderDiff to all holders.</li> <li><strong>Comment edit &amp; delete</strong> &mdash; Edit or delete your own comments. Trust-based: post authors can also delete comments on their posts. Propagates via BlobHeaderDiff to all holders.</li>

View file

@ -10,6 +10,7 @@
<body> <body>
<nav> <nav>
<a href="index.html" class="logo">ItsGoin</a> <a href="index.html" class="logo">ItsGoin</a>
<button class="menu-toggle" onclick="this.parentElement.querySelector('.links').classList.toggle('open')" aria-label="Menu">&#9776;</button>
<div class="links"> <div class="links">
<a href="index.html" class="active">About</a> <a href="index.html" class="active">About</a>
<a href="tech.html">How It Works</a> <a href="tech.html">How It Works</a>

View file

@ -51,6 +51,15 @@ nav .links a {
transition: color 0.15s; transition: color 0.15s;
} }
nav .links a:hover, nav .links a.active { color: var(--text); text-decoration: none; } nav .links a:hover, nav .links a.active { color: var(--text); text-decoration: none; }
nav .menu-toggle { display: none; background: none; border: none; color: var(--text); font-size: 1.5rem; cursor: pointer; padding: 0.2rem; }
@media (max-width: 640px) {
nav { padding: 0.75rem 1rem; gap: 0; justify-content: space-between; flex-wrap: wrap; }
nav .menu-toggle { display: block; }
nav .links { display: none; width: 100%; flex-direction: column; gap: 0; padding-top: 0.5rem; }
nav .links.open { display: flex; }
nav .links a { padding: 0.5rem 0; border-top: 1px solid var(--border); font-size: 0.95rem; }
}
/* --- Layout --- */ /* --- Layout --- */
.container { max-width: 800px; margin: 0 auto; padding: 3rem 2rem; } .container { max-width: 800px; margin: 0 auto; padding: 3rem 2rem; }

View file

@ -10,6 +10,7 @@
<body> <body>
<nav> <nav>
<a href="index.html" class="logo">ItsGoin</a> <a href="index.html" class="logo">ItsGoin</a>
<button class="menu-toggle" onclick="this.parentElement.querySelector('.links').classList.toggle('open')" aria-label="Menu">&#9776;</button>
<div class="links"> <div class="links">
<a href="index.html">About</a> <a href="index.html">About</a>
<a href="tech.html" class="active">How It Works</a> <a href="tech.html" class="active">How It Works</a>