itsgoin/crates/core/src/crypto.rs
Scott Reimers a41b11c0b8 v0.3.5: Private blob encryption, blob prefetch, intent-based filtering, crypto refactoring
Private blob encryption:
- Encrypted posts (Friends/Circle/Direct) now encrypt attachment blobs with same CEK
- Public blobs unchanged, CID computed on ciphertext for private
- decrypt_blob_for_post/get_blob_for_post for transparent decryption on retrieval

Blob prefetch:
- Pull cycle and sync_with eagerly fetch missing blobs after post sync
- prefetch_blobs_from_peer scans for missing attachments, fetches via fallback chain
- Runs outside conn_mgr lock at Node level

Crypto refactoring:
- Extracted: encrypt/decrypt_bytes_with_cek, wrap/unwrap_cek_for_recipients
- unwrap_cek_for_recipient, unwrap_group_cek, random_cek
- encrypt_post_with_cek, encrypt_post_for_group_with_cek variants
- All existing functions refactored to delegate, 19 crypto tests pass

Intent-based filtering:
- intent_kind field on PostDto ("public"/"friends"/"circle"/"direct"/"unknown")
- Feed/MyPosts filter on intentKind !== 'direct' instead of visibility
- Messages filter with backward-compatible fallback for pre-intent posts
- get_post_intent storage method

IPC updates:
- resolve_blob_data helper using get_blob_for_post with network fallback
- sanitize_download_filename prevents path traversal
- get_blob_path accepts optional post_id_hex

Website:
- Mobile hamburger nav on all pages
- Mesh/Non-mesh N1 labels in network diagnostics

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:44:07 -04:00

973 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Vec<u8>> {
let cipher = ChaCha20Poly1305::new_from_slice(cek)
.map_err(|e| anyhow::anyhow!("cipher init: {}", e))?;
let mut nonce_bytes = [0u8; 12];
rand::rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, bytes)
.map_err(|e| anyhow::anyhow!("encrypt: {}", e))?;
let mut payload = Vec::with_capacity(12 + ciphertext.len());
payload.extend_from_slice(&nonce_bytes);
payload.extend_from_slice(&ciphertext);
Ok(payload)
}
/// Decrypt bytes that were encrypted with `encrypt_bytes_with_cek`.
/// Expects `nonce(12) || ciphertext || tag(16)`.
pub fn decrypt_bytes_with_cek(payload: &[u8], cek: &[u8; 32]) -> Result<Vec<u8>> {
if payload.len() < 12 + 16 {
bail!("encrypted payload too short");
}
let nonce = Nonce::from_slice(&payload[..12]);
let cipher = ChaCha20Poly1305::new_from_slice(cek)
.map_err(|e| anyhow::anyhow!("cipher init: {}", e))?;
let plaintext = cipher
.decrypt(nonce, &payload[12..])
.map_err(|e| anyhow::anyhow!("decrypt: {}", e))?;
Ok(plaintext)
}
/// Wrap a CEK for a set of recipients using X25519 DH.
/// The author (our_node_id) is always included.
fn wrap_cek_for_recipients(
cek: &[u8; 32],
our_seed: &[u8; 32],
our_node_id: &NodeId,
recipients: &[NodeId],
) -> Result<Vec<WrappedKey>> {
let our_x25519_private = ed25519_seed_to_x25519_private(our_seed);
// Build recipient set (always include ourselves)
let mut all_recipients: Vec<NodeId> = 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<Option<[u8; 32]>> {
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<WrappedKey>)`.
pub fn encrypt_post_with_cek(
plaintext: &str,
cek: &[u8; 32],
our_seed: &[u8; 32],
our_node_id: &NodeId,
recipients: &[NodeId],
) -> Result<(String, Vec<WrappedKey>)> {
let payload = encrypt_bytes_with_cek(plaintext.as_bytes(), cek)?;
let encoded = base64::engine::general_purpose::STANDARD.encode(&payload);
let wrapped_keys = wrap_cek_for_recipients(cek, our_seed, our_node_id, recipients)?;
Ok((encoded, wrapped_keys))
}
/// Encrypt a post for a group with a provided CEK.
/// Returns `(base64_ciphertext, wrapped_cek_bytes)`.
pub fn encrypt_post_for_group_with_cek(
plaintext: &str,
cek: &[u8; 32],
group_seed: &[u8; 32],
group_public_key: &[u8; 32],
) -> Result<(String, Vec<u8>)> {
let payload = encrypt_bytes_with_cek(plaintext.as_bytes(), cek)?;
let encoded = base64::engine::general_purpose::STANDARD.encode(&payload);
// Wrap CEK using group DH
let group_x25519_private = ed25519_seed_to_x25519_private(group_seed);
let group_x25519_public = ed25519_pubkey_to_x25519_public(group_public_key)?;
let shared_secret = x25519_dh(&group_x25519_private, &group_x25519_public);
let wrapping_key = derive_group_cek_wrapping_key(&shared_secret);
let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key)
.map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?;
let mut wrap_nonce_bytes = [0u8; 12];
rand::rng().fill_bytes(&mut wrap_nonce_bytes);
let wrap_nonce = Nonce::from_slice(&wrap_nonce_bytes);
let wrapped = wrap_cipher
.encrypt(wrap_nonce, cek.as_slice())
.map_err(|e| anyhow::anyhow!("wrap CEK: {}", e))?;
let mut wrapped_cek = Vec::with_capacity(60);
wrapped_cek.extend_from_slice(&wrap_nonce_bytes);
wrapped_cek.extend_from_slice(&wrapped);
Ok((encoded, wrapped_cek))
}
/// Encrypt a post's plaintext content for the given recipients.
///
/// Returns `(base64_ciphertext, Vec<WrappedKey>)` where:
/// - base64_ciphertext is `base64(nonce(12) || ciphertext || tag(16))` for the content
/// - Each WrappedKey contains the CEK encrypted for one recipient
///
/// The author (our_seed's corresponding NodeId) is always included as a recipient.
pub fn encrypt_post(
plaintext: &str,
our_seed: &[u8; 32],
our_node_id: &NodeId,
recipients: &[NodeId],
) -> Result<(String, Vec<WrappedKey>)> {
let cek = random_cek();
encrypt_post_with_cek(plaintext, &cek, our_seed, our_node_id, recipients)
}
/// Decrypt a post's content if we are among the recipients.
///
/// Returns `Ok(Some(plaintext))` if we can decrypt, `Ok(None)` if we're not a recipient.
pub fn decrypt_post(
encrypted_content_b64: &str,
our_seed: &[u8; 32],
our_node_id: &NodeId,
sender_pubkey: &NodeId,
wrapped_keys: &[WrappedKey],
) -> Result<Option<String>> {
let cek = match unwrap_cek_for_recipient(our_seed, our_node_id, sender_pubkey, wrapped_keys)? {
Some(cek) => cek,
None => return Ok(None),
};
// Decode base64 content
let payload = base64::engine::general_purpose::STANDARD
.decode(encrypted_content_b64)
.map_err(|e| anyhow::anyhow!("base64 decode: {}", e))?;
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<u8> {
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<Vec<WrappedKey>> {
// 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<Vec<u8>> {
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<u8>)> {
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<String> {
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<GroupMemberKey>)> {
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<u8> {
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<WrappedKey>)> {
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<String> {
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<String> {
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<u8> {
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(&timestamp_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(&timestamp_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());
}
}