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
2156 lines
83 KiB
Rust
2156 lines
83 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";
|
||
|
||
/// 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(×tamp_ms.to_le_bytes());
|
||
buf
|
||
}
|
||
|
||
/// Sign a control-post DeletePost operation.
|
||
pub fn sign_control_delete(seed: &[u8; 32], post_id: &PostId, timestamp_ms: u64) -> Vec<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(×tamp_ms.to_le_bytes());
|
||
buf
|
||
}
|
||
|
||
pub fn sign_control_visibility(
|
||
seed: &[u8; 32],
|
||
post_id: &PostId,
|
||
new_visibility: &crate::types::PostVisibility,
|
||
timestamp_ms: u64,
|
||
) -> Vec<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(×tamp_ms.to_le_bytes());
|
||
buf
|
||
}
|
||
|
||
pub fn sign_profile(
|
||
seed: &[u8; 32],
|
||
display_name: &str,
|
||
bio: &str,
|
||
avatar_cid: &Option<[u8; 32]>,
|
||
timestamp_ms: u64,
|
||
) -> Vec<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(×tamp_ms.to_le_bytes());
|
||
match release {
|
||
Some(r) => {
|
||
buf.push(1u8);
|
||
let c = r.channel.as_bytes();
|
||
let v = r.version.as_bytes();
|
||
let u = r.download_url.as_bytes();
|
||
buf.extend_from_slice(&(c.len() as u64).to_le_bytes());
|
||
buf.extend_from_slice(c);
|
||
buf.extend_from_slice(&(v.len() as u64).to_le_bytes());
|
||
buf.extend_from_slice(v);
|
||
buf.extend_from_slice(&(u.len() as u64).to_le_bytes());
|
||
buf.extend_from_slice(u);
|
||
}
|
||
None => buf.push(0u8),
|
||
}
|
||
buf
|
||
}
|
||
|
||
pub fn sign_announcement(
|
||
seed: &[u8; 32],
|
||
category: &str,
|
||
title: &str,
|
||
body: &str,
|
||
timestamp_ms: u64,
|
||
release: &Option<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(×tamp_ms.to_le_bytes());
|
||
// Domain-separated appends (same pattern as the v0.6.2 `ref:` field).
|
||
if let Some(rid) = ref_post_id {
|
||
hasher.update(b"ref:");
|
||
hasher.update(rid);
|
||
}
|
||
hasher.update(b"expires:");
|
||
hasher.update(&expires_at_ms.to_le_bytes());
|
||
hasher.finalize()
|
||
}
|
||
|
||
pub fn sign_comment(
|
||
seed: &[u8; 32],
|
||
author: &NodeId,
|
||
post_id: &PostId,
|
||
content: &str,
|
||
timestamp_ms: u64,
|
||
ref_post_id: Option<&PostId>,
|
||
expires_at_ms: u64,
|
||
) -> Vec<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(×tamp_ms.to_le_bytes());
|
||
hasher.finalize()
|
||
}
|
||
|
||
/// Sign a comment delete: ed25519 by the comment author's posting key
|
||
/// over BLAKE3(context="itsgoin/comment-delete/v1", author || post_id ||
|
||
/// timestamp_ms). Verifiable from the delete op alone — works on holders
|
||
/// that never met the persona (registry unregister path).
|
||
pub fn sign_comment_delete(
|
||
seed: &[u8; 32],
|
||
author: &NodeId,
|
||
post_id: &PostId,
|
||
timestamp_ms: u64,
|
||
) -> Vec<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(×tamp_ms.to_le_bytes());
|
||
let digest = hasher.finalize();
|
||
signing_key.sign(digest.as_bytes()).to_bytes().to_vec()
|
||
}
|
||
|
||
/// Verify a reaction's ed25519 signature.
|
||
pub fn verify_reaction_signature(
|
||
reactor: &NodeId,
|
||
post_id: &PostId,
|
||
emoji: &str,
|
||
timestamp_ms: u64,
|
||
signature: &[u8],
|
||
) -> bool {
|
||
let Ok(verifying_key) = VerifyingKey::from_bytes(reactor) else {
|
||
return false;
|
||
};
|
||
let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else {
|
||
return false;
|
||
};
|
||
let mut hasher = blake3::Hasher::new_derive_key(REACTION_SIGN_CONTEXT);
|
||
hasher.update(reactor);
|
||
hasher.update(post_id);
|
||
hasher.update(emoji.as_bytes());
|
||
hasher.update(×tamp_ms.to_le_bytes());
|
||
let digest = hasher.finalize();
|
||
verifying_key.verify(digest.as_bytes(), &sig).is_ok()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn make_keypair(seed_byte: u8) -> ([u8; 32], NodeId) {
|
||
let mut seed = [0u8; 32];
|
||
seed[0] = seed_byte;
|
||
let signing_key = SigningKey::from_bytes(&seed);
|
||
let node_id: NodeId = signing_key.verifying_key().to_bytes();
|
||
(seed, node_id)
|
||
}
|
||
|
||
#[test]
|
||
fn test_encrypt_decrypt_roundtrip() {
|
||
let (alice_seed, alice_id) = make_keypair(1);
|
||
let (bob_seed, bob_id) = make_keypair(2);
|
||
|
||
let plaintext = "Hello, Bob! This is a secret message.";
|
||
let (encrypted, wrapped_keys) =
|
||
encrypt_post(plaintext, &alice_seed, &alice_id, &[bob_id]).unwrap();
|
||
|
||
// Alice (sender) can decrypt
|
||
let decrypted =
|
||
decrypt_post(&encrypted, &alice_seed, &alice_id, &alice_id, &wrapped_keys).unwrap();
|
||
assert_eq!(decrypted.as_deref(), Some(plaintext));
|
||
|
||
// Bob (recipient) can decrypt
|
||
let decrypted =
|
||
decrypt_post(&encrypted, &bob_seed, &bob_id, &alice_id, &wrapped_keys).unwrap();
|
||
assert_eq!(decrypted.as_deref(), Some(plaintext));
|
||
}
|
||
|
||
#[test]
|
||
fn test_non_recipient_cannot_decrypt() {
|
||
let (alice_seed, alice_id) = make_keypair(1);
|
||
let (_bob_seed, bob_id) = make_keypair(2);
|
||
let (carol_seed, carol_id) = make_keypair(3);
|
||
|
||
let plaintext = "Secret for Bob only";
|
||
let (encrypted, wrapped_keys) =
|
||
encrypt_post(plaintext, &alice_seed, &alice_id, &[bob_id]).unwrap();
|
||
|
||
// Carol is not a recipient
|
||
let result =
|
||
decrypt_post(&encrypted, &carol_seed, &carol_id, &alice_id, &wrapped_keys).unwrap();
|
||
assert_eq!(result, None);
|
||
}
|
||
|
||
#[test]
|
||
fn test_author_always_included() {
|
||
let (alice_seed, alice_id) = make_keypair(1);
|
||
let (_bob_seed, bob_id) = make_keypair(2);
|
||
|
||
let (_encrypted, wrapped_keys) =
|
||
encrypt_post("test", &alice_seed, &alice_id, &[bob_id]).unwrap();
|
||
|
||
// Alice should be in recipients even though only Bob was passed
|
||
assert!(wrapped_keys.iter().any(|wk| wk.recipient == alice_id));
|
||
assert!(wrapped_keys.iter().any(|wk| wk.recipient == bob_id));
|
||
}
|
||
|
||
#[test]
|
||
fn test_multiple_recipients() {
|
||
let (alice_seed, alice_id) = make_keypair(1);
|
||
let (bob_seed, bob_id) = make_keypair(2);
|
||
let (carol_seed, carol_id) = make_keypair(3);
|
||
|
||
let plaintext = "Group message!";
|
||
let (encrypted, wrapped_keys) =
|
||
encrypt_post(plaintext, &alice_seed, &alice_id, &[bob_id, carol_id]).unwrap();
|
||
|
||
// All three can decrypt
|
||
for (seed, nid) in [
|
||
(&alice_seed, &alice_id),
|
||
(&bob_seed, &bob_id),
|
||
(&carol_seed, &carol_id),
|
||
] {
|
||
let result =
|
||
decrypt_post(&encrypted, seed, nid, &alice_id, &wrapped_keys).unwrap();
|
||
assert_eq!(result.as_deref(), Some(plaintext));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_x25519_conversion() {
|
||
let (seed, node_id) = make_keypair(42);
|
||
let x_priv = ed25519_seed_to_x25519_private(&seed);
|
||
let x_pub = ed25519_pubkey_to_x25519_public(&node_id).unwrap();
|
||
|
||
// Verify: private * basepoint == public
|
||
let derived_pub = MontgomeryPoint::mul_base_clamped(x_priv);
|
||
assert_eq!(derived_pub.to_bytes(), x_pub);
|
||
}
|
||
|
||
#[test]
|
||
fn test_sign_verify_delete() {
|
||
let (seed, node_id) = make_keypair(1);
|
||
let post_id = [42u8; 32];
|
||
let sig = sign_delete(&seed, &post_id);
|
||
assert_eq!(sig.len(), 64);
|
||
assert!(verify_delete_signature(&node_id, &post_id, &sig));
|
||
}
|
||
|
||
#[test]
|
||
fn test_forged_delete_rejected() {
|
||
let (seed, _alice_id) = make_keypair(1);
|
||
let (_bob_seed, bob_id) = make_keypair(2);
|
||
let post_id = [42u8; 32];
|
||
// Alice signs, but we check against Bob's key
|
||
let sig = sign_delete(&seed, &post_id);
|
||
assert!(!verify_delete_signature(&bob_id, &post_id, &sig));
|
||
// Wrong post_id
|
||
let wrong_id = [99u8; 32];
|
||
assert!(!verify_delete_signature(&_alice_id, &wrong_id, &sig));
|
||
}
|
||
|
||
#[test]
|
||
fn test_rewrap_roundtrip() {
|
||
let (alice_seed, alice_id) = make_keypair(1);
|
||
let (bob_seed, bob_id) = make_keypair(2);
|
||
let (_carol_seed, carol_id) = make_keypair(3);
|
||
|
||
let plaintext = "secret message";
|
||
let (encrypted, original_keys) =
|
||
encrypt_post(plaintext, &alice_seed, &alice_id, &[bob_id, carol_id]).unwrap();
|
||
|
||
// Re-wrap excluding carol (only alice + bob remain)
|
||
let new_keys =
|
||
rewrap_visibility(&alice_seed, &alice_id, &original_keys, &[alice_id, bob_id]).unwrap();
|
||
|
||
// Alice can still decrypt
|
||
let dec = decrypt_post(&encrypted, &alice_seed, &alice_id, &alice_id, &new_keys).unwrap();
|
||
assert_eq!(dec.as_deref(), Some(plaintext));
|
||
|
||
// Bob can still decrypt
|
||
let dec = decrypt_post(&encrypted, &bob_seed, &bob_id, &alice_id, &new_keys).unwrap();
|
||
assert_eq!(dec.as_deref(), Some(plaintext));
|
||
}
|
||
|
||
#[test]
|
||
fn test_revoked_cannot_decrypt_after_rewrap() {
|
||
let (alice_seed, alice_id) = make_keypair(1);
|
||
let (_bob_seed, bob_id) = make_keypair(2);
|
||
let (carol_seed, carol_id) = make_keypair(3);
|
||
|
||
let plaintext = "secret message";
|
||
let (encrypted, original_keys) =
|
||
encrypt_post(plaintext, &alice_seed, &alice_id, &[bob_id, carol_id]).unwrap();
|
||
|
||
// Re-wrap excluding carol
|
||
let new_keys =
|
||
rewrap_visibility(&alice_seed, &alice_id, &original_keys, &[alice_id, bob_id]).unwrap();
|
||
|
||
// Carol cannot decrypt with new keys
|
||
let dec = decrypt_post(&encrypted, &carol_seed, &carol_id, &alice_id, &new_keys).unwrap();
|
||
assert_eq!(dec, None);
|
||
}
|
||
|
||
#[test]
|
||
fn test_re_encrypt_roundtrip() {
|
||
let (alice_seed, alice_id) = make_keypair(1);
|
||
let (bob_seed, bob_id) = make_keypair(2);
|
||
let (carol_seed, carol_id) = make_keypair(3);
|
||
|
||
let plaintext = "re-encrypt test";
|
||
let (encrypted, original_keys) =
|
||
encrypt_post(plaintext, &alice_seed, &alice_id, &[bob_id, carol_id]).unwrap();
|
||
|
||
// Re-encrypt excluding carol
|
||
let (new_encrypted, new_keys) =
|
||
re_encrypt_post(&encrypted, &alice_seed, &alice_id, &original_keys, &[bob_id]).unwrap();
|
||
|
||
// Bob can decrypt new ciphertext
|
||
let dec = decrypt_post(&new_encrypted, &bob_seed, &bob_id, &alice_id, &new_keys).unwrap();
|
||
assert_eq!(dec.as_deref(), Some(plaintext));
|
||
|
||
// Carol cannot decrypt new ciphertext (not a recipient + different CEK)
|
||
let dec = decrypt_post(&new_encrypted, &carol_seed, &carol_id, &alice_id, &new_keys).unwrap();
|
||
assert_eq!(dec, None);
|
||
|
||
// Carol cannot decrypt new ciphertext even with old keys (different CEK — will error or return wrong plaintext)
|
||
let dec = decrypt_post(&new_encrypted, &carol_seed, &carol_id, &alice_id, &original_keys);
|
||
// Either returns None (not a recipient in new keys) or an error (wrong CEK for new ciphertext)
|
||
match dec {
|
||
Ok(None) => {} // Not a recipient
|
||
Err(_) => {} // AEAD decryption failure — expected with wrong CEK
|
||
Ok(Some(_)) => panic!("carol should not be able to decrypt re-encrypted post"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn comment_signature_binds_ref_post_id() {
|
||
let (seed, nid) = make_keypair(7);
|
||
let post_id = [1u8; 32];
|
||
let ref_post = [2u8; 32];
|
||
let content = "preview";
|
||
let ts = 1000u64;
|
||
let exp = 5000u64;
|
||
|
||
// Signature including ref_post_id.
|
||
let sig_with_ref = sign_comment(&seed, &nid, &post_id, content, ts, Some(&ref_post), exp);
|
||
// Verifies only when the ref is supplied.
|
||
assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&ref_post), exp));
|
||
// Same signature must NOT verify when the ref is dropped (binding).
|
||
assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, None, exp));
|
||
// Nor when the ref is swapped.
|
||
let other_ref = [3u8; 32];
|
||
assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&other_ref), exp));
|
||
|
||
// Plain-comment signature still works.
|
||
let sig_plain = sign_comment(&seed, &nid, &post_id, content, ts, None, exp);
|
||
assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_plain, None, exp));
|
||
}
|
||
|
||
/// A3: digest v2 covers expires_at_ms — mutating the expiry flips
|
||
/// verification (no silent TTL extension possible).
|
||
#[test]
|
||
fn comment_signature_binds_expiry() {
|
||
let (seed, nid) = make_keypair(8);
|
||
let post_id = [1u8; 32];
|
||
let ts = 1000u64;
|
||
let exp = 90_000_000u64;
|
||
|
||
let sig = sign_comment(&seed, &nid, &post_id, "hi", ts, None, exp);
|
||
assert!(verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, exp));
|
||
// Extended expiry must fail.
|
||
assert!(!verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, exp + 1));
|
||
// Stripped expiry (0) must fail.
|
||
assert!(!verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, 0));
|
||
}
|
||
|
||
/// A3: self-certifying comment-delete roundtrip + wrong-key reject.
|
||
#[test]
|
||
fn comment_delete_sign_verify() {
|
||
let (seed, nid) = make_keypair(9);
|
||
let (_mseed, mallory) = make_keypair(10);
|
||
let post_id = [4u8; 32];
|
||
let ts = 123_456u64;
|
||
|
||
let sig = sign_comment_delete(&seed, &nid, &post_id, ts);
|
||
assert_eq!(sig.len(), 64);
|
||
assert!(verify_comment_delete(&nid, &post_id, ts, &sig));
|
||
// Wrong author key.
|
||
assert!(!verify_comment_delete(&mallory, &post_id, ts, &sig));
|
||
// Wrong tuple.
|
||
assert!(!verify_comment_delete(&nid, &post_id, ts + 1, &sig));
|
||
assert!(!verify_comment_delete(&nid, &[5u8; 32], ts, &sig));
|
||
// Garbage signature.
|
||
assert!(!verify_comment_delete(&nid, &post_id, ts, &[0u8; 64]));
|
||
assert!(!verify_comment_delete(&nid, &post_id, ts, &[]));
|
||
}
|
||
|
||
/// A3: derive_open_slot_vx determinism + distinctness across nonces
|
||
/// and authors.
|
||
#[test]
|
||
fn open_slot_vx_deterministic_and_distinct() {
|
||
let (_s1, a1) = make_keypair(21);
|
||
let (_s2, a2) = make_keypair(22);
|
||
let n1 = [0xAA; 32];
|
||
let n2 = [0xBB; 32];
|
||
|
||
let v = derive_open_slot_vx(&a1, &n1);
|
||
assert_eq!(v, derive_open_slot_vx(&a1, &n1), "deterministic");
|
||
assert_ne!(v, derive_open_slot_vx(&a1, &n2), "distinct per nonce");
|
||
assert_ne!(v, derive_open_slot_vx(&a2, &n1), "distinct per author");
|
||
}
|
||
|
||
/// A3: seal/open_greeting_body roundtrip, exact-bucket ciphertext
|
||
/// length equality across different plaintext lengths, tamper reject,
|
||
/// wrong-key reject.
|
||
#[test]
|
||
fn greeting_body_roundtrip_and_bucketing() {
|
||
let (bob_priv, bob_pub) = make_persona_x25519(30);
|
||
let (carol_priv, _carol_pub) = make_persona_x25519(31);
|
||
let bio_post_id: PostId = [0x77; 32];
|
||
const BUCKET: usize = 1024;
|
||
|
||
let body = br#"{"v":1,"sender_persona":"aa","sender_name":"Al","text":"hi","return_path":"bb","reply_pubkey":"cc"}"#;
|
||
let sealed = seal_greeting_body(&bob_pub, &bio_post_id, body, BUCKET).unwrap();
|
||
assert_eq!(sealed.len(), 32 + 4 + BUCKET + 16, "fixed-size ciphertext");
|
||
|
||
// Different plaintext length → same ciphertext length.
|
||
let sealed2 = seal_greeting_body(&bob_pub, &bio_post_id, b"x", BUCKET).unwrap();
|
||
assert_eq!(sealed.len(), sealed2.len(), "bucket hides length");
|
||
|
||
// Recipient opens.
|
||
let opened = open_greeting_body(&bob_priv, &bio_post_id, &sealed).unwrap();
|
||
assert_eq!(opened, body.to_vec());
|
||
|
||
// Non-recipient cannot.
|
||
assert!(open_greeting_body(&carol_priv, &bio_post_id, &sealed).is_none());
|
||
|
||
// Wrong bio post id cannot.
|
||
assert!(open_greeting_body(&bob_priv, &[0x88; 32], &sealed).is_none());
|
||
|
||
// Tampered ciphertext fails AEAD.
|
||
let mut tampered = sealed.clone();
|
||
let last = tampered.len() - 1;
|
||
tampered[last] ^= 0x01;
|
||
assert!(open_greeting_body(&bob_priv, &bio_post_id, &tampered).is_none());
|
||
|
||
// Oversized body refused.
|
||
assert!(seal_greeting_body(&bob_pub, &bio_post_id, &vec![0u8; BUCKET + 1], BUCKET).is_err());
|
||
}
|
||
|
||
/// A3: a reply sealed to a fresh `reply_pubkey` opens with the stored
|
||
/// reply private key and NOT with the recipient's long-term key.
|
||
#[test]
|
||
fn greeting_reply_sealed_to_fresh_key_only() {
|
||
// Long-term persona key of the greeting's original sender.
|
||
let (longterm_priv, _longterm_pub) = make_persona_x25519(40);
|
||
// Fresh per-greeting reply keypair minted by that sender.
|
||
let (reply_priv, reply_pub) = generate_x25519_keypair();
|
||
let return_path_post: PostId = [0x99; 32];
|
||
|
||
let reply_body = b"hello back";
|
||
let sealed = seal_greeting_body(&reply_pub, &return_path_post, reply_body, 1024).unwrap();
|
||
|
||
// Stored fresh reply key opens it.
|
||
let opened = open_greeting_body(&reply_priv, &return_path_post, &sealed).unwrap();
|
||
assert_eq!(opened, reply_body.to_vec());
|
||
|
||
// Long-term key does NOT.
|
||
assert!(open_greeting_body(&longterm_priv, &return_path_post, &sealed).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_sign_verify_manifest() {
|
||
use crate::types::{AuthorManifest, ManifestEntry};
|
||
let (seed, node_id) = make_keypair(1);
|
||
|
||
let mut manifest = AuthorManifest {
|
||
post_id: [42u8; 32],
|
||
author: node_id,
|
||
created_at: 1000,
|
||
updated_at: 2000,
|
||
previous_posts: vec![ManifestEntry {
|
||
post_id: [1u8; 32],
|
||
timestamp_ms: 900,
|
||
has_attachments: false,
|
||
}],
|
||
following_posts: vec![],
|
||
signature: vec![],
|
||
};
|
||
|
||
manifest.signature = sign_manifest(&seed, &manifest);
|
||
assert_eq!(manifest.signature.len(), 64);
|
||
assert!(verify_manifest_signature(&manifest));
|
||
}
|
||
|
||
/// v0.8 (A2): manifests must never carry device addresses — neither the
|
||
/// author-signed part nor the CdnManifest wire wrapper.
|
||
#[test]
|
||
fn test_manifest_carries_no_addresses() {
|
||
use crate::types::{AuthorManifest, CdnManifest, ManifestEntry};
|
||
let (seed, node_id) = make_keypair(1);
|
||
let (_hseed, host_id) = make_keypair(2);
|
||
|
||
let mut manifest = AuthorManifest {
|
||
post_id: [42u8; 32],
|
||
author: node_id,
|
||
created_at: 1000,
|
||
updated_at: 2000,
|
||
previous_posts: vec![ManifestEntry {
|
||
post_id: [1u8; 32],
|
||
timestamp_ms: 900,
|
||
has_attachments: true,
|
||
}],
|
||
following_posts: vec![],
|
||
signature: vec![],
|
||
};
|
||
manifest.signature = sign_manifest(&seed, &manifest);
|
||
assert!(verify_manifest_signature(&manifest));
|
||
|
||
let author_json = serde_json::to_string(&manifest).unwrap();
|
||
assert!(
|
||
!author_json.contains("addresses"),
|
||
"AuthorManifest JSON must not contain any address field: {author_json}"
|
||
);
|
||
|
||
let cdn = CdnManifest {
|
||
author_manifest: manifest,
|
||
host: host_id,
|
||
};
|
||
let cdn_json = serde_json::to_string(&cdn).unwrap();
|
||
assert!(
|
||
!cdn_json.contains("addresses"),
|
||
"CdnManifest JSON must not contain any address field: {cdn_json}"
|
||
);
|
||
assert!(!cdn_json.contains("downstream_count"));
|
||
assert!(!cdn_json.contains("\"source\""));
|
||
}
|
||
|
||
/// A1: crypto pairings must use the MATCHED persona's (id, seed) tuple.
|
||
/// The historical bug paired the default posting secret with the NETWORK
|
||
/// NodeId — a pair that can never unwrap anything.
|
||
#[test]
|
||
fn test_cek_unwrap_requires_matching_persona_pair() {
|
||
let (author_seed, author_id) = make_keypair(1);
|
||
let (persona_seed, persona_id) = make_keypair(2); // recipient persona
|
||
let (network_seed, network_id) = make_keypair(9); // device network key
|
||
|
||
let (_encrypted, wrapped_keys) =
|
||
encrypt_post("hi", &author_seed, &author_id, &[persona_id]).unwrap();
|
||
|
||
// Mismatched pair (persona seed + network id): recipient lookup fails.
|
||
let r = unwrap_cek_for_recipient(&persona_seed, &network_id, &author_id, &wrapped_keys).unwrap();
|
||
assert!(r.is_none(), "network id must not appear in recipients");
|
||
|
||
// Network pair entirely: also fails.
|
||
let r = unwrap_cek_for_recipient(&network_seed, &network_id, &author_id, &wrapped_keys).unwrap();
|
||
assert!(r.is_none());
|
||
|
||
// Matched persona pair: succeeds.
|
||
let r = unwrap_cek_for_recipient(&persona_seed, &persona_id, &author_id, &wrapped_keys).unwrap();
|
||
assert!(r.is_some(), "matched persona (id, seed) pair must unwrap the CEK");
|
||
}
|
||
|
||
/// A1 (revocation): rewrap_visibility works with the AUTHOR persona's
|
||
/// (seed, id) pair — and cannot work with the network pair the old code
|
||
/// passed.
|
||
#[test]
|
||
fn test_rewrap_visibility_uses_author_persona_pair() {
|
||
let (author_seed, author_id) = make_keypair(3); // non-default persona
|
||
let (_b_seed, bob_id) = make_keypair(4);
|
||
let (_c_seed, carol_id) = make_keypair(5);
|
||
let (network_seed, network_id) = make_keypair(9);
|
||
|
||
let (_encrypted, wrapped_keys) =
|
||
encrypt_post("secret", &author_seed, &author_id, &[bob_id, carol_id]).unwrap();
|
||
|
||
// Old bug shape: (default/other secret, network id) — must fail.
|
||
assert!(rewrap_visibility(&network_seed, &network_id, &wrapped_keys, &[author_id, bob_id]).is_err());
|
||
|
||
// Correct: the author persona's own pair — succeeds and re-wraps
|
||
// for the reduced recipient set.
|
||
let new_wrapped = rewrap_visibility(&author_seed, &author_id, &wrapped_keys, &[author_id, bob_id]).unwrap();
|
||
assert!(new_wrapped.iter().any(|wk| wk.recipient == bob_id));
|
||
assert!(!new_wrapped.iter().any(|wk| wk.recipient == carol_id), "revoked recipient must be gone");
|
||
}
|
||
|
||
#[test]
|
||
fn test_forged_manifest_rejected() {
|
||
use crate::types::AuthorManifest;
|
||
let (seed, node_id) = make_keypair(1);
|
||
let (_bob_seed, bob_id) = make_keypair(2);
|
||
|
||
let mut manifest = AuthorManifest {
|
||
post_id: [42u8; 32],
|
||
author: node_id,
|
||
created_at: 1000,
|
||
updated_at: 2000,
|
||
previous_posts: vec![],
|
||
following_posts: vec![],
|
||
signature: vec![],
|
||
};
|
||
|
||
manifest.signature = sign_manifest(&seed, &manifest);
|
||
|
||
// Tamper with author → verification fails
|
||
manifest.author = bob_id;
|
||
assert!(!verify_manifest_signature(&manifest));
|
||
|
||
// Restore author, tamper with updated_at → fails
|
||
manifest.author = node_id;
|
||
manifest.updated_at = 9999;
|
||
assert!(!verify_manifest_signature(&manifest));
|
||
}
|
||
|
||
#[test]
|
||
fn test_group_key_gen_and_id() {
|
||
let (seed1, pubkey1) = generate_group_keypair();
|
||
let (seed2, pubkey2) = generate_group_keypair();
|
||
assert_ne!(seed1, seed2);
|
||
assert_ne!(pubkey1, pubkey2);
|
||
let id1 = compute_group_id(&pubkey1);
|
||
let id2 = compute_group_id(&pubkey2);
|
||
assert_ne!(id1, id2);
|
||
// Deterministic
|
||
assert_eq!(compute_group_id(&pubkey1), id1);
|
||
}
|
||
|
||
#[test]
|
||
fn test_group_key_wrap_unwrap_roundtrip() {
|
||
let (admin_seed, admin_id) = make_keypair(1);
|
||
let (bob_seed, bob_id) = make_keypair(2);
|
||
|
||
let (group_seed, _group_pubkey) = generate_group_keypair();
|
||
|
||
// Admin wraps for Bob
|
||
let wrapped = wrap_group_key_for_member(&admin_seed, &bob_id, &group_seed).unwrap();
|
||
assert_eq!(wrapped.len(), 60);
|
||
|
||
// Bob unwraps using admin's public key
|
||
let unwrapped = unwrap_group_key(&bob_seed, &admin_id, &wrapped).unwrap();
|
||
assert_eq!(unwrapped, group_seed);
|
||
}
|
||
|
||
#[test]
|
||
fn test_group_key_wrap_unwrap_self() {
|
||
let (admin_seed, admin_id) = make_keypair(1);
|
||
let (group_seed, _) = generate_group_keypair();
|
||
|
||
let wrapped = wrap_group_key_for_member(&admin_seed, &admin_id, &group_seed).unwrap();
|
||
let unwrapped = unwrap_group_key(&admin_seed, &admin_id, &wrapped).unwrap();
|
||
assert_eq!(unwrapped, group_seed);
|
||
}
|
||
|
||
#[test]
|
||
fn test_group_encrypt_decrypt_roundtrip() {
|
||
let (group_seed, group_pubkey) = generate_group_keypair();
|
||
let plaintext = "Hello group members!";
|
||
|
||
let (encrypted, wrapped_cek) = encrypt_post_for_group(plaintext, &group_seed, &group_pubkey).unwrap();
|
||
assert_eq!(wrapped_cek.len(), 60);
|
||
|
||
let decrypted = decrypt_group_post(&encrypted, &group_seed, &group_pubkey, &wrapped_cek).unwrap();
|
||
assert_eq!(decrypted, plaintext);
|
||
}
|
||
|
||
#[test]
|
||
fn test_group_decrypt_wrong_seed_fails() {
|
||
let (group_seed, group_pubkey) = generate_group_keypair();
|
||
let (wrong_seed, _) = generate_group_keypair();
|
||
let plaintext = "Secret message";
|
||
|
||
let (encrypted, wrapped_cek) = encrypt_post_for_group(plaintext, &group_seed, &group_pubkey).unwrap();
|
||
|
||
let result = decrypt_group_post(&encrypted, &wrong_seed, &group_pubkey, &wrapped_cek);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_rotate_group_key() {
|
||
let (admin_seed, admin_id) = make_keypair(1);
|
||
let (_bob_seed, bob_id) = make_keypair(2);
|
||
let (_carol_seed, carol_id) = make_keypair(3);
|
||
|
||
let (new_seed, new_pubkey, new_epoch, member_keys) =
|
||
rotate_group_key(&admin_seed, 1, &[admin_id, bob_id, carol_id]).unwrap();
|
||
|
||
assert_eq!(new_epoch, 2);
|
||
assert_eq!(member_keys.len(), 3);
|
||
for mk in &member_keys {
|
||
assert_eq!(mk.epoch, 2);
|
||
assert_eq!(mk.wrapped_group_key.len(), 60);
|
||
}
|
||
|
||
// Verify the new seed can encrypt/decrypt
|
||
let plaintext = "New epoch message";
|
||
let (encrypted, wrapped_cek) = encrypt_post_for_group(plaintext, &new_seed, &new_pubkey).unwrap();
|
||
let decrypted = decrypt_group_post(&encrypted, &new_seed, &new_pubkey, &wrapped_cek).unwrap();
|
||
assert_eq!(decrypted, plaintext);
|
||
}
|
||
|
||
#[test]
|
||
fn test_rotate_then_decrypt_old_epoch() {
|
||
let (group_seed_v1, group_pubkey_v1) = generate_group_keypair();
|
||
let plaintext_v1 = "Old epoch message";
|
||
let (encrypted_v1, wrapped_cek_v1) = encrypt_post_for_group(plaintext_v1, &group_seed_v1, &group_pubkey_v1).unwrap();
|
||
|
||
// Rotate — new key pair
|
||
let (group_seed_v2, group_pubkey_v2) = generate_group_keypair();
|
||
let plaintext_v2 = "New epoch message";
|
||
let (encrypted_v2, wrapped_cek_v2) = encrypt_post_for_group(plaintext_v2, &group_seed_v2, &group_pubkey_v2).unwrap();
|
||
|
||
// Old epoch still decryptable with old seed
|
||
let dec_v1 = decrypt_group_post(&encrypted_v1, &group_seed_v1, &group_pubkey_v1, &wrapped_cek_v1).unwrap();
|
||
assert_eq!(dec_v1, plaintext_v1);
|
||
|
||
// New epoch decryptable with new seed
|
||
let dec_v2 = decrypt_group_post(&encrypted_v2, &group_seed_v2, &group_pubkey_v2, &wrapped_cek_v2).unwrap();
|
||
assert_eq!(dec_v2, plaintext_v2);
|
||
|
||
// Old seed cannot decrypt new epoch
|
||
let result = decrypt_group_post(&encrypted_v2, &group_seed_v1, &group_pubkey_v1, &wrapped_cek_v2);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_slot_key_derivation_deterministic() {
|
||
let cek = [42u8; 32];
|
||
let key1 = derive_slot_key(&cek);
|
||
let key2 = derive_slot_key(&cek);
|
||
assert_eq!(key1, key2);
|
||
// Different CEK gives different slot key
|
||
let cek2 = [99u8; 32];
|
||
let key3 = derive_slot_key(&cek2);
|
||
assert_ne!(key1, key3);
|
||
}
|
||
|
||
#[test]
|
||
fn test_slot_encrypt_decrypt_roundtrip() {
|
||
let cek = [42u8; 32];
|
||
let slot_key = derive_slot_key(&cek);
|
||
let plaintext = b"hello slot encryption";
|
||
let encrypted = encrypt_slot(plaintext, &slot_key).unwrap();
|
||
let decrypted = decrypt_slot(&encrypted, &slot_key).unwrap();
|
||
assert_eq!(decrypted, plaintext);
|
||
}
|
||
|
||
#[test]
|
||
fn test_slot_wrong_key_fails() {
|
||
let cek = [42u8; 32];
|
||
let slot_key = derive_slot_key(&cek);
|
||
let wrong_key = derive_slot_key(&[99u8; 32]);
|
||
let plaintext = b"secret data";
|
||
let encrypted = encrypt_slot(plaintext, &slot_key).unwrap();
|
||
assert!(decrypt_slot(&encrypted, &wrong_key).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_receipt_slot_roundtrip() {
|
||
let cek = [42u8; 32];
|
||
let slot_key = derive_slot_key(&cek);
|
||
|
||
// Build a receipt: state=seen, timestamp, no emoji
|
||
let mut plaintext = [0u8; 32];
|
||
plaintext[0] = 2; // seen
|
||
let ts: u64 = 1700000000000;
|
||
plaintext[1..9].copy_from_slice(&ts.to_le_bytes());
|
||
|
||
let encrypted = encrypt_slot(&plaintext, &slot_key).unwrap();
|
||
let decrypted = decrypt_slot(&encrypted, &slot_key).unwrap();
|
||
assert_eq!(decrypted[0], 2);
|
||
assert_eq!(u64::from_le_bytes(decrypted[1..9].try_into().unwrap()), ts);
|
||
}
|
||
|
||
#[test]
|
||
fn test_comment_slot_roundtrip() {
|
||
let cek = [42u8; 32];
|
||
let slot_key = derive_slot_key(&cek);
|
||
|
||
let mut plaintext = [0u8; 256];
|
||
let author = [1u8; 32];
|
||
plaintext[..32].copy_from_slice(&author);
|
||
let ts: u64 = 1700000000000;
|
||
plaintext[32..40].copy_from_slice(&ts.to_le_bytes());
|
||
let content = b"Hello from a slot comment!";
|
||
plaintext[40..40 + content.len()].copy_from_slice(content);
|
||
|
||
let encrypted = encrypt_slot(&plaintext, &slot_key).unwrap();
|
||
let decrypted = decrypt_slot(&encrypted, &slot_key).unwrap();
|
||
assert_eq!(&decrypted[..32], &author);
|
||
assert_eq!(u64::from_le_bytes(decrypted[32..40].try_into().unwrap()), ts);
|
||
let end = decrypted[40..].iter().position(|&b| b == 0).unwrap_or(216);
|
||
assert_eq!(&decrypted[40..40 + end], content);
|
||
}
|
||
|
||
#[test]
|
||
fn test_random_slot_noise_correct_size() {
|
||
let noise64 = random_slot_noise(64);
|
||
assert_eq!(noise64.len(), 64);
|
||
let noise256 = random_slot_noise(256);
|
||
assert_eq!(noise256.len(), 256);
|
||
// Different calls produce different noise (with very high probability)
|
||
assert_ne!(random_slot_noise(64), random_slot_noise(64));
|
||
}
|
||
|
||
// --- FoF Layer 1: vouch-grant seal/open ---
|
||
|
||
fn make_persona_x25519(seed_byte: u8) -> ([u8; 32], [u8; 32]) {
|
||
// Derive (x25519_priv, x25519_pub) from an ed25519 seed, mirroring
|
||
// the production path personas use.
|
||
let mut seed = [0u8; 32];
|
||
seed[0] = seed_byte;
|
||
let priv_x = ed25519_seed_to_x25519_private(&seed);
|
||
let signing_key = SigningKey::from_bytes(&seed);
|
||
let pub_x = signing_key.verifying_key().to_montgomery().to_bytes();
|
||
(priv_x, pub_x)
|
||
}
|
||
|
||
#[test]
|
||
fn vouch_grant_roundtrip() {
|
||
let (alice_priv, _alice_pub) = make_persona_x25519(11);
|
||
let (bob_priv, bob_pub) = make_persona_x25519(22);
|
||
let bio_post_id: PostId = [7u8; 32];
|
||
let v_me: [u8; 32] = [42u8; 32];
|
||
|
||
let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral();
|
||
|
||
// Seal for Bob
|
||
let wrapper = seal_vouch_grant(&eph_priv, &bob_pub, &bio_post_id, &v_me).unwrap();
|
||
assert_eq!(wrapper.len(), 48, "wrapper must be 48 bytes (32 sealed + 16 tag)");
|
||
|
||
// Bob opens it
|
||
let opened = open_vouch_grant(&bob_priv, &eph_pub, &bio_post_id, &wrapper);
|
||
assert_eq!(opened, Some(v_me));
|
||
|
||
// Alice (not the recipient) cannot open it
|
||
let alice_attempt = open_vouch_grant(&alice_priv, &eph_pub, &bio_post_id, &wrapper);
|
||
assert_eq!(alice_attempt, None, "non-recipient must not decrypt");
|
||
}
|
||
|
||
#[test]
|
||
fn vouch_grant_wrong_bio_post_id_fails() {
|
||
let (_, bob_pub) = make_persona_x25519(22);
|
||
let (bob_priv, _) = make_persona_x25519(22);
|
||
let real_bio_id: PostId = [1u8; 32];
|
||
let wrong_bio_id: PostId = [2u8; 32];
|
||
let v_me: [u8; 32] = [99u8; 32];
|
||
|
||
let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral();
|
||
let wrapper = seal_vouch_grant(&eph_priv, &bob_pub, &real_bio_id, &v_me).unwrap();
|
||
|
||
// Wrong bio_post_id derives a different key+nonce → AEAD fails.
|
||
let attempt = open_vouch_grant(&bob_priv, &eph_pub, &wrong_bio_id, &wrapper);
|
||
assert_eq!(attempt, None);
|
||
|
||
// Right bio_post_id succeeds.
|
||
let ok = open_vouch_grant(&bob_priv, &eph_pub, &real_bio_id, &wrapper);
|
||
assert_eq!(ok, Some(v_me));
|
||
}
|
||
|
||
#[test]
|
||
fn vouch_grant_random_bytes_fail() {
|
||
let (bob_priv, _) = make_persona_x25519(22);
|
||
let bio_post_id: PostId = [5u8; 32];
|
||
let (_, eph_pub) = generate_vouch_batch_ephemeral();
|
||
|
||
let mut junk = [0u8; 48];
|
||
rand::rng().fill_bytes(&mut junk);
|
||
let attempt = open_vouch_grant(&bob_priv, &eph_pub, &bio_post_id, &junk);
|
||
assert_eq!(attempt, None, "random bytes must AEAD-fail (dummy wrapper indistinguishable)");
|
||
}
|
||
|
||
// --- FoF Layer 2: wrap-slot seal/open ---
|
||
|
||
#[test]
|
||
fn wrap_slot_roundtrip() {
|
||
let v_x: [u8; 32] = [0x42; 32];
|
||
let slot_binder_nonce: [u8; 32] = [0xAB; 32];
|
||
let cek: [u8; 32] = [0x01; 32];
|
||
let priv_x_seed: [u8; 32] = [0x02; 32];
|
||
|
||
let sealed = seal_wrap_slot(&v_x, &slot_binder_nonce, &cek, &priv_x_seed).unwrap();
|
||
assert_eq!(sealed.read_ciphertext.len(), 48);
|
||
assert_eq!(sealed.sign_ciphertext.len(), 48);
|
||
|
||
// Same V_x opens it.
|
||
let opened = open_wrap_slot(
|
||
&v_x, &slot_binder_nonce,
|
||
&sealed.read_ciphertext, &sealed.sign_ciphertext,
|
||
).unwrap();
|
||
assert_eq!(opened.cek, cek);
|
||
assert_eq!(opened.priv_x_seed, priv_x_seed);
|
||
|
||
// Different V_x must not.
|
||
let wrong_v_x: [u8; 32] = [0x99; 32];
|
||
let attempt = open_wrap_slot(
|
||
&wrong_v_x, &slot_binder_nonce,
|
||
&sealed.read_ciphertext, &sealed.sign_ciphertext,
|
||
);
|
||
assert_eq!(attempt, None);
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_slot_wrong_binder_fails() {
|
||
let v_x: [u8; 32] = [0x42; 32];
|
||
let real_nonce: [u8; 32] = [0xAB; 32];
|
||
let wrong_nonce: [u8; 32] = [0xCD; 32];
|
||
let cek: [u8; 32] = [0x01; 32];
|
||
let priv_x_seed: [u8; 32] = [0x02; 32];
|
||
|
||
let sealed = seal_wrap_slot(&v_x, &real_nonce, &cek, &priv_x_seed).unwrap();
|
||
// Same V_x but wrong slot_binder_nonce → AEAD-fail.
|
||
let attempt = open_wrap_slot(
|
||
&v_x, &wrong_nonce,
|
||
&sealed.read_ciphertext, &sealed.sign_ciphertext,
|
||
);
|
||
assert_eq!(attempt, None);
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_slot_prefilter_tag_is_stable_and_keyed() {
|
||
let v_a: [u8; 32] = [0x11; 32];
|
||
let v_b: [u8; 32] = [0x22; 32];
|
||
let nonce_x: [u8; 32] = [0xAA; 32];
|
||
let nonce_y: [u8; 32] = [0xBB; 32];
|
||
|
||
let t1 = wrap_slot_prefilter_tag(&v_a, &nonce_x);
|
||
let t2 = wrap_slot_prefilter_tag(&v_a, &nonce_x);
|
||
assert_eq!(t1, t2, "deterministic for same inputs");
|
||
|
||
// Different V_x or different nonce → different tag (overwhelmingly).
|
||
let t3 = wrap_slot_prefilter_tag(&v_b, &nonce_x);
|
||
assert_ne!(t1, t3);
|
||
let t4 = wrap_slot_prefilter_tag(&v_a, &nonce_y);
|
||
assert_ne!(t1, t4);
|
||
}
|
||
|
||
#[test]
|
||
fn cek_comments_is_distinct_per_post() {
|
||
let cek: [u8; 32] = [0x01; 32];
|
||
let nonce_a: [u8; 32] = [0xAA; 32];
|
||
let nonce_b: [u8; 32] = [0xBB; 32];
|
||
let a = derive_cek_comments(&cek, &nonce_a);
|
||
let b = derive_cek_comments(&cek, &nonce_b);
|
||
assert_ne!(a, b);
|
||
// Stable.
|
||
assert_eq!(derive_cek_comments(&cek, &nonce_a), a);
|
||
// Different from the base CEK.
|
||
assert_ne!(a, cek);
|
||
}
|
||
}
|