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>
973 lines
37 KiB
Rust
973 lines
37 KiB
Rust
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(×tamp_ms.to_le_bytes());
|
||
let digest = hasher.finalize();
|
||
signing_key.sign(digest.as_bytes()).to_bytes().to_vec()
|
||
}
|
||
|
||
/// Verify a comment's ed25519 signature.
|
||
pub fn verify_comment_signature(
|
||
author: &NodeId,
|
||
post_id: &PostId,
|
||
content: &str,
|
||
timestamp_ms: u64,
|
||
signature: &[u8],
|
||
) -> bool {
|
||
let Ok(verifying_key) = VerifyingKey::from_bytes(author) else {
|
||
return false;
|
||
};
|
||
let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else {
|
||
return false;
|
||
};
|
||
let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT);
|
||
hasher.update(author);
|
||
hasher.update(post_id);
|
||
hasher.update(content.as_bytes());
|
||
hasher.update(×tamp_ms.to_le_bytes());
|
||
let digest = hasher.finalize();
|
||
verifying_key.verify(digest.as_bytes(), &sig).is_ok()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn make_keypair(seed_byte: u8) -> ([u8; 32], NodeId) {
|
||
let mut seed = [0u8; 32];
|
||
seed[0] = seed_byte;
|
||
let signing_key = SigningKey::from_bytes(&seed);
|
||
let node_id: NodeId = signing_key.verifying_key().to_bytes();
|
||
(seed, node_id)
|
||
}
|
||
|
||
#[test]
|
||
fn test_encrypt_decrypt_roundtrip() {
|
||
let (alice_seed, alice_id) = make_keypair(1);
|
||
let (bob_seed, bob_id) = make_keypair(2);
|
||
|
||
let plaintext = "Hello, Bob! This is a secret message.";
|
||
let (encrypted, wrapped_keys) =
|
||
encrypt_post(plaintext, &alice_seed, &alice_id, &[bob_id]).unwrap();
|
||
|
||
// Alice (sender) can decrypt
|
||
let decrypted =
|
||
decrypt_post(&encrypted, &alice_seed, &alice_id, &alice_id, &wrapped_keys).unwrap();
|
||
assert_eq!(decrypted.as_deref(), Some(plaintext));
|
||
|
||
// Bob (recipient) can decrypt
|
||
let decrypted =
|
||
decrypt_post(&encrypted, &bob_seed, &bob_id, &alice_id, &wrapped_keys).unwrap();
|
||
assert_eq!(decrypted.as_deref(), Some(plaintext));
|
||
}
|
||
|
||
#[test]
|
||
fn test_non_recipient_cannot_decrypt() {
|
||
let (alice_seed, alice_id) = make_keypair(1);
|
||
let (_bob_seed, bob_id) = make_keypair(2);
|
||
let (carol_seed, carol_id) = make_keypair(3);
|
||
|
||
let plaintext = "Secret for Bob only";
|
||
let (encrypted, wrapped_keys) =
|
||
encrypt_post(plaintext, &alice_seed, &alice_id, &[bob_id]).unwrap();
|
||
|
||
// Carol is not a recipient
|
||
let result =
|
||
decrypt_post(&encrypted, &carol_seed, &carol_id, &alice_id, &wrapped_keys).unwrap();
|
||
assert_eq!(result, None);
|
||
}
|
||
|
||
#[test]
|
||
fn test_author_always_included() {
|
||
let (alice_seed, alice_id) = make_keypair(1);
|
||
let (_bob_seed, bob_id) = make_keypair(2);
|
||
|
||
let (_encrypted, wrapped_keys) =
|
||
encrypt_post("test", &alice_seed, &alice_id, &[bob_id]).unwrap();
|
||
|
||
// Alice should be in recipients even though only Bob was passed
|
||
assert!(wrapped_keys.iter().any(|wk| wk.recipient == alice_id));
|
||
assert!(wrapped_keys.iter().any(|wk| wk.recipient == bob_id));
|
||
}
|
||
|
||
#[test]
|
||
fn test_multiple_recipients() {
|
||
let (alice_seed, alice_id) = make_keypair(1);
|
||
let (bob_seed, bob_id) = make_keypair(2);
|
||
let (carol_seed, carol_id) = make_keypair(3);
|
||
|
||
let plaintext = "Group message!";
|
||
let (encrypted, wrapped_keys) =
|
||
encrypt_post(plaintext, &alice_seed, &alice_id, &[bob_id, carol_id]).unwrap();
|
||
|
||
// All three can decrypt
|
||
for (seed, nid) in [
|
||
(&alice_seed, &alice_id),
|
||
(&bob_seed, &bob_id),
|
||
(&carol_seed, &carol_id),
|
||
] {
|
||
let result =
|
||
decrypt_post(&encrypted, seed, nid, &alice_id, &wrapped_keys).unwrap();
|
||
assert_eq!(result.as_deref(), Some(plaintext));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_x25519_conversion() {
|
||
let (seed, node_id) = make_keypair(42);
|
||
let x_priv = ed25519_seed_to_x25519_private(&seed);
|
||
let x_pub = ed25519_pubkey_to_x25519_public(&node_id).unwrap();
|
||
|
||
// Verify: private * basepoint == public
|
||
let derived_pub = MontgomeryPoint::mul_base_clamped(x_priv);
|
||
assert_eq!(derived_pub.to_bytes(), x_pub);
|
||
}
|
||
|
||
#[test]
|
||
fn test_sign_verify_delete() {
|
||
let (seed, node_id) = make_keypair(1);
|
||
let post_id = [42u8; 32];
|
||
let sig = sign_delete(&seed, &post_id);
|
||
assert_eq!(sig.len(), 64);
|
||
assert!(verify_delete_signature(&node_id, &post_id, &sig));
|
||
}
|
||
|
||
#[test]
|
||
fn test_forged_delete_rejected() {
|
||
let (seed, _alice_id) = make_keypair(1);
|
||
let (_bob_seed, bob_id) = make_keypair(2);
|
||
let post_id = [42u8; 32];
|
||
// Alice signs, but we check against Bob's key
|
||
let sig = sign_delete(&seed, &post_id);
|
||
assert!(!verify_delete_signature(&bob_id, &post_id, &sig));
|
||
// Wrong post_id
|
||
let wrong_id = [99u8; 32];
|
||
assert!(!verify_delete_signature(&_alice_id, &wrong_id, &sig));
|
||
}
|
||
|
||
#[test]
|
||
fn test_rewrap_roundtrip() {
|
||
let (alice_seed, alice_id) = make_keypair(1);
|
||
let (bob_seed, bob_id) = make_keypair(2);
|
||
let (_carol_seed, carol_id) = make_keypair(3);
|
||
|
||
let plaintext = "secret message";
|
||
let (encrypted, original_keys) =
|
||
encrypt_post(plaintext, &alice_seed, &alice_id, &[bob_id, carol_id]).unwrap();
|
||
|
||
// Re-wrap excluding carol (only alice + bob remain)
|
||
let new_keys =
|
||
rewrap_visibility(&alice_seed, &alice_id, &original_keys, &[alice_id, bob_id]).unwrap();
|
||
|
||
// Alice can still decrypt
|
||
let dec = decrypt_post(&encrypted, &alice_seed, &alice_id, &alice_id, &new_keys).unwrap();
|
||
assert_eq!(dec.as_deref(), Some(plaintext));
|
||
|
||
// Bob can still decrypt
|
||
let dec = decrypt_post(&encrypted, &bob_seed, &bob_id, &alice_id, &new_keys).unwrap();
|
||
assert_eq!(dec.as_deref(), Some(plaintext));
|
||
}
|
||
|
||
#[test]
|
||
fn test_revoked_cannot_decrypt_after_rewrap() {
|
||
let (alice_seed, alice_id) = make_keypair(1);
|
||
let (_bob_seed, bob_id) = make_keypair(2);
|
||
let (carol_seed, carol_id) = make_keypair(3);
|
||
|
||
let plaintext = "secret message";
|
||
let (encrypted, original_keys) =
|
||
encrypt_post(plaintext, &alice_seed, &alice_id, &[bob_id, carol_id]).unwrap();
|
||
|
||
// Re-wrap excluding carol
|
||
let new_keys =
|
||
rewrap_visibility(&alice_seed, &alice_id, &original_keys, &[alice_id, bob_id]).unwrap();
|
||
|
||
// Carol cannot decrypt with new keys
|
||
let dec = decrypt_post(&encrypted, &carol_seed, &carol_id, &alice_id, &new_keys).unwrap();
|
||
assert_eq!(dec, None);
|
||
}
|
||
|
||
#[test]
|
||
fn test_re_encrypt_roundtrip() {
|
||
let (alice_seed, alice_id) = make_keypair(1);
|
||
let (bob_seed, bob_id) = make_keypair(2);
|
||
let (carol_seed, carol_id) = make_keypair(3);
|
||
|
||
let plaintext = "re-encrypt test";
|
||
let (encrypted, original_keys) =
|
||
encrypt_post(plaintext, &alice_seed, &alice_id, &[bob_id, carol_id]).unwrap();
|
||
|
||
// Re-encrypt excluding carol
|
||
let (new_encrypted, new_keys) =
|
||
re_encrypt_post(&encrypted, &alice_seed, &alice_id, &original_keys, &[bob_id]).unwrap();
|
||
|
||
// Bob can decrypt new ciphertext
|
||
let dec = decrypt_post(&new_encrypted, &bob_seed, &bob_id, &alice_id, &new_keys).unwrap();
|
||
assert_eq!(dec.as_deref(), Some(plaintext));
|
||
|
||
// Carol cannot decrypt new ciphertext (not a recipient + different CEK)
|
||
let dec = decrypt_post(&new_encrypted, &carol_seed, &carol_id, &alice_id, &new_keys).unwrap();
|
||
assert_eq!(dec, None);
|
||
|
||
// Carol cannot decrypt new ciphertext even with old keys (different CEK — will error or return wrong plaintext)
|
||
let dec = decrypt_post(&new_encrypted, &carol_seed, &carol_id, &alice_id, &original_keys);
|
||
// Either returns None (not a recipient in new keys) or an error (wrong CEK for new ciphertext)
|
||
match dec {
|
||
Ok(None) => {} // Not a recipient
|
||
Err(_) => {} // AEAD decryption failure — expected with wrong CEK
|
||
Ok(Some(_)) => panic!("carol should not be able to decrypt re-encrypted post"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_sign_verify_manifest() {
|
||
use crate::types::{AuthorManifest, ManifestEntry};
|
||
let (seed, node_id) = make_keypair(1);
|
||
|
||
let mut manifest = AuthorManifest {
|
||
post_id: [42u8; 32],
|
||
author: node_id,
|
||
author_addresses: vec!["10.0.0.1:4433".to_string()],
|
||
created_at: 1000,
|
||
updated_at: 2000,
|
||
previous_posts: vec![ManifestEntry {
|
||
post_id: [1u8; 32],
|
||
timestamp_ms: 900,
|
||
has_attachments: false,
|
||
}],
|
||
following_posts: vec![],
|
||
signature: vec![],
|
||
};
|
||
|
||
manifest.signature = sign_manifest(&seed, &manifest);
|
||
assert_eq!(manifest.signature.len(), 64);
|
||
assert!(verify_manifest_signature(&manifest));
|
||
}
|
||
|
||
#[test]
|
||
fn test_forged_manifest_rejected() {
|
||
use crate::types::AuthorManifest;
|
||
let (seed, node_id) = make_keypair(1);
|
||
let (_bob_seed, bob_id) = make_keypair(2);
|
||
|
||
let mut manifest = AuthorManifest {
|
||
post_id: [42u8; 32],
|
||
author: node_id,
|
||
author_addresses: vec![],
|
||
created_at: 1000,
|
||
updated_at: 2000,
|
||
previous_posts: vec![],
|
||
following_posts: vec![],
|
||
signature: vec![],
|
||
};
|
||
|
||
manifest.signature = sign_manifest(&seed, &manifest);
|
||
|
||
// Tamper with author → verification fails
|
||
manifest.author = bob_id;
|
||
assert!(!verify_manifest_signature(&manifest));
|
||
|
||
// Restore author, tamper with updated_at → fails
|
||
manifest.author = node_id;
|
||
manifest.updated_at = 9999;
|
||
assert!(!verify_manifest_signature(&manifest));
|
||
}
|
||
|
||
#[test]
|
||
fn test_group_key_gen_and_id() {
|
||
let (seed1, pubkey1) = generate_group_keypair();
|
||
let (seed2, pubkey2) = generate_group_keypair();
|
||
assert_ne!(seed1, seed2);
|
||
assert_ne!(pubkey1, pubkey2);
|
||
let id1 = compute_group_id(&pubkey1);
|
||
let id2 = compute_group_id(&pubkey2);
|
||
assert_ne!(id1, id2);
|
||
// Deterministic
|
||
assert_eq!(compute_group_id(&pubkey1), id1);
|
||
}
|
||
|
||
#[test]
|
||
fn test_group_key_wrap_unwrap_roundtrip() {
|
||
let (admin_seed, admin_id) = make_keypair(1);
|
||
let (bob_seed, bob_id) = make_keypair(2);
|
||
|
||
let (group_seed, _group_pubkey) = generate_group_keypair();
|
||
|
||
// Admin wraps for Bob
|
||
let wrapped = wrap_group_key_for_member(&admin_seed, &bob_id, &group_seed).unwrap();
|
||
assert_eq!(wrapped.len(), 60);
|
||
|
||
// Bob unwraps using admin's public key
|
||
let unwrapped = unwrap_group_key(&bob_seed, &admin_id, &wrapped).unwrap();
|
||
assert_eq!(unwrapped, group_seed);
|
||
}
|
||
|
||
#[test]
|
||
fn test_group_key_wrap_unwrap_self() {
|
||
let (admin_seed, admin_id) = make_keypair(1);
|
||
let (group_seed, _) = generate_group_keypair();
|
||
|
||
let wrapped = wrap_group_key_for_member(&admin_seed, &admin_id, &group_seed).unwrap();
|
||
let unwrapped = unwrap_group_key(&admin_seed, &admin_id, &wrapped).unwrap();
|
||
assert_eq!(unwrapped, group_seed);
|
||
}
|
||
|
||
#[test]
|
||
fn test_group_encrypt_decrypt_roundtrip() {
|
||
let (group_seed, group_pubkey) = generate_group_keypair();
|
||
let plaintext = "Hello group members!";
|
||
|
||
let (encrypted, wrapped_cek) = encrypt_post_for_group(plaintext, &group_seed, &group_pubkey).unwrap();
|
||
assert_eq!(wrapped_cek.len(), 60);
|
||
|
||
let decrypted = decrypt_group_post(&encrypted, &group_seed, &group_pubkey, &wrapped_cek).unwrap();
|
||
assert_eq!(decrypted, plaintext);
|
||
}
|
||
|
||
#[test]
|
||
fn test_group_decrypt_wrong_seed_fails() {
|
||
let (group_seed, group_pubkey) = generate_group_keypair();
|
||
let (wrong_seed, _) = generate_group_keypair();
|
||
let plaintext = "Secret message";
|
||
|
||
let (encrypted, wrapped_cek) = encrypt_post_for_group(plaintext, &group_seed, &group_pubkey).unwrap();
|
||
|
||
let result = decrypt_group_post(&encrypted, &wrong_seed, &group_pubkey, &wrapped_cek);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_rotate_group_key() {
|
||
let (admin_seed, admin_id) = make_keypair(1);
|
||
let (_bob_seed, bob_id) = make_keypair(2);
|
||
let (_carol_seed, carol_id) = make_keypair(3);
|
||
|
||
let (new_seed, new_pubkey, new_epoch, member_keys) =
|
||
rotate_group_key(&admin_seed, 1, &[admin_id, bob_id, carol_id]).unwrap();
|
||
|
||
assert_eq!(new_epoch, 2);
|
||
assert_eq!(member_keys.len(), 3);
|
||
for mk in &member_keys {
|
||
assert_eq!(mk.epoch, 2);
|
||
assert_eq!(mk.wrapped_group_key.len(), 60);
|
||
}
|
||
|
||
// Verify the new seed can encrypt/decrypt
|
||
let plaintext = "New epoch message";
|
||
let (encrypted, wrapped_cek) = encrypt_post_for_group(plaintext, &new_seed, &new_pubkey).unwrap();
|
||
let decrypted = decrypt_group_post(&encrypted, &new_seed, &new_pubkey, &wrapped_cek).unwrap();
|
||
assert_eq!(decrypted, plaintext);
|
||
}
|
||
|
||
#[test]
|
||
fn test_rotate_then_decrypt_old_epoch() {
|
||
let (group_seed_v1, group_pubkey_v1) = generate_group_keypair();
|
||
let plaintext_v1 = "Old epoch message";
|
||
let (encrypted_v1, wrapped_cek_v1) = encrypt_post_for_group(plaintext_v1, &group_seed_v1, &group_pubkey_v1).unwrap();
|
||
|
||
// Rotate — new key pair
|
||
let (group_seed_v2, group_pubkey_v2) = generate_group_keypair();
|
||
let plaintext_v2 = "New epoch message";
|
||
let (encrypted_v2, wrapped_cek_v2) = encrypt_post_for_group(plaintext_v2, &group_seed_v2, &group_pubkey_v2).unwrap();
|
||
|
||
// Old epoch still decryptable with old seed
|
||
let dec_v1 = decrypt_group_post(&encrypted_v1, &group_seed_v1, &group_pubkey_v1, &wrapped_cek_v1).unwrap();
|
||
assert_eq!(dec_v1, plaintext_v1);
|
||
|
||
// New epoch decryptable with new seed
|
||
let dec_v2 = decrypt_group_post(&encrypted_v2, &group_seed_v2, &group_pubkey_v2, &wrapped_cek_v2).unwrap();
|
||
assert_eq!(dec_v2, plaintext_v2);
|
||
|
||
// Old seed cannot decrypt new epoch
|
||
let result = decrypt_group_post(&encrypted_v2, &group_seed_v1, &group_pubkey_v1, &wrapped_cek_v2);
|
||
assert!(result.is_err());
|
||
}
|
||
}
|