itsgoin/crates/core/src/crypto.rs
Scott Reimers 17dbf076cb feat: v0.8 Iteration A — ID-class fixes, manifest privacy, registry + greetings
A1 — ID-class bug family (v0.6.1 persona split left network-NodeId comparisons
where posting IDs live). ~14 sites fixed with multi-persona semantics ("author is
one of MY posting identities", not == default):
- revoke_post_access / revoke_circle_access (revocation was broken for every post)
- group key create/add-member/rotate (add-member distribution now works)
- circle profile set/delete/get, encrypted-attachment CEK unwrap
- receipt/comment slot authorship, replication cycle, blob eviction own-tier
- orphaned GroupKeyRequest/Response (0xA1/0xA2) deleted (no send side existed)

A2 — Manifest privacy: AuthorManifest.author_addresses removed from struct AND
signature digest; CdnManifest tree-era fields dropped. Startup migrations re-sign
own manifests, purge unverifiable foreign copies, strip persona fields from legacy
network-id profile rows. Posting identities are now location-anonymous.

A3 — Discovery & first contact:
- Open-slot comments: derivable slot V_x (blake3 from author + slot_binder_nonce)
  so strangers pass the CDN comment-verification gate; OpenSlotDecl in FoF gating
- Greetings: HPKE-style sealed GreetingBody (return_path + fresh reply_pubkey),
  throwaway outer ID, size-bucketed; messaging-first (Reply/Dismiss, no vouch)
- Registry: frozen canonical registry post + REGISTRY_POST_ID, signed registration
  comments (self-certifying deletes), newest-wins per persona, --publish-registry
- Comment TTL: expires_at_ms inside signed digest v2 (context bump), rand 30-365d
  ordinary / fixed 30d registrations, expiry sweep on the eviction cycle
- Single accept_incoming_comment() gate wired into all three ingest sites, closing
  a pre-existing hole where both pull paths stored comments with no verification
- UI: consent panel (visible/listed/greetings, active choice at first publish),
  registry search in Discover, sealed "Say hi" compose, greetings inbox

Security fixes from review: forged-tombstone injection, delete-by-sender-trust,
remote SetPolicy poisoning, stored XSS (escapeHtml quotes, 7 pre-existing sites),
greeting off-switch now revokes prior bios' slots.

No PoW (rejected: inverted cost). Session-relay gating untouched.
201 core tests pass; CLI + desktop build; 3-node integration green.

DEPLOY GATE: wire-incompatible with v0.7.3 — ALPN bump to itsgoin/4 lands in
Iteration B. Do not build/deploy/anchor-swap before that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
2026-07-30 08:28:26 -04:00

2156 lines
83 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";
/// 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<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)
}
// --- 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<Vec<u8>> {
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<u8>, // 48 bytes
pub sign_ciphertext: Vec<u8>, // 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<SealedWrapSlot> {
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<OpenedWrapSlot> {
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<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()
}
/// Canonical bytes for a ControlOp::DeletePost signature.
fn control_delete_bytes(post_id: &PostId, timestamp_ms: u64) -> Vec<u8> {
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(&timestamp_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<u8> {
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<u8> {
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(&timestamp_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<u8> {
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<u8> {
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(&timestamp_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<u8> {
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<crate::types::ReleaseAnnouncement>,
) -> Vec<u8> {
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(&timestamp_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<crate::types::ReleaseAnnouncement>,
) -> Vec<u8> {
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<crate::types::ReleaseAnnouncement>,
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<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 ‖ 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<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)
}
// --- 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<Vec<u8>> {
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<Vec<u8>> {
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<u8> {
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<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 [|| 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(&timestamp_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<u8> {
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(&timestamp_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<u8> {
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<Vec<u8>> {
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<Vec<u8>> {
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<u8> {
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(&timestamp_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(&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 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);
}
}