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"; /// FoF Layer 1: vouch-grant HPKE-style wrapper construction. /// HKDF/derive_key info MUST be recipient-free (key privacy). /// `bio_post_id` ties the wrapper to the publishing bio post. const VOUCH_GRANT_KEY_CONTEXT: &str = "itsgoin/vouch-grant/v1/key"; const VOUCH_GRANT_NONCE_CONTEXT: &str = "itsgoin/vouch-grant/v1/nonce"; /// FoF Layer 2: per-V_x wrap-slot derivation contexts. Each slot is /// dual-derived under a different sub-context: `read` yields the CEK /// (read capability), `sign` yields the per-V_x signing seed. Bound to /// the post via `slot_binder_nonce` (a random 32B nonce in the post /// header — not the PostId, which would be circular). const WRAP_SLOT_READ_KEY_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/read/key"; const WRAP_SLOT_READ_NONCE_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/read/nonce"; const WRAP_SLOT_SIGN_KEY_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/sign/key"; const WRAP_SLOT_SIGN_NONCE_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/sign/nonce"; const WRAP_SLOT_PREFILTER_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/prefilter"; /// 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) } // --- FoF Layer 1: vouch-grant HPKE-style seal/open --- // // Per the FoF spec (docs/fof-spec/layer-1-vouch-primitive.md), a voucher // publishes anonymous per-recipient wrappers inside their bio post. Each // wrapper carries `V_me` (the voucher's symmetric key) sealed under a // shared secret derived from ECDH between a per-batch ephemeral X25519 // keypair and the recipient's persona X25519 key. // // Recipient anonymity ("key privacy") is preserved because: // 1. Wrappers carry no recipient identifier. // 2. The KDF info string is recipient-free (only the post_id appears). // 3. All wrappers in a batch share the same ephemeral pubkey. // // Wire shape: 48 bytes per wrapper (32B sealed V_me + 16B AEAD tag). // One 32B ephemeral pubkey shared across all wrappers in the batch. /// Generate a fresh ephemeral X25519 keypair for a vouch-grant batch. /// Returns `(eph_priv_scalar, eph_pub)` in X25519 byte form. Reuses the /// ed25519 → X25519 derivation path that the rest of the codebase uses /// so all X25519 endpoints are produced identically. pub fn generate_vouch_batch_ephemeral() -> ([u8; 32], [u8; 32]) { let mut seed = [0u8; 32]; rand::rng().fill_bytes(&mut seed); let eph_priv = ed25519_seed_to_x25519_private(&seed); let signing_key = SigningKey::from_bytes(&seed); let eph_pub = signing_key.verifying_key().to_montgomery().to_bytes(); (eph_priv, eph_pub) } /// Derive the (wrapping_key, nonce) pair for a vouch-grant wrapper from /// the ECDH shared secret and the publishing bio post's ID. fn derive_vouch_grant_key_nonce( shared_secret: &[u8; 32], bio_post_id: &PostId, ) -> ([u8; 32], [u8; 12]) { // Bake bio_post_id into the derivation context. Recipient-free. let key_ctx = format!("{}/{}", VOUCH_GRANT_KEY_CONTEXT, hex_lower(bio_post_id)); let nonce_ctx = format!("{}/{}", VOUCH_GRANT_NONCE_CONTEXT, hex_lower(bio_post_id)); let wrapping_key = blake3::derive_key(&key_ctx, shared_secret); let nonce_full = blake3::derive_key(&nonce_ctx, shared_secret); let mut nonce = [0u8; 12]; nonce.copy_from_slice(&nonce_full[..12]); (wrapping_key, nonce) } fn hex_lower(bytes: &[u8; 32]) -> String { let mut s = String::with_capacity(64); for b in bytes { s.push_str(&format!("{:02x}", b)); } s } /// Seal `V_me` (32B) under the recipient's X25519 pubkey using the /// batch's ephemeral X25519 private key. Returns the 48-byte wrapper /// `ciphertext(32) || tag(16)`. pub fn seal_vouch_grant( eph_priv: &[u8; 32], recipient_x25519_pub: &[u8; 32], bio_post_id: &PostId, v_me: &[u8; 32], ) -> Result> { let shared_secret = x25519_dh(eph_priv, recipient_x25519_pub); let (wrapping_key, nonce) = derive_vouch_grant_key_nonce(&shared_secret, bio_post_id); let cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key) .map_err(|e| anyhow::anyhow!("vouch-grant cipher init: {}", e))?; let ciphertext = cipher .encrypt(Nonce::from_slice(&nonce), v_me.as_slice()) .map_err(|e| anyhow::anyhow!("vouch-grant seal: {}", e))?; // ChaCha20-Poly1305 output is 32B plaintext + 16B tag = 48B. if ciphertext.len() != 48 { bail!("unexpected vouch-grant wrapper length: {}", ciphertext.len()); } Ok(ciphertext) } /// Try to open a vouch-grant wrapper using the recipient's X25519 private /// scalar. Returns `Some(V_me)` on success, `None` on AEAD failure (i.e., /// this wrapper was not addressed to this recipient). pub fn open_vouch_grant( recipient_x25519_priv: &[u8; 32], batch_eph_pub: &[u8; 32], bio_post_id: &PostId, wrapper_ciphertext: &[u8], ) -> Option<[u8; 32]> { if wrapper_ciphertext.len() != 48 { return None; } let shared_secret = x25519_dh(recipient_x25519_priv, batch_eph_pub); let (wrapping_key, nonce) = derive_vouch_grant_key_nonce(&shared_secret, bio_post_id); let cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key).ok()?; let plaintext = cipher .decrypt(Nonce::from_slice(&nonce), wrapper_ciphertext) .ok()?; if plaintext.len() != 32 { return None; } let mut v_me = [0u8; 32]; v_me.copy_from_slice(&plaintext); Some(v_me) } // --- FoF Layer 2: wrap-slot seal/open (dual-derived read + sign) --- // // Each post under FoF comment-gating carries one wrap slot per // admitted V_x. The slot is dual-derived: one half yields the post's // shared CEK (read capability), the other yields the per-V_x signing // seed priv_x (comment-authorship capability for that voucher-chain). // // Receivers trial-decrypt slots whose prefilter tag matches one of // their held V_x's. Successful AEAD-open on the `read` part gives // them CEK; the `sign` part gives them priv_x. They derive the // matching pub_x = ed25519_pub(priv_x_seed); the CDN verifies their // comment signatures against pub_x via the post's pub_post_set. // // All AEAD derivation is bound to a per-post `slot_binder_nonce` // (random 32B in the post header). This plays the same role as the // spec's "post_id in HKDF info" but isn't circular (PostId = // BLAKE3(post) depends on wrap_slots → circular). // // Wire shape: // prefilter_tag: 2 bytes (HMAC(V_x, slot_binder_nonce)[:2]) // read_part: 48 bytes (32B sealed CEK + 16B tag) // sign_part: 48 bytes (32B sealed priv_x seed + 16B tag) // Total: 98 bytes per slot. /// Output of [`seal_wrap_slot`]. All fields are wire-stable. See module /// doc above for derivation details. #[derive(Debug, Clone)] pub struct SealedWrapSlot { pub prefilter_tag: [u8; 2], pub read_ciphertext: Vec, // 48 bytes pub sign_ciphertext: Vec, // 48 bytes } /// Compute the 2-byte prefilter tag for a (V_x, slot_binder_nonce) /// pair. Cheap; receivers precompute one per held V_x per post and /// skip non-matching slots entirely. pub fn wrap_slot_prefilter_tag(v_x: &[u8; 32], slot_binder_nonce: &[u8; 32]) -> [u8; 2] { let mut input = [0u8; 64]; input[..32].copy_from_slice(slot_binder_nonce); input[32..].copy_from_slice(v_x); let tag = blake3::derive_key(WRAP_SLOT_PREFILTER_CONTEXT, &input); [tag[0], tag[1]] } fn derive_wrap_slot_part( v_x: &[u8; 32], slot_binder_nonce: &[u8; 32], key_ctx: &str, nonce_ctx: &str, ) -> ([u8; 32], [u8; 12]) { let mut input = [0u8; 64]; input[..32].copy_from_slice(slot_binder_nonce); input[32..].copy_from_slice(v_x); let key = blake3::derive_key(key_ctx, &input); let nonce_full = blake3::derive_key(nonce_ctx, &input); let mut nonce = [0u8; 12]; nonce.copy_from_slice(&nonce_full[..12]); (key, nonce) } /// Seal one wrap slot for a specific V_x. Pair (CEK, priv_x_seed) is /// the slot plaintext: the read part carries CEK, the sign part carries /// priv_x_seed. Both halves are bound to `slot_binder_nonce` via HKDF. pub fn seal_wrap_slot( v_x: &[u8; 32], slot_binder_nonce: &[u8; 32], cek: &[u8; 32], priv_x_seed: &[u8; 32], ) -> Result { let (read_key, read_nonce) = derive_wrap_slot_part( v_x, slot_binder_nonce, WRAP_SLOT_READ_KEY_CONTEXT, WRAP_SLOT_READ_NONCE_CONTEXT, ); let (sign_key, sign_nonce) = derive_wrap_slot_part( v_x, slot_binder_nonce, WRAP_SLOT_SIGN_KEY_CONTEXT, WRAP_SLOT_SIGN_NONCE_CONTEXT, ); let read_cipher = ChaCha20Poly1305::new_from_slice(&read_key) .map_err(|e| anyhow::anyhow!("read cipher init: {}", e))?; let sign_cipher = ChaCha20Poly1305::new_from_slice(&sign_key) .map_err(|e| anyhow::anyhow!("sign cipher init: {}", e))?; let read_ct = read_cipher .encrypt(Nonce::from_slice(&read_nonce), cek.as_slice()) .map_err(|e| anyhow::anyhow!("read seal: {}", e))?; let sign_ct = sign_cipher .encrypt(Nonce::from_slice(&sign_nonce), priv_x_seed.as_slice()) .map_err(|e| anyhow::anyhow!("sign seal: {}", e))?; if read_ct.len() != 48 || sign_ct.len() != 48 { bail!("unexpected wrap-slot ciphertext length"); } Ok(SealedWrapSlot { prefilter_tag: wrap_slot_prefilter_tag(v_x, slot_binder_nonce), read_ciphertext: read_ct, sign_ciphertext: sign_ct, }) } /// Output of a successful [`open_wrap_slot`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct OpenedWrapSlot { pub cek: [u8; 32], pub priv_x_seed: [u8; 32], } /// Try to open a wrap slot using one of the receiver's V_x's. Returns /// `None` if either AEAD fails (this slot isn't sealed under this V_x). pub fn open_wrap_slot( v_x: &[u8; 32], slot_binder_nonce: &[u8; 32], read_ciphertext: &[u8], sign_ciphertext: &[u8], ) -> Option { if read_ciphertext.len() != 48 || sign_ciphertext.len() != 48 { return None; } let (read_key, read_nonce) = derive_wrap_slot_part( v_x, slot_binder_nonce, WRAP_SLOT_READ_KEY_CONTEXT, WRAP_SLOT_READ_NONCE_CONTEXT, ); let (sign_key, sign_nonce) = derive_wrap_slot_part( v_x, slot_binder_nonce, WRAP_SLOT_SIGN_KEY_CONTEXT, WRAP_SLOT_SIGN_NONCE_CONTEXT, ); let read_cipher = ChaCha20Poly1305::new_from_slice(&read_key).ok()?; let sign_cipher = ChaCha20Poly1305::new_from_slice(&sign_key).ok()?; let cek_bytes = read_cipher .decrypt(Nonce::from_slice(&read_nonce), read_ciphertext) .ok()?; let seed_bytes = sign_cipher .decrypt(Nonce::from_slice(&sign_nonce), sign_ciphertext) .ok()?; if cek_bytes.len() != 32 || seed_bytes.len() != 32 { return None; } let mut cek = [0u8; 32]; cek.copy_from_slice(&cek_bytes); let mut priv_x_seed = [0u8; 32]; priv_x_seed.copy_from_slice(&seed_bytes); Some(OpenedWrapSlot { cek, priv_x_seed }) } /// Derive the per-post comments CEK from the wrap-slot CEK. The /// comments-CEK is used to encrypt comment bodies separately from the /// post body — preserves the option of body-public + comments-private /// (Mode 2) without leaking the body CEK relationship. pub fn derive_cek_comments(cek: &[u8; 32], slot_binder_nonce: &[u8; 32]) -> [u8; 32] { let mut input = [0u8; 64]; input[..32].copy_from_slice(cek); input[32..].copy_from_slice(slot_binder_nonce); blake3::derive_key("itsgoin/fof-cek-comments/v1", &input) } /// 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() } /// Canonical bytes for a ControlOp::DeletePost signature. fn control_delete_bytes(post_id: &PostId, timestamp_ms: u64) -> Vec { let mut buf = Vec::with_capacity(12 + 32 + 8); buf.extend_from_slice(b"ctrl:delete:"); buf.extend_from_slice(post_id); buf.extend_from_slice(×tamp_ms.to_le_bytes()); buf } /// Sign a control-post DeletePost operation. pub fn sign_control_delete(seed: &[u8; 32], post_id: &PostId, timestamp_ms: u64) -> Vec { let signing_key = SigningKey::from_bytes(seed); let sig = signing_key.sign(&control_delete_bytes(post_id, timestamp_ms)); sig.to_bytes().to_vec() } pub fn verify_control_delete( author: &NodeId, post_id: &PostId, timestamp_ms: u64, 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(vk) = VerifyingKey::from_bytes(author) else { return false }; vk.verify_strict(&control_delete_bytes(post_id, timestamp_ms), &sig).is_ok() } /// Canonical bytes for a ControlOp::UpdateVisibility signature. Uses JSON /// round-trip on the visibility payload because PostVisibility is an enum /// with variable shape; callers must pass the exact same bytes when verifying. fn control_visibility_bytes( post_id: &PostId, new_visibility_canonical: &[u8], timestamp_ms: u64, ) -> Vec { let mut buf = Vec::with_capacity(10 + 32 + new_visibility_canonical.len() + 8); buf.extend_from_slice(b"ctrl:vis:"); buf.extend_from_slice(post_id); buf.extend_from_slice(new_visibility_canonical); buf.extend_from_slice(×tamp_ms.to_le_bytes()); buf } pub fn sign_control_visibility( seed: &[u8; 32], post_id: &PostId, new_visibility: &crate::types::PostVisibility, timestamp_ms: u64, ) -> Vec { let canon = serde_json::to_vec(new_visibility).unwrap_or_default(); let signing_key = SigningKey::from_bytes(seed); let sig = signing_key.sign(&control_visibility_bytes(post_id, &canon, timestamp_ms)); sig.to_bytes().to_vec() } pub fn verify_control_visibility( author: &NodeId, post_id: &PostId, new_visibility: &crate::types::PostVisibility, timestamp_ms: u64, 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(vk) = VerifyingKey::from_bytes(author) else { return false }; let canon = match serde_json::to_vec(new_visibility) { Ok(v) => v, Err(_) => return false }; vk.verify_strict(&control_visibility_bytes(post_id, &canon, timestamp_ms), &sig).is_ok() } /// Canonical bytes for a Profile-post signature: length-prefixed display_name /// and bio, 32-byte avatar_cid (or zeros), then timestamp_ms. Length prefixes /// prevent extension/reordering attacks. fn profile_post_bytes( display_name: &str, bio: &str, avatar_cid: &Option<[u8; 32]>, timestamp_ms: u64, ) -> Vec { let dn = display_name.as_bytes(); let bio_bytes = bio.as_bytes(); let mut buf = Vec::with_capacity(5 + 8 + dn.len() + 8 + bio_bytes.len() + 32 + 8); buf.extend_from_slice(b"prof:"); buf.extend_from_slice(&(dn.len() as u64).to_le_bytes()); buf.extend_from_slice(dn); buf.extend_from_slice(&(bio_bytes.len() as u64).to_le_bytes()); buf.extend_from_slice(bio_bytes); let avatar = avatar_cid.unwrap_or([0u8; 32]); buf.extend_from_slice(&avatar); buf.extend_from_slice(×tamp_ms.to_le_bytes()); buf } pub fn sign_profile( seed: &[u8; 32], display_name: &str, bio: &str, avatar_cid: &Option<[u8; 32]>, timestamp_ms: u64, ) -> Vec { let signing_key = SigningKey::from_bytes(seed); let sig = signing_key.sign(&profile_post_bytes(display_name, bio, avatar_cid, timestamp_ms)); sig.to_bytes().to_vec() } pub fn verify_profile( author: &NodeId, display_name: &str, bio: &str, avatar_cid: &Option<[u8; 32]>, timestamp_ms: u64, 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(vk) = VerifyingKey::from_bytes(author) else { return false }; vk.verify_strict(&profile_post_bytes(display_name, bio, avatar_cid, timestamp_ms), &sig).is_ok() } /// Canonical bytes for an announcement signature. Length-prefixed strings /// prevent extension/reordering attacks; the release subfields are all /// bundled after a 1-byte "has release" flag. fn announcement_bytes( category: &str, title: &str, body: &str, timestamp_ms: u64, release: &Option, ) -> Vec { let cat = category.as_bytes(); let tit = title.as_bytes(); let bd = body.as_bytes(); let mut buf = Vec::with_capacity(128 + cat.len() + tit.len() + bd.len()); buf.extend_from_slice(b"annc:"); buf.extend_from_slice(&(cat.len() as u64).to_le_bytes()); buf.extend_from_slice(cat); buf.extend_from_slice(&(tit.len() as u64).to_le_bytes()); buf.extend_from_slice(tit); buf.extend_from_slice(&(bd.len() as u64).to_le_bytes()); buf.extend_from_slice(bd); buf.extend_from_slice(×tamp_ms.to_le_bytes()); match release { Some(r) => { buf.push(1u8); let c = r.channel.as_bytes(); let v = r.version.as_bytes(); let u = r.download_url.as_bytes(); buf.extend_from_slice(&(c.len() as u64).to_le_bytes()); buf.extend_from_slice(c); buf.extend_from_slice(&(v.len() as u64).to_le_bytes()); buf.extend_from_slice(v); buf.extend_from_slice(&(u.len() as u64).to_le_bytes()); buf.extend_from_slice(u); } None => buf.push(0u8), } buf } pub fn sign_announcement( seed: &[u8; 32], category: &str, title: &str, body: &str, timestamp_ms: u64, release: &Option, ) -> Vec { let signing_key = SigningKey::from_bytes(seed); let sig = signing_key.sign(&announcement_bytes(category, title, body, timestamp_ms, release)); sig.to_bytes().to_vec() } pub fn verify_announcement( author: &NodeId, category: &str, title: &str, body: &str, timestamp_ms: u64, release: &Option, 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(vk) = VerifyingKey::from_bytes(author) else { return false }; vk.verify_strict(&announcement_bytes(category, title, body, timestamp_ms, release), &sig).is_ok() } /// 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 ‖ previous_posts_json ‖ following_posts_json) /// v0.8: author_addresses removed from AuthorManifest (and from this digest) — /// posting-identity manifests must not carry device addresses. 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 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) } // --- Slot encryption (receipt + comment slots for encrypted posts) --- const SLOT_KEY_CONTEXT: &str = "itsgoin/slot/v1"; /// Derive the slot encryption key from a post's CEK. /// Only participants who can unwrap the CEK can derive this key. pub fn derive_slot_key(cek: &[u8; 32]) -> [u8; 32] { blake3::derive_key(SLOT_KEY_CONTEXT, cek) } /// Encrypt a slot's plaintext bytes using the slot key (derived from CEK). /// Returns encrypted payload via encrypt_bytes_with_cek. pub fn encrypt_slot(plaintext: &[u8], slot_key: &[u8; 32]) -> Result> { encrypt_bytes_with_cek(plaintext, slot_key) } /// Decrypt a slot's encrypted bytes using the slot key (derived from CEK). pub fn decrypt_slot(encrypted: &[u8], slot_key: &[u8; 32]) -> Result> { decrypt_bytes_with_cek(encrypted, slot_key) } /// Generate a random noise-filled slot (indistinguishable from encrypted data). pub fn random_slot_noise(size: usize) -> Vec { let mut buf = vec![0u8; size]; rand::rng().fill_bytes(&mut buf); buf } // --- Engagement crypto --- const REACTION_WRAP_CONTEXT: &str = "itsgoin/private-reaction/v1"; /// v0.8 (A3): digest v2 — expires_at_ms enters the signed digest so a /// comment's TTL can never be silently extended by holders. v0.8 has /// ZERO wire-compat obligations (zero-users ruling), so v1 is gone. const COMMENT_SIGN_CONTEXT: &str = "itsgoin/comment-sig/v2"; const REACTION_SIGN_CONTEXT: &str = "itsgoin/reaction-sig/v1"; /// v0.8 (A3): self-certifying comment-delete signature context. const COMMENT_DELETE_CONTEXT: &str = "itsgoin/comment-delete/v1"; /// v0.8 (A3): derivable open-slot V_x context (design §27). Anyone can /// compute V_open from the post alone: author pubkey + slot_binder_nonce. const OPEN_SLOT_VX_CONTEXT: &str = "itsgoin/open-slot-vx/v1"; /// v0.8 (A3): greeting-body seal contexts. The bio post id is baked into /// the derivation (same domain-separation style as vouch grants). const GREETING_KEY_CONTEXT: &str = "itsgoin/greeting/v1/key"; const GREETING_NONCE_CONTEXT: &str = "itsgoin/greeting/v1/nonce"; /// 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 [|| ref:ref_post_id] || expires:expires_at_ms). /// /// Digest v2 (A3): `expires_at_ms` is part of the comment's identity — /// holders cannot extend a comment's life without invalidating the sig. fn comment_digest( author: &NodeId, post_id: &PostId, content: &str, timestamp_ms: u64, ref_post_id: Option<&PostId>, expires_at_ms: u64, ) -> blake3::Hash { 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()); // Domain-separated appends (same pattern as the v0.6.2 `ref:` field). if let Some(rid) = ref_post_id { hasher.update(b"ref:"); hasher.update(rid); } hasher.update(b"expires:"); hasher.update(&expires_at_ms.to_le_bytes()); hasher.finalize() } pub fn sign_comment( seed: &[u8; 32], author: &NodeId, post_id: &PostId, content: &str, timestamp_ms: u64, ref_post_id: Option<&PostId>, expires_at_ms: u64, ) -> Vec { let signing_key = SigningKey::from_bytes(seed); let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id, expires_at_ms); signing_key.sign(digest.as_bytes()).to_bytes().to_vec() } /// Verify a comment's ed25519 signature (digest v2, expiry included). pub fn verify_comment_signature( author: &NodeId, post_id: &PostId, content: &str, timestamp_ms: u64, signature: &[u8], ref_post_id: Option<&PostId>, expires_at_ms: u64, ) -> 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 digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id, expires_at_ms); verifying_key.verify(digest.as_bytes(), &sig).is_ok() } /// Convenience: verify an `InlineComment`'s identity signature. pub fn verify_inline_comment_signature(comment: &crate::types::InlineComment) -> bool { verify_comment_signature( &comment.author, &comment.post_id, &comment.content, comment.timestamp_ms, &comment.signature, comment.ref_post_id.as_ref(), comment.expires_at_ms, ) } // --- v0.8 (A3): self-certifying comment deletes --- fn comment_delete_digest(author: &NodeId, post_id: &PostId, timestamp_ms: u64) -> blake3::Hash { let mut hasher = blake3::Hasher::new_derive_key(COMMENT_DELETE_CONTEXT); hasher.update(author); hasher.update(post_id); hasher.update(×tamp_ms.to_le_bytes()); hasher.finalize() } /// Sign a comment delete: ed25519 by the comment author's posting key /// over BLAKE3(context="itsgoin/comment-delete/v1", author || post_id || /// timestamp_ms). Verifiable from the delete op alone — works on holders /// that never met the persona (registry unregister path). pub fn sign_comment_delete( seed: &[u8; 32], author: &NodeId, post_id: &PostId, timestamp_ms: u64, ) -> Vec { let signing_key = SigningKey::from_bytes(seed); let digest = comment_delete_digest(author, post_id, timestamp_ms); signing_key.sign(digest.as_bytes()).to_bytes().to_vec() } /// Verify a self-certifying comment-delete signature. pub fn verify_comment_delete( author: &NodeId, post_id: &PostId, 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 digest = comment_delete_digest(author, post_id, timestamp_ms); verifying_key.verify(digest.as_bytes(), &sig).is_ok() } // --- v0.8 (A3): derivable open-slot V_x (design §27) --- /// Derive the open slot's V_x from the post alone: /// `V_open = blake3::derive_key("itsgoin/open-slot-vx/v1", /// author_posting_pubkey || slot_binder_nonce)`. /// Computable by any stranger (author is on the Post, nonce is in the /// gating); distinct per publish. The CEK recovered through an open slot /// is PUBLIC — the outer CEK layer is camouflage only; confidentiality /// (greetings) comes from the inner HPKE-style seal. pub fn derive_open_slot_vx( author_posting_pubkey: &NodeId, slot_binder_nonce: &[u8; 32], ) -> [u8; 32] { let mut input = [0u8; 64]; input[..32].copy_from_slice(author_posting_pubkey); input[32..].copy_from_slice(slot_binder_nonce); blake3::derive_key(OPEN_SLOT_VX_CONTEXT, &input) } // --- v0.8 (A3): sealed greeting bodies (bucketed) --- fn derive_greeting_key_nonce( shared_secret: &[u8; 32], bio_post_id: &PostId, ) -> ([u8; 32], [u8; 12]) { let key_ctx = format!("{}/{}", GREETING_KEY_CONTEXT, hex_lower(bio_post_id)); let nonce_ctx = format!("{}/{}", GREETING_NONCE_CONTEXT, hex_lower(bio_post_id)); let key = blake3::derive_key(&key_ctx, shared_secret); let nonce_full = blake3::derive_key(&nonce_ctx, shared_secret); let mut nonce = [0u8; 12]; nonce.copy_from_slice(&nonce_full[..12]); (key, nonce) } /// Generate a fresh x25519 keypair `(priv_scalar, pub)` for greeting /// reply keys / ephemeral seal keys. Same ed25519→x25519 derivation path /// the rest of the codebase uses. pub fn generate_x25519_keypair() -> ([u8; 32], [u8; 32]) { generate_vouch_batch_ephemeral() } /// Seal a greeting body to `recipient_x25519_pub`, padded to exactly /// `body_bucket` plaintext bytes. Output layout: /// `eph_x25519_pub(32) || ChaCha20-Poly1305(len_u32_le || body || pad)`. /// Total ciphertext length is `32 + 4 + body_bucket + 16` for every /// greeting in the same bucket — all greetings on a bio are /// size-identical ciphertexts. /// /// The recipient key is the bio author's posting key converted via /// `ed25519_pubkey_to_x25519_public` for original greetings, or the raw /// per-greeting `reply_pubkey` for replies. `bio_post_id` is the post the /// comment is placed ON (binds the seal to that post). pub fn seal_greeting_body( recipient_x25519_pub: &[u8; 32], bio_post_id: &PostId, plaintext: &[u8], body_bucket: usize, ) -> Result> { if plaintext.len() > body_bucket { bail!( "greeting body too large: {} > bucket {}", plaintext.len(), body_bucket ); } let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral(); let shared = x25519_dh(&eph_priv, recipient_x25519_pub); let (key, nonce) = derive_greeting_key_nonce(&shared, bio_post_id); // len prefix + body + random pad to the bucket. let mut padded = Vec::with_capacity(4 + body_bucket); padded.extend_from_slice(&(plaintext.len() as u32).to_le_bytes()); padded.extend_from_slice(plaintext); let mut pad = vec![0u8; body_bucket - plaintext.len()]; rand::rng().fill_bytes(&mut pad); padded.extend_from_slice(&pad); let cipher = ChaCha20Poly1305::new_from_slice(&key) .map_err(|e| anyhow::anyhow!("greeting cipher init: {}", e))?; let ciphertext = cipher .encrypt(Nonce::from_slice(&nonce), padded.as_slice()) .map_err(|e| anyhow::anyhow!("greeting seal: {}", e))?; let mut out = Vec::with_capacity(32 + ciphertext.len()); out.extend_from_slice(&eph_pub); out.extend_from_slice(&ciphertext); Ok(out) } /// Try to open a sealed greeting body with the recipient's x25519 /// private scalar. Returns `None` on any shape/AEAD failure (not sealed /// to this key, or tampered). pub fn open_greeting_body( recipient_x25519_priv: &[u8; 32], bio_post_id: &PostId, sealed: &[u8], ) -> Option> { if sealed.len() < 32 + 4 + 16 { return None; } let mut eph_pub = [0u8; 32]; eph_pub.copy_from_slice(&sealed[..32]); let shared = x25519_dh(recipient_x25519_priv, &eph_pub); let (key, nonce) = derive_greeting_key_nonce(&shared, bio_post_id); let cipher = ChaCha20Poly1305::new_from_slice(&key).ok()?; let padded = cipher.decrypt(Nonce::from_slice(&nonce), &sealed[32..]).ok()?; if padded.len() < 4 { return None; } let real_len = u32::from_le_bytes(padded[..4].try_into().ok()?) as usize; if 4 + real_len > padded.len() { return None; } Some(padded[4..4 + real_len].to_vec()) } /// Sign a reaction: ed25519 over BLAKE3(reactor || post_id || emoji || timestamp_ms). pub fn sign_reaction( seed: &[u8; 32], reactor: &NodeId, post_id: &PostId, emoji: &str, timestamp_ms: u64, ) -> Vec { let signing_key = SigningKey::from_bytes(seed); let mut hasher = blake3::Hasher::new_derive_key(REACTION_SIGN_CONTEXT); hasher.update(reactor); hasher.update(post_id); hasher.update(emoji.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 reaction's ed25519 signature. pub fn verify_reaction_signature( reactor: &NodeId, post_id: &PostId, emoji: &str, timestamp_ms: u64, signature: &[u8], ) -> bool { let Ok(verifying_key) = VerifyingKey::from_bytes(reactor) else { return false; }; let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else { return false; }; let mut hasher = blake3::Hasher::new_derive_key(REACTION_SIGN_CONTEXT); hasher.update(reactor); hasher.update(post_id); hasher.update(emoji.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 comment_signature_binds_ref_post_id() { let (seed, nid) = make_keypair(7); let post_id = [1u8; 32]; let ref_post = [2u8; 32]; let content = "preview"; let ts = 1000u64; let exp = 5000u64; // Signature including ref_post_id. let sig_with_ref = sign_comment(&seed, &nid, &post_id, content, ts, Some(&ref_post), exp); // Verifies only when the ref is supplied. assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&ref_post), exp)); // Same signature must NOT verify when the ref is dropped (binding). assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, None, exp)); // Nor when the ref is swapped. let other_ref = [3u8; 32]; assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&other_ref), exp)); // Plain-comment signature still works. let sig_plain = sign_comment(&seed, &nid, &post_id, content, ts, None, exp); assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_plain, None, exp)); } /// A3: digest v2 covers expires_at_ms — mutating the expiry flips /// verification (no silent TTL extension possible). #[test] fn comment_signature_binds_expiry() { let (seed, nid) = make_keypair(8); let post_id = [1u8; 32]; let ts = 1000u64; let exp = 90_000_000u64; let sig = sign_comment(&seed, &nid, &post_id, "hi", ts, None, exp); assert!(verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, exp)); // Extended expiry must fail. assert!(!verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, exp + 1)); // Stripped expiry (0) must fail. assert!(!verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, 0)); } /// A3: self-certifying comment-delete roundtrip + wrong-key reject. #[test] fn comment_delete_sign_verify() { let (seed, nid) = make_keypair(9); let (_mseed, mallory) = make_keypair(10); let post_id = [4u8; 32]; let ts = 123_456u64; let sig = sign_comment_delete(&seed, &nid, &post_id, ts); assert_eq!(sig.len(), 64); assert!(verify_comment_delete(&nid, &post_id, ts, &sig)); // Wrong author key. assert!(!verify_comment_delete(&mallory, &post_id, ts, &sig)); // Wrong tuple. assert!(!verify_comment_delete(&nid, &post_id, ts + 1, &sig)); assert!(!verify_comment_delete(&nid, &[5u8; 32], ts, &sig)); // Garbage signature. assert!(!verify_comment_delete(&nid, &post_id, ts, &[0u8; 64])); assert!(!verify_comment_delete(&nid, &post_id, ts, &[])); } /// A3: derive_open_slot_vx determinism + distinctness across nonces /// and authors. #[test] fn open_slot_vx_deterministic_and_distinct() { let (_s1, a1) = make_keypair(21); let (_s2, a2) = make_keypair(22); let n1 = [0xAA; 32]; let n2 = [0xBB; 32]; let v = derive_open_slot_vx(&a1, &n1); assert_eq!(v, derive_open_slot_vx(&a1, &n1), "deterministic"); assert_ne!(v, derive_open_slot_vx(&a1, &n2), "distinct per nonce"); assert_ne!(v, derive_open_slot_vx(&a2, &n1), "distinct per author"); } /// A3: seal/open_greeting_body roundtrip, exact-bucket ciphertext /// length equality across different plaintext lengths, tamper reject, /// wrong-key reject. #[test] fn greeting_body_roundtrip_and_bucketing() { let (bob_priv, bob_pub) = make_persona_x25519(30); let (carol_priv, _carol_pub) = make_persona_x25519(31); let bio_post_id: PostId = [0x77; 32]; const BUCKET: usize = 1024; let body = br#"{"v":1,"sender_persona":"aa","sender_name":"Al","text":"hi","return_path":"bb","reply_pubkey":"cc"}"#; let sealed = seal_greeting_body(&bob_pub, &bio_post_id, body, BUCKET).unwrap(); assert_eq!(sealed.len(), 32 + 4 + BUCKET + 16, "fixed-size ciphertext"); // Different plaintext length → same ciphertext length. let sealed2 = seal_greeting_body(&bob_pub, &bio_post_id, b"x", BUCKET).unwrap(); assert_eq!(sealed.len(), sealed2.len(), "bucket hides length"); // Recipient opens. let opened = open_greeting_body(&bob_priv, &bio_post_id, &sealed).unwrap(); assert_eq!(opened, body.to_vec()); // Non-recipient cannot. assert!(open_greeting_body(&carol_priv, &bio_post_id, &sealed).is_none()); // Wrong bio post id cannot. assert!(open_greeting_body(&bob_priv, &[0x88; 32], &sealed).is_none()); // Tampered ciphertext fails AEAD. let mut tampered = sealed.clone(); let last = tampered.len() - 1; tampered[last] ^= 0x01; assert!(open_greeting_body(&bob_priv, &bio_post_id, &tampered).is_none()); // Oversized body refused. assert!(seal_greeting_body(&bob_pub, &bio_post_id, &vec![0u8; BUCKET + 1], BUCKET).is_err()); } /// A3: a reply sealed to a fresh `reply_pubkey` opens with the stored /// reply private key and NOT with the recipient's long-term key. #[test] fn greeting_reply_sealed_to_fresh_key_only() { // Long-term persona key of the greeting's original sender. let (longterm_priv, _longterm_pub) = make_persona_x25519(40); // Fresh per-greeting reply keypair minted by that sender. let (reply_priv, reply_pub) = generate_x25519_keypair(); let return_path_post: PostId = [0x99; 32]; let reply_body = b"hello back"; let sealed = seal_greeting_body(&reply_pub, &return_path_post, reply_body, 1024).unwrap(); // Stored fresh reply key opens it. let opened = open_greeting_body(&reply_priv, &return_path_post, &sealed).unwrap(); assert_eq!(opened, reply_body.to_vec()); // Long-term key does NOT. assert!(open_greeting_body(&longterm_priv, &return_path_post, &sealed).is_none()); } #[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, 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)); } /// v0.8 (A2): manifests must never carry device addresses — neither the /// author-signed part nor the CdnManifest wire wrapper. #[test] fn test_manifest_carries_no_addresses() { use crate::types::{AuthorManifest, CdnManifest, ManifestEntry}; let (seed, node_id) = make_keypair(1); let (_hseed, host_id) = make_keypair(2); let mut manifest = AuthorManifest { post_id: [42u8; 32], author: node_id, created_at: 1000, updated_at: 2000, previous_posts: vec![ManifestEntry { post_id: [1u8; 32], timestamp_ms: 900, has_attachments: true, }], following_posts: vec![], signature: vec![], }; manifest.signature = sign_manifest(&seed, &manifest); assert!(verify_manifest_signature(&manifest)); let author_json = serde_json::to_string(&manifest).unwrap(); assert!( !author_json.contains("addresses"), "AuthorManifest JSON must not contain any address field: {author_json}" ); let cdn = CdnManifest { author_manifest: manifest, host: host_id, }; let cdn_json = serde_json::to_string(&cdn).unwrap(); assert!( !cdn_json.contains("addresses"), "CdnManifest JSON must not contain any address field: {cdn_json}" ); assert!(!cdn_json.contains("downstream_count")); assert!(!cdn_json.contains("\"source\"")); } /// A1: crypto pairings must use the MATCHED persona's (id, seed) tuple. /// The historical bug paired the default posting secret with the NETWORK /// NodeId — a pair that can never unwrap anything. #[test] fn test_cek_unwrap_requires_matching_persona_pair() { let (author_seed, author_id) = make_keypair(1); let (persona_seed, persona_id) = make_keypair(2); // recipient persona let (network_seed, network_id) = make_keypair(9); // device network key let (_encrypted, wrapped_keys) = encrypt_post("hi", &author_seed, &author_id, &[persona_id]).unwrap(); // Mismatched pair (persona seed + network id): recipient lookup fails. let r = unwrap_cek_for_recipient(&persona_seed, &network_id, &author_id, &wrapped_keys).unwrap(); assert!(r.is_none(), "network id must not appear in recipients"); // Network pair entirely: also fails. let r = unwrap_cek_for_recipient(&network_seed, &network_id, &author_id, &wrapped_keys).unwrap(); assert!(r.is_none()); // Matched persona pair: succeeds. let r = unwrap_cek_for_recipient(&persona_seed, &persona_id, &author_id, &wrapped_keys).unwrap(); assert!(r.is_some(), "matched persona (id, seed) pair must unwrap the CEK"); } /// A1 (revocation): rewrap_visibility works with the AUTHOR persona's /// (seed, id) pair — and cannot work with the network pair the old code /// passed. #[test] fn test_rewrap_visibility_uses_author_persona_pair() { let (author_seed, author_id) = make_keypair(3); // non-default persona let (_b_seed, bob_id) = make_keypair(4); let (_c_seed, carol_id) = make_keypair(5); let (network_seed, network_id) = make_keypair(9); let (_encrypted, wrapped_keys) = encrypt_post("secret", &author_seed, &author_id, &[bob_id, carol_id]).unwrap(); // Old bug shape: (default/other secret, network id) — must fail. assert!(rewrap_visibility(&network_seed, &network_id, &wrapped_keys, &[author_id, bob_id]).is_err()); // Correct: the author persona's own pair — succeeds and re-wraps // for the reduced recipient set. let new_wrapped = rewrap_visibility(&author_seed, &author_id, &wrapped_keys, &[author_id, bob_id]).unwrap(); assert!(new_wrapped.iter().any(|wk| wk.recipient == bob_id)); assert!(!new_wrapped.iter().any(|wk| wk.recipient == carol_id), "revoked recipient must be gone"); } #[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, 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()); } #[test] fn test_slot_key_derivation_deterministic() { let cek = [42u8; 32]; let key1 = derive_slot_key(&cek); let key2 = derive_slot_key(&cek); assert_eq!(key1, key2); // Different CEK gives different slot key let cek2 = [99u8; 32]; let key3 = derive_slot_key(&cek2); assert_ne!(key1, key3); } #[test] fn test_slot_encrypt_decrypt_roundtrip() { let cek = [42u8; 32]; let slot_key = derive_slot_key(&cek); let plaintext = b"hello slot encryption"; let encrypted = encrypt_slot(plaintext, &slot_key).unwrap(); let decrypted = decrypt_slot(&encrypted, &slot_key).unwrap(); assert_eq!(decrypted, plaintext); } #[test] fn test_slot_wrong_key_fails() { let cek = [42u8; 32]; let slot_key = derive_slot_key(&cek); let wrong_key = derive_slot_key(&[99u8; 32]); let plaintext = b"secret data"; let encrypted = encrypt_slot(plaintext, &slot_key).unwrap(); assert!(decrypt_slot(&encrypted, &wrong_key).is_err()); } #[test] fn test_receipt_slot_roundtrip() { let cek = [42u8; 32]; let slot_key = derive_slot_key(&cek); // Build a receipt: state=seen, timestamp, no emoji let mut plaintext = [0u8; 32]; plaintext[0] = 2; // seen let ts: u64 = 1700000000000; plaintext[1..9].copy_from_slice(&ts.to_le_bytes()); let encrypted = encrypt_slot(&plaintext, &slot_key).unwrap(); let decrypted = decrypt_slot(&encrypted, &slot_key).unwrap(); assert_eq!(decrypted[0], 2); assert_eq!(u64::from_le_bytes(decrypted[1..9].try_into().unwrap()), ts); } #[test] fn test_comment_slot_roundtrip() { let cek = [42u8; 32]; let slot_key = derive_slot_key(&cek); let mut plaintext = [0u8; 256]; let author = [1u8; 32]; plaintext[..32].copy_from_slice(&author); let ts: u64 = 1700000000000; plaintext[32..40].copy_from_slice(&ts.to_le_bytes()); let content = b"Hello from a slot comment!"; plaintext[40..40 + content.len()].copy_from_slice(content); let encrypted = encrypt_slot(&plaintext, &slot_key).unwrap(); let decrypted = decrypt_slot(&encrypted, &slot_key).unwrap(); assert_eq!(&decrypted[..32], &author); assert_eq!(u64::from_le_bytes(decrypted[32..40].try_into().unwrap()), ts); let end = decrypted[40..].iter().position(|&b| b == 0).unwrap_or(216); assert_eq!(&decrypted[40..40 + end], content); } #[test] fn test_random_slot_noise_correct_size() { let noise64 = random_slot_noise(64); assert_eq!(noise64.len(), 64); let noise256 = random_slot_noise(256); assert_eq!(noise256.len(), 256); // Different calls produce different noise (with very high probability) assert_ne!(random_slot_noise(64), random_slot_noise(64)); } // --- FoF Layer 1: vouch-grant seal/open --- fn make_persona_x25519(seed_byte: u8) -> ([u8; 32], [u8; 32]) { // Derive (x25519_priv, x25519_pub) from an ed25519 seed, mirroring // the production path personas use. let mut seed = [0u8; 32]; seed[0] = seed_byte; let priv_x = ed25519_seed_to_x25519_private(&seed); let signing_key = SigningKey::from_bytes(&seed); let pub_x = signing_key.verifying_key().to_montgomery().to_bytes(); (priv_x, pub_x) } #[test] fn vouch_grant_roundtrip() { let (alice_priv, _alice_pub) = make_persona_x25519(11); let (bob_priv, bob_pub) = make_persona_x25519(22); let bio_post_id: PostId = [7u8; 32]; let v_me: [u8; 32] = [42u8; 32]; let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral(); // Seal for Bob let wrapper = seal_vouch_grant(&eph_priv, &bob_pub, &bio_post_id, &v_me).unwrap(); assert_eq!(wrapper.len(), 48, "wrapper must be 48 bytes (32 sealed + 16 tag)"); // Bob opens it let opened = open_vouch_grant(&bob_priv, &eph_pub, &bio_post_id, &wrapper); assert_eq!(opened, Some(v_me)); // Alice (not the recipient) cannot open it let alice_attempt = open_vouch_grant(&alice_priv, &eph_pub, &bio_post_id, &wrapper); assert_eq!(alice_attempt, None, "non-recipient must not decrypt"); } #[test] fn vouch_grant_wrong_bio_post_id_fails() { let (_, bob_pub) = make_persona_x25519(22); let (bob_priv, _) = make_persona_x25519(22); let real_bio_id: PostId = [1u8; 32]; let wrong_bio_id: PostId = [2u8; 32]; let v_me: [u8; 32] = [99u8; 32]; let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral(); let wrapper = seal_vouch_grant(&eph_priv, &bob_pub, &real_bio_id, &v_me).unwrap(); // Wrong bio_post_id derives a different key+nonce → AEAD fails. let attempt = open_vouch_grant(&bob_priv, &eph_pub, &wrong_bio_id, &wrapper); assert_eq!(attempt, None); // Right bio_post_id succeeds. let ok = open_vouch_grant(&bob_priv, &eph_pub, &real_bio_id, &wrapper); assert_eq!(ok, Some(v_me)); } #[test] fn vouch_grant_random_bytes_fail() { let (bob_priv, _) = make_persona_x25519(22); let bio_post_id: PostId = [5u8; 32]; let (_, eph_pub) = generate_vouch_batch_ephemeral(); let mut junk = [0u8; 48]; rand::rng().fill_bytes(&mut junk); let attempt = open_vouch_grant(&bob_priv, &eph_pub, &bio_post_id, &junk); assert_eq!(attempt, None, "random bytes must AEAD-fail (dummy wrapper indistinguishable)"); } // --- FoF Layer 2: wrap-slot seal/open --- #[test] fn wrap_slot_roundtrip() { let v_x: [u8; 32] = [0x42; 32]; let slot_binder_nonce: [u8; 32] = [0xAB; 32]; let cek: [u8; 32] = [0x01; 32]; let priv_x_seed: [u8; 32] = [0x02; 32]; let sealed = seal_wrap_slot(&v_x, &slot_binder_nonce, &cek, &priv_x_seed).unwrap(); assert_eq!(sealed.read_ciphertext.len(), 48); assert_eq!(sealed.sign_ciphertext.len(), 48); // Same V_x opens it. let opened = open_wrap_slot( &v_x, &slot_binder_nonce, &sealed.read_ciphertext, &sealed.sign_ciphertext, ).unwrap(); assert_eq!(opened.cek, cek); assert_eq!(opened.priv_x_seed, priv_x_seed); // Different V_x must not. let wrong_v_x: [u8; 32] = [0x99; 32]; let attempt = open_wrap_slot( &wrong_v_x, &slot_binder_nonce, &sealed.read_ciphertext, &sealed.sign_ciphertext, ); assert_eq!(attempt, None); } #[test] fn wrap_slot_wrong_binder_fails() { let v_x: [u8; 32] = [0x42; 32]; let real_nonce: [u8; 32] = [0xAB; 32]; let wrong_nonce: [u8; 32] = [0xCD; 32]; let cek: [u8; 32] = [0x01; 32]; let priv_x_seed: [u8; 32] = [0x02; 32]; let sealed = seal_wrap_slot(&v_x, &real_nonce, &cek, &priv_x_seed).unwrap(); // Same V_x but wrong slot_binder_nonce → AEAD-fail. let attempt = open_wrap_slot( &v_x, &wrong_nonce, &sealed.read_ciphertext, &sealed.sign_ciphertext, ); assert_eq!(attempt, None); } #[test] fn wrap_slot_prefilter_tag_is_stable_and_keyed() { let v_a: [u8; 32] = [0x11; 32]; let v_b: [u8; 32] = [0x22; 32]; let nonce_x: [u8; 32] = [0xAA; 32]; let nonce_y: [u8; 32] = [0xBB; 32]; let t1 = wrap_slot_prefilter_tag(&v_a, &nonce_x); let t2 = wrap_slot_prefilter_tag(&v_a, &nonce_x); assert_eq!(t1, t2, "deterministic for same inputs"); // Different V_x or different nonce → different tag (overwhelmingly). let t3 = wrap_slot_prefilter_tag(&v_b, &nonce_x); assert_ne!(t1, t3); let t4 = wrap_slot_prefilter_tag(&v_a, &nonce_y); assert_ne!(t1, t4); } #[test] fn cek_comments_is_distinct_per_post() { let cek: [u8; 32] = [0x01; 32]; let nonce_a: [u8; 32] = [0xAA; 32]; let nonce_b: [u8; 32] = [0xBB; 32]; let a = derive_cek_comments(&cek, &nonce_a); let b = derive_cek_comments(&cek, &nonce_b); assert_ne!(a, b); // Stable. assert_eq!(derive_cek_comments(&cek, &nonce_a), a); // Different from the base CEK. assert_ne!(a, cek); } }