use anyhow::{bail, Result}; use base64::Engine; use chacha20poly1305::{ aead::{Aead, KeyInit}, ChaCha20Poly1305, Nonce, }; use curve25519_dalek::montgomery::MontgomeryPoint; use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey}; use rand::RngCore; use crate::types::{GroupEpoch, GroupId, GroupMemberKey, NodeId, PostId, WrappedKey}; const CEK_WRAP_CONTEXT: &str = "itsgoin/cek-wrap/v1"; /// Convert an ed25519 seed (32 bytes from identity.key) to X25519 private scalar bytes. pub fn ed25519_seed_to_x25519_private(seed: &[u8; 32]) -> [u8; 32] { let signing_key = SigningKey::from_bytes(seed); signing_key.to_scalar_bytes() } /// Convert an ed25519 public key (NodeId) to X25519 public key bytes. pub fn ed25519_pubkey_to_x25519_public(pk: &[u8; 32]) -> Result<[u8; 32]> { let verifying_key = VerifyingKey::from_bytes(pk) .map_err(|e| anyhow::anyhow!("invalid ed25519 public key: {}", e))?; Ok(verifying_key.to_montgomery().to_bytes()) } /// Perform X25519 Diffie-Hellman: our_private (scalar bytes) * their_public (montgomery point). fn x25519_dh(our_private: &[u8; 32], their_public: &[u8; 32]) -> [u8; 32] { MontgomeryPoint(*their_public) .mul_clamped(*our_private) .to_bytes() } /// Derive a symmetric wrapping key from a DH shared secret using BLAKE3. fn derive_wrapping_key(shared_secret: &[u8; 32]) -> [u8; 32] { blake3::derive_key(CEK_WRAP_CONTEXT, shared_secret) } // --- 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> { 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> { 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> { let our_x25519_private = ed25519_seed_to_x25519_private(our_seed); // Build recipient set (always include ourselves) let mut all_recipients: Vec = recipients.to_vec(); if !all_recipients.contains(our_node_id) { all_recipients.push(*our_node_id); } 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)?; 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))?; // nonce(12) || encrypted_cek(32) || tag(16) = 60 bytes 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) } /// 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> { let our_wk = match wrapped_keys.iter().find(|wk| &wk.recipient == our_node_id) { Some(wk) => wk, None => return Ok(None), }; if our_wk.wrapped_cek.len() != 60 { bail!( "invalid wrapped_cek length: expected 60, got {}", our_wk.wrapped_cek.len() ); } 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); 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_vec = wrap_cipher .decrypt(wrap_nonce, &our_wk.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(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)`. pub fn encrypt_post_with_cek( plaintext: &str, cek: &[u8; 32], our_seed: &[u8; 32], our_node_id: &NodeId, recipients: &[NodeId], ) -> Result<(String, Vec)> { 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)> { 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)` 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)> { 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> { 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))?; let plaintext = decrypt_bytes_with_cek(&payload, &cek)?; Ok(Some(String::from_utf8(plaintext)?)) } /// Sign a delete record: ed25519 sign over post_id bytes using our seed. pub fn sign_delete(seed: &[u8; 32], post_id: &PostId) -> Vec { let signing_key = SigningKey::from_bytes(seed); let sig = signing_key.sign(post_id); sig.to_bytes().to_vec() } /// Verify an ed25519 delete signature: the author's public key signed the post_id. pub fn verify_delete_signature(author: &NodeId, post_id: &PostId, signature: &[u8]) -> bool { if signature.len() != 64 { return false; } let sig_bytes: [u8; 64] = match signature.try_into() { Ok(b) => b, Err(_) => return false, }; let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes); let Ok(verifying_key) = VerifyingKey::from_bytes(author) else { return false; }; verifying_key.verify(post_id, &sig).is_ok() } /// Re-wrap a post's CEK for a new set of recipients (excluding revoked ones). /// /// Given the existing wrapped keys and a new list of recipient NodeIds, /// unwraps the CEK using our own key, then wraps it for each new recipient. pub fn rewrap_visibility( our_seed: &[u8; 32], our_node_id: &NodeId, existing_recipients: &[WrappedKey], new_recipient_ids: &[NodeId], ) -> Result> { // 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"))?; // 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 --- const GROUP_KEY_WRAP_CONTEXT: &str = "itsgoin/group-key-wrap/v1"; const GROUP_CEK_WRAP_CONTEXT: &str = "itsgoin/group-cek-wrap/v1"; /// Generate a new group key pair (ed25519 seed + public key). pub fn generate_group_keypair() -> ([u8; 32], [u8; 32]) { let signing_key = SigningKey::generate(&mut rand::rng()); let seed = signing_key.to_bytes(); let public_key = signing_key.verifying_key().to_bytes(); (seed, public_key) } /// Compute the group ID from the initial public key (BLAKE3 hash). pub fn compute_group_id(public_key: &[u8; 32]) -> GroupId { *blake3::hash(public_key).as_bytes() } /// Derive a wrapping key for group key distribution (admin → member). fn derive_group_key_wrapping_key(shared_secret: &[u8; 32]) -> [u8; 32] { blake3::derive_key(GROUP_KEY_WRAP_CONTEXT, shared_secret) } /// Derive a wrapping key for CEK wrapping via group key DH. fn derive_group_cek_wrapping_key(shared_secret: &[u8; 32]) -> [u8; 32] { blake3::derive_key(GROUP_CEK_WRAP_CONTEXT, shared_secret) } /// Wrap the group seed for a specific member using X25519 DH (admin_seed × member_pubkey). pub fn wrap_group_key_for_member( admin_seed: &[u8; 32], member_node_id: &NodeId, group_seed: &[u8; 32], ) -> Result> { let admin_x25519 = ed25519_seed_to_x25519_private(admin_seed); let member_x25519_pub = ed25519_pubkey_to_x25519_public(member_node_id)?; let shared_secret = x25519_dh(&admin_x25519, &member_x25519_pub); let wrapping_key = derive_group_key_wrapping_key(&shared_secret); let cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key) .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 encrypted = cipher .encrypt(nonce, group_seed.as_slice()) .map_err(|e| anyhow::anyhow!("wrap group key: {}", e))?; let mut result = Vec::with_capacity(60); result.extend_from_slice(&nonce_bytes); result.extend_from_slice(&encrypted); Ok(result) } /// Unwrap a group seed using X25519 DH (our_seed × admin_pubkey). pub fn unwrap_group_key( our_seed: &[u8; 32], admin_node_id: &NodeId, wrapped: &[u8], ) -> Result<[u8; 32]> { if wrapped.len() != 60 { bail!("invalid wrapped group key length: expected 60, got {}", wrapped.len()); } let our_x25519 = ed25519_seed_to_x25519_private(our_seed); let admin_x25519_pub = ed25519_pubkey_to_x25519_public(admin_node_id)?; let shared_secret = x25519_dh(&our_x25519, &admin_x25519_pub); let wrapping_key = derive_group_key_wrapping_key(&shared_secret); let cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key) .map_err(|e| anyhow::anyhow!("cipher init: {}", e))?; let nonce = Nonce::from_slice(&wrapped[..12]); let decrypted = cipher .decrypt(nonce, &wrapped[12..]) .map_err(|e| anyhow::anyhow!("unwrap group key: {}", e))?; if decrypted.len() != 32 { bail!("unwrapped group seed wrong length: {}", decrypted.len()); } let mut seed = [0u8; 32]; seed.copy_from_slice(&decrypted); Ok(seed) } /// Encrypt a post for a group: generates a random CEK, encrypts the content, /// then wraps the CEK using X25519 DH between the group seed and group public key. pub fn encrypt_post_for_group( plaintext: &str, group_seed: &[u8; 32], group_public_key: &[u8; 32], ) -> Result<(String, Vec)> { 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. pub fn decrypt_group_post( encrypted_b64: &str, group_seed: &[u8; 32], group_public_key: &[u8; 32], wrapped_cek: &[u8], ) -> Result { 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))?; let plaintext = decrypt_bytes_with_cek(&payload, &cek)?; Ok(String::from_utf8(plaintext)?) } /// Rotate a group key: generate new keypair, wrap for remaining members, return new state. pub fn rotate_group_key( admin_seed: &[u8; 32], current_epoch: GroupEpoch, remaining_members: &[NodeId], ) -> Result<([u8; 32], [u8; 32], GroupEpoch, Vec)> { let (new_seed, new_pubkey) = generate_group_keypair(); let new_epoch = current_epoch + 1; let mut member_keys = Vec::with_capacity(remaining_members.len()); for member in remaining_members { let wrapped = wrap_group_key_for_member(admin_seed, member, &new_seed)?; member_keys.push(GroupMemberKey { member: *member, epoch: new_epoch, wrapped_group_key: wrapped, }); } Ok((new_seed, new_pubkey, new_epoch, member_keys)) } // --- CDN Manifest Signing --- /// Compute the canonical digest for an AuthorManifest (for signing/verification). /// Digest = BLAKE3(post_id ‖ author ‖ created_at_le ‖ updated_at_le ‖ author_addresses_json ‖ previous_posts_json ‖ following_posts_json) fn manifest_digest(manifest: &crate::types::AuthorManifest) -> [u8; 32] { let mut hasher = blake3::Hasher::new(); hasher.update(&manifest.post_id); hasher.update(&manifest.author); hasher.update(&manifest.created_at.to_le_bytes()); hasher.update(&manifest.updated_at.to_le_bytes()); let addrs_json = serde_json::to_string(&manifest.author_addresses).unwrap_or_default(); hasher.update(addrs_json.as_bytes()); let prev_json = serde_json::to_string(&manifest.previous_posts).unwrap_or_default(); hasher.update(prev_json.as_bytes()); let next_json = serde_json::to_string(&manifest.following_posts).unwrap_or_default(); hasher.update(next_json.as_bytes()); *hasher.finalize().as_bytes() } /// Sign an AuthorManifest: BLAKE3 digest → ed25519 sign. pub fn sign_manifest(seed: &[u8; 32], manifest: &crate::types::AuthorManifest) -> Vec { let digest = manifest_digest(manifest); let signing_key = SigningKey::from_bytes(seed); let sig = signing_key.sign(&digest); sig.to_bytes().to_vec() } /// Verify an AuthorManifest signature against the embedded author public key. pub fn verify_manifest_signature(manifest: &crate::types::AuthorManifest) -> bool { if manifest.signature.len() != 64 { return false; } let sig_bytes: [u8; 64] = match manifest.signature.as_slice().try_into() { Ok(b) => b, Err(_) => return false, }; let digest = manifest_digest(manifest); let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes); let Ok(verifying_key) = VerifyingKey::from_bytes(&manifest.author) else { return false; }; verifying_key.verify(&digest, &sig).is_ok() } /// Re-encrypt a post with a brand new CEK for a new set of recipients. /// /// Decrypts with the old keys, then encrypts fresh. Returns new (base64_ciphertext, wrapped_keys). pub fn re_encrypt_post( encrypted_b64: &str, our_seed: &[u8; 32], our_node_id: &NodeId, existing_recipients: &[WrappedKey], new_recipient_ids: &[NodeId], ) -> Result<(String, Vec)> { let plaintext = decrypt_post(encrypted_b64, our_seed, our_node_id, our_node_id, existing_recipients)? .ok_or_else(|| anyhow::anyhow!("cannot decrypt post for re-encryption"))?; encrypt_post(&plaintext, our_seed, our_node_id, new_recipient_ids) } // --- Engagement crypto --- const REACTION_WRAP_CONTEXT: &str = "itsgoin/private-reaction/v1"; const COMMENT_SIGN_CONTEXT: &str = "itsgoin/comment-sig/v1"; /// Encrypt a private reaction payload (only the post author can decrypt). /// Uses X25519 DH between reactor and author, then ChaCha20-Poly1305. /// Returns base64(nonce(12) || ciphertext || tag(16)). pub fn encrypt_private_reaction( reactor_seed: &[u8; 32], author_node_id: &NodeId, plaintext: &str, ) -> Result { let our_private = ed25519_seed_to_x25519_private(reactor_seed); let their_public = ed25519_pubkey_to_x25519_public(author_node_id)?; let shared = x25519_dh(&our_private, &their_public); let wrap_key = blake3::derive_key(REACTION_WRAP_CONTEXT, &shared); let cipher = ChaCha20Poly1305::new_from_slice(&wrap_key) .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, plaintext.as_bytes()) .map_err(|e| anyhow::anyhow!("encrypt: {}", e))?; let mut combined = Vec::with_capacity(12 + ciphertext.len()); combined.extend_from_slice(&nonce_bytes); combined.extend_from_slice(&ciphertext); Ok(base64::engine::general_purpose::STANDARD.encode(&combined)) } /// Decrypt a private reaction (only the post author can do this). /// Takes the author's seed and the reactor's NodeId. pub fn decrypt_private_reaction( author_seed: &[u8; 32], reactor_node_id: &NodeId, encrypted_b64: &str, ) -> Result { let our_private = ed25519_seed_to_x25519_private(author_seed); let their_public = ed25519_pubkey_to_x25519_public(reactor_node_id)?; let shared = x25519_dh(&our_private, &their_public); let wrap_key = blake3::derive_key(REACTION_WRAP_CONTEXT, &shared); let combined = base64::engine::general_purpose::STANDARD.decode(encrypted_b64)?; if combined.len() < 12 { bail!("encrypted reaction too short"); } let nonce = Nonce::from_slice(&combined[..12]); let ciphertext = &combined[12..]; let cipher = ChaCha20Poly1305::new_from_slice(&wrap_key) .map_err(|e| anyhow::anyhow!("cipher init: {}", e))?; let plaintext = cipher.decrypt(nonce, ciphertext) .map_err(|_| anyhow::anyhow!("decrypt failed — wrong key or corrupted data"))?; String::from_utf8(plaintext).map_err(|e| anyhow::anyhow!("invalid utf8: {}", e)) } /// Sign a comment: ed25519 over BLAKE3(author || post_id || content || timestamp_ms). pub fn sign_comment( seed: &[u8; 32], author: &NodeId, post_id: &PostId, content: &str, timestamp_ms: u64, ) -> Vec { let signing_key = SigningKey::from_bytes(seed); let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT); hasher.update(author); hasher.update(post_id); hasher.update(content.as_bytes()); hasher.update(×tamp_ms.to_le_bytes()); let digest = hasher.finalize(); signing_key.sign(digest.as_bytes()).to_bytes().to_vec() } /// Verify a comment's ed25519 signature. pub fn verify_comment_signature( author: &NodeId, post_id: &PostId, content: &str, timestamp_ms: u64, signature: &[u8], ) -> bool { let Ok(verifying_key) = VerifyingKey::from_bytes(author) else { return false; }; let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else { return false; }; let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT); hasher.update(author); hasher.update(post_id); hasher.update(content.as_bytes()); hasher.update(×tamp_ms.to_le_bytes()); let digest = hasher.finalize(); verifying_key.verify(digest.as_bytes(), &sig).is_ok() } #[cfg(test)] mod tests { use super::*; fn make_keypair(seed_byte: u8) -> ([u8; 32], NodeId) { let mut seed = [0u8; 32]; seed[0] = seed_byte; let signing_key = SigningKey::from_bytes(&seed); let node_id: NodeId = signing_key.verifying_key().to_bytes(); (seed, node_id) } #[test] fn test_encrypt_decrypt_roundtrip() { let (alice_seed, alice_id) = make_keypair(1); let (bob_seed, bob_id) = make_keypair(2); let plaintext = "Hello, Bob! This is a secret message."; let (encrypted, wrapped_keys) = encrypt_post(plaintext, &alice_seed, &alice_id, &[bob_id]).unwrap(); // Alice (sender) can decrypt let decrypted = decrypt_post(&encrypted, &alice_seed, &alice_id, &alice_id, &wrapped_keys).unwrap(); assert_eq!(decrypted.as_deref(), Some(plaintext)); // Bob (recipient) can decrypt let decrypted = decrypt_post(&encrypted, &bob_seed, &bob_id, &alice_id, &wrapped_keys).unwrap(); assert_eq!(decrypted.as_deref(), Some(plaintext)); } #[test] fn test_non_recipient_cannot_decrypt() { let (alice_seed, alice_id) = make_keypair(1); let (_bob_seed, bob_id) = make_keypair(2); let (carol_seed, carol_id) = make_keypair(3); let plaintext = "Secret for Bob only"; let (encrypted, wrapped_keys) = encrypt_post(plaintext, &alice_seed, &alice_id, &[bob_id]).unwrap(); // Carol is not a recipient let result = decrypt_post(&encrypted, &carol_seed, &carol_id, &alice_id, &wrapped_keys).unwrap(); assert_eq!(result, None); } #[test] fn test_author_always_included() { let (alice_seed, alice_id) = make_keypair(1); let (_bob_seed, bob_id) = make_keypair(2); let (_encrypted, wrapped_keys) = encrypt_post("test", &alice_seed, &alice_id, &[bob_id]).unwrap(); // Alice should be in recipients even though only Bob was passed assert!(wrapped_keys.iter().any(|wk| wk.recipient == alice_id)); assert!(wrapped_keys.iter().any(|wk| wk.recipient == bob_id)); } #[test] fn test_multiple_recipients() { let (alice_seed, alice_id) = make_keypair(1); let (bob_seed, bob_id) = make_keypair(2); let (carol_seed, carol_id) = make_keypair(3); let plaintext = "Group message!"; let (encrypted, wrapped_keys) = encrypt_post(plaintext, &alice_seed, &alice_id, &[bob_id, carol_id]).unwrap(); // All three can decrypt for (seed, nid) in [ (&alice_seed, &alice_id), (&bob_seed, &bob_id), (&carol_seed, &carol_id), ] { let result = decrypt_post(&encrypted, seed, nid, &alice_id, &wrapped_keys).unwrap(); assert_eq!(result.as_deref(), Some(plaintext)); } } #[test] fn test_x25519_conversion() { let (seed, node_id) = make_keypair(42); let x_priv = ed25519_seed_to_x25519_private(&seed); let x_pub = ed25519_pubkey_to_x25519_public(&node_id).unwrap(); // Verify: private * basepoint == public let derived_pub = MontgomeryPoint::mul_base_clamped(x_priv); assert_eq!(derived_pub.to_bytes(), x_pub); } #[test] fn test_sign_verify_delete() { let (seed, node_id) = make_keypair(1); let post_id = [42u8; 32]; let sig = sign_delete(&seed, &post_id); assert_eq!(sig.len(), 64); assert!(verify_delete_signature(&node_id, &post_id, &sig)); } #[test] fn test_forged_delete_rejected() { let (seed, _alice_id) = make_keypair(1); let (_bob_seed, bob_id) = make_keypair(2); let post_id = [42u8; 32]; // Alice signs, but we check against Bob's key let sig = sign_delete(&seed, &post_id); assert!(!verify_delete_signature(&bob_id, &post_id, &sig)); // Wrong post_id let wrong_id = [99u8; 32]; assert!(!verify_delete_signature(&_alice_id, &wrong_id, &sig)); } #[test] fn test_rewrap_roundtrip() { let (alice_seed, alice_id) = make_keypair(1); let (bob_seed, bob_id) = make_keypair(2); let (_carol_seed, carol_id) = make_keypair(3); let plaintext = "secret message"; let (encrypted, original_keys) = encrypt_post(plaintext, &alice_seed, &alice_id, &[bob_id, carol_id]).unwrap(); // Re-wrap excluding carol (only alice + bob remain) let new_keys = rewrap_visibility(&alice_seed, &alice_id, &original_keys, &[alice_id, bob_id]).unwrap(); // Alice can still decrypt let dec = decrypt_post(&encrypted, &alice_seed, &alice_id, &alice_id, &new_keys).unwrap(); assert_eq!(dec.as_deref(), Some(plaintext)); // Bob can still decrypt let dec = decrypt_post(&encrypted, &bob_seed, &bob_id, &alice_id, &new_keys).unwrap(); assert_eq!(dec.as_deref(), Some(plaintext)); } #[test] fn test_revoked_cannot_decrypt_after_rewrap() { let (alice_seed, alice_id) = make_keypair(1); let (_bob_seed, bob_id) = make_keypair(2); let (carol_seed, carol_id) = make_keypair(3); let plaintext = "secret message"; let (encrypted, original_keys) = encrypt_post(plaintext, &alice_seed, &alice_id, &[bob_id, carol_id]).unwrap(); // Re-wrap excluding carol let new_keys = rewrap_visibility(&alice_seed, &alice_id, &original_keys, &[alice_id, bob_id]).unwrap(); // Carol cannot decrypt with new keys let dec = decrypt_post(&encrypted, &carol_seed, &carol_id, &alice_id, &new_keys).unwrap(); assert_eq!(dec, None); } #[test] fn test_re_encrypt_roundtrip() { let (alice_seed, alice_id) = make_keypair(1); let (bob_seed, bob_id) = make_keypair(2); let (carol_seed, carol_id) = make_keypair(3); let plaintext = "re-encrypt test"; let (encrypted, original_keys) = encrypt_post(plaintext, &alice_seed, &alice_id, &[bob_id, carol_id]).unwrap(); // Re-encrypt excluding carol let (new_encrypted, new_keys) = re_encrypt_post(&encrypted, &alice_seed, &alice_id, &original_keys, &[bob_id]).unwrap(); // Bob can decrypt new ciphertext let dec = decrypt_post(&new_encrypted, &bob_seed, &bob_id, &alice_id, &new_keys).unwrap(); assert_eq!(dec.as_deref(), Some(plaintext)); // Carol cannot decrypt new ciphertext (not a recipient + different CEK) let dec = decrypt_post(&new_encrypted, &carol_seed, &carol_id, &alice_id, &new_keys).unwrap(); assert_eq!(dec, None); // Carol cannot decrypt new ciphertext even with old keys (different CEK — will error or return wrong plaintext) let dec = decrypt_post(&new_encrypted, &carol_seed, &carol_id, &alice_id, &original_keys); // Either returns None (not a recipient in new keys) or an error (wrong CEK for new ciphertext) match dec { Ok(None) => {} // Not a recipient Err(_) => {} // AEAD decryption failure — expected with wrong CEK Ok(Some(_)) => panic!("carol should not be able to decrypt re-encrypted post"), } } #[test] fn test_sign_verify_manifest() { use crate::types::{AuthorManifest, ManifestEntry}; let (seed, node_id) = make_keypair(1); let mut manifest = AuthorManifest { post_id: [42u8; 32], author: node_id, author_addresses: vec!["10.0.0.1:4433".to_string()], created_at: 1000, updated_at: 2000, previous_posts: vec![ManifestEntry { post_id: [1u8; 32], timestamp_ms: 900, has_attachments: false, }], following_posts: vec![], signature: vec![], }; manifest.signature = sign_manifest(&seed, &manifest); assert_eq!(manifest.signature.len(), 64); assert!(verify_manifest_signature(&manifest)); } #[test] fn test_forged_manifest_rejected() { use crate::types::AuthorManifest; let (seed, node_id) = make_keypair(1); let (_bob_seed, bob_id) = make_keypair(2); let mut manifest = AuthorManifest { post_id: [42u8; 32], author: node_id, author_addresses: vec![], created_at: 1000, updated_at: 2000, previous_posts: vec![], following_posts: vec![], signature: vec![], }; manifest.signature = sign_manifest(&seed, &manifest); // Tamper with author → verification fails manifest.author = bob_id; assert!(!verify_manifest_signature(&manifest)); // Restore author, tamper with updated_at → fails manifest.author = node_id; manifest.updated_at = 9999; assert!(!verify_manifest_signature(&manifest)); } #[test] fn test_group_key_gen_and_id() { let (seed1, pubkey1) = generate_group_keypair(); let (seed2, pubkey2) = generate_group_keypair(); assert_ne!(seed1, seed2); assert_ne!(pubkey1, pubkey2); let id1 = compute_group_id(&pubkey1); let id2 = compute_group_id(&pubkey2); assert_ne!(id1, id2); // Deterministic assert_eq!(compute_group_id(&pubkey1), id1); } #[test] fn test_group_key_wrap_unwrap_roundtrip() { let (admin_seed, admin_id) = make_keypair(1); let (bob_seed, bob_id) = make_keypair(2); let (group_seed, _group_pubkey) = generate_group_keypair(); // Admin wraps for Bob let wrapped = wrap_group_key_for_member(&admin_seed, &bob_id, &group_seed).unwrap(); assert_eq!(wrapped.len(), 60); // Bob unwraps using admin's public key let unwrapped = unwrap_group_key(&bob_seed, &admin_id, &wrapped).unwrap(); assert_eq!(unwrapped, group_seed); } #[test] fn test_group_key_wrap_unwrap_self() { let (admin_seed, admin_id) = make_keypair(1); let (group_seed, _) = generate_group_keypair(); let wrapped = wrap_group_key_for_member(&admin_seed, &admin_id, &group_seed).unwrap(); let unwrapped = unwrap_group_key(&admin_seed, &admin_id, &wrapped).unwrap(); assert_eq!(unwrapped, group_seed); } #[test] fn test_group_encrypt_decrypt_roundtrip() { let (group_seed, group_pubkey) = generate_group_keypair(); let plaintext = "Hello group members!"; let (encrypted, wrapped_cek) = encrypt_post_for_group(plaintext, &group_seed, &group_pubkey).unwrap(); assert_eq!(wrapped_cek.len(), 60); let decrypted = decrypt_group_post(&encrypted, &group_seed, &group_pubkey, &wrapped_cek).unwrap(); assert_eq!(decrypted, plaintext); } #[test] fn test_group_decrypt_wrong_seed_fails() { let (group_seed, group_pubkey) = generate_group_keypair(); let (wrong_seed, _) = generate_group_keypair(); let plaintext = "Secret message"; let (encrypted, wrapped_cek) = encrypt_post_for_group(plaintext, &group_seed, &group_pubkey).unwrap(); let result = decrypt_group_post(&encrypted, &wrong_seed, &group_pubkey, &wrapped_cek); assert!(result.is_err()); } #[test] fn test_rotate_group_key() { let (admin_seed, admin_id) = make_keypair(1); let (_bob_seed, bob_id) = make_keypair(2); let (_carol_seed, carol_id) = make_keypair(3); let (new_seed, new_pubkey, new_epoch, member_keys) = rotate_group_key(&admin_seed, 1, &[admin_id, bob_id, carol_id]).unwrap(); assert_eq!(new_epoch, 2); assert_eq!(member_keys.len(), 3); for mk in &member_keys { assert_eq!(mk.epoch, 2); assert_eq!(mk.wrapped_group_key.len(), 60); } // Verify the new seed can encrypt/decrypt let plaintext = "New epoch message"; let (encrypted, wrapped_cek) = encrypt_post_for_group(plaintext, &new_seed, &new_pubkey).unwrap(); let decrypted = decrypt_group_post(&encrypted, &new_seed, &new_pubkey, &wrapped_cek).unwrap(); assert_eq!(decrypted, plaintext); } #[test] fn test_rotate_then_decrypt_old_epoch() { let (group_seed_v1, group_pubkey_v1) = generate_group_keypair(); let plaintext_v1 = "Old epoch message"; let (encrypted_v1, wrapped_cek_v1) = encrypt_post_for_group(plaintext_v1, &group_seed_v1, &group_pubkey_v1).unwrap(); // Rotate — new key pair let (group_seed_v2, group_pubkey_v2) = generate_group_keypair(); let plaintext_v2 = "New epoch message"; let (encrypted_v2, wrapped_cek_v2) = encrypt_post_for_group(plaintext_v2, &group_seed_v2, &group_pubkey_v2).unwrap(); // Old epoch still decryptable with old seed let dec_v1 = decrypt_group_post(&encrypted_v1, &group_seed_v1, &group_pubkey_v1, &wrapped_cek_v1).unwrap(); assert_eq!(dec_v1, plaintext_v1); // New epoch decryptable with new seed let dec_v2 = decrypt_group_post(&encrypted_v2, &group_seed_v2, &group_pubkey_v2, &wrapped_cek_v2).unwrap(); assert_eq!(dec_v2, plaintext_v2); // Old seed cannot decrypt new epoch let result = decrypt_group_post(&encrypted_v2, &group_seed_v1, &group_pubkey_v1, &wrapped_cek_v2); assert!(result.is_err()); } }