diff --git a/crates/core/src/crypto.rs b/crates/core/src/crypto.rs index f7264c1..9130d6b 100644 --- a/crates/core/src/crypto.rs +++ b/crates/core/src/crypto.rs @@ -18,6 +18,17 @@ const CEK_WRAP_CONTEXT: &str = "itsgoin/cek-wrap/v1"; 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); @@ -301,6 +312,158 @@ pub fn open_vouch_grant( Some(v_me) } +// --- FoF Layer 2: wrap-slot seal/open (dual-derived read + sign) --- +// +// Each post under FoF comment-gating carries one wrap slot per +// admitted V_x. The slot is dual-derived: one half yields the post's +// shared CEK (read capability), the other yields the per-V_x signing +// seed priv_x (comment-authorship capability for that voucher-chain). +// +// Receivers trial-decrypt slots whose prefilter tag matches one of +// their held V_x's. Successful AEAD-open on the `read` part gives +// them CEK; the `sign` part gives them priv_x. They derive the +// matching pub_x = ed25519_pub(priv_x_seed); the CDN verifies their +// comment signatures against pub_x via the post's pub_post_set. +// +// All AEAD derivation is bound to a per-post `slot_binder_nonce` +// (random 32B in the post header). This plays the same role as the +// spec's "post_id in HKDF info" but isn't circular (PostId = +// BLAKE3(post) depends on wrap_slots → circular). +// +// Wire shape: +// prefilter_tag: 2 bytes (HMAC(V_x, slot_binder_nonce)[:2]) +// read_part: 48 bytes (32B sealed CEK + 16B tag) +// sign_part: 48 bytes (32B sealed priv_x seed + 16B tag) +// Total: 98 bytes per slot. + +/// Output of [`seal_wrap_slot`]. All fields are wire-stable. See module +/// doc above for derivation details. +#[derive(Debug, Clone)] +pub struct SealedWrapSlot { + pub prefilter_tag: [u8; 2], + pub read_ciphertext: Vec, // 48 bytes + pub sign_ciphertext: Vec, // 48 bytes +} + +/// Compute the 2-byte prefilter tag for a (V_x, slot_binder_nonce) +/// pair. Cheap; receivers precompute one per held V_x per post and +/// skip non-matching slots entirely. +pub fn wrap_slot_prefilter_tag(v_x: &[u8; 32], slot_binder_nonce: &[u8; 32]) -> [u8; 2] { + let mut input = [0u8; 64]; + input[..32].copy_from_slice(slot_binder_nonce); + input[32..].copy_from_slice(v_x); + let tag = blake3::derive_key(WRAP_SLOT_PREFILTER_CONTEXT, &input); + [tag[0], tag[1]] +} + +fn derive_wrap_slot_part( + v_x: &[u8; 32], + slot_binder_nonce: &[u8; 32], + key_ctx: &str, + nonce_ctx: &str, +) -> ([u8; 32], [u8; 12]) { + let mut input = [0u8; 64]; + input[..32].copy_from_slice(slot_binder_nonce); + input[32..].copy_from_slice(v_x); + let key = blake3::derive_key(key_ctx, &input); + let nonce_full = blake3::derive_key(nonce_ctx, &input); + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(&nonce_full[..12]); + (key, nonce) +} + +/// Seal one wrap slot for a specific V_x. Pair (CEK, priv_x_seed) is +/// the slot plaintext: the read part carries CEK, the sign part carries +/// priv_x_seed. Both halves are bound to `slot_binder_nonce` via HKDF. +pub fn seal_wrap_slot( + v_x: &[u8; 32], + slot_binder_nonce: &[u8; 32], + cek: &[u8; 32], + priv_x_seed: &[u8; 32], +) -> Result { + let (read_key, read_nonce) = derive_wrap_slot_part( + v_x, slot_binder_nonce, + WRAP_SLOT_READ_KEY_CONTEXT, WRAP_SLOT_READ_NONCE_CONTEXT, + ); + let (sign_key, sign_nonce) = derive_wrap_slot_part( + v_x, slot_binder_nonce, + WRAP_SLOT_SIGN_KEY_CONTEXT, WRAP_SLOT_SIGN_NONCE_CONTEXT, + ); + let read_cipher = ChaCha20Poly1305::new_from_slice(&read_key) + .map_err(|e| anyhow::anyhow!("read cipher init: {}", e))?; + let sign_cipher = ChaCha20Poly1305::new_from_slice(&sign_key) + .map_err(|e| anyhow::anyhow!("sign cipher init: {}", e))?; + let read_ct = read_cipher + .encrypt(Nonce::from_slice(&read_nonce), cek.as_slice()) + .map_err(|e| anyhow::anyhow!("read seal: {}", e))?; + let sign_ct = sign_cipher + .encrypt(Nonce::from_slice(&sign_nonce), priv_x_seed.as_slice()) + .map_err(|e| anyhow::anyhow!("sign seal: {}", e))?; + if read_ct.len() != 48 || sign_ct.len() != 48 { + bail!("unexpected wrap-slot ciphertext length"); + } + Ok(SealedWrapSlot { + prefilter_tag: wrap_slot_prefilter_tag(v_x, slot_binder_nonce), + read_ciphertext: read_ct, + sign_ciphertext: sign_ct, + }) +} + +/// Output of a successful [`open_wrap_slot`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpenedWrapSlot { + pub cek: [u8; 32], + pub priv_x_seed: [u8; 32], +} + +/// Try to open a wrap slot using one of the receiver's V_x's. Returns +/// `None` if either AEAD fails (this slot isn't sealed under this V_x). +pub fn open_wrap_slot( + v_x: &[u8; 32], + slot_binder_nonce: &[u8; 32], + read_ciphertext: &[u8], + sign_ciphertext: &[u8], +) -> Option { + if read_ciphertext.len() != 48 || sign_ciphertext.len() != 48 { + return None; + } + let (read_key, read_nonce) = derive_wrap_slot_part( + v_x, slot_binder_nonce, + WRAP_SLOT_READ_KEY_CONTEXT, WRAP_SLOT_READ_NONCE_CONTEXT, + ); + let (sign_key, sign_nonce) = derive_wrap_slot_part( + v_x, slot_binder_nonce, + WRAP_SLOT_SIGN_KEY_CONTEXT, WRAP_SLOT_SIGN_NONCE_CONTEXT, + ); + let read_cipher = ChaCha20Poly1305::new_from_slice(&read_key).ok()?; + let sign_cipher = ChaCha20Poly1305::new_from_slice(&sign_key).ok()?; + let cek_bytes = read_cipher + .decrypt(Nonce::from_slice(&read_nonce), read_ciphertext) + .ok()?; + let seed_bytes = sign_cipher + .decrypt(Nonce::from_slice(&sign_nonce), sign_ciphertext) + .ok()?; + if cek_bytes.len() != 32 || seed_bytes.len() != 32 { + return None; + } + let mut cek = [0u8; 32]; + cek.copy_from_slice(&cek_bytes); + let mut priv_x_seed = [0u8; 32]; + priv_x_seed.copy_from_slice(&seed_bytes); + Some(OpenedWrapSlot { cek, priv_x_seed }) +} + +/// Derive the per-post comments CEK from the wrap-slot CEK. The +/// comments-CEK is used to encrypt comment bodies separately from the +/// post body — preserves the option of body-public + comments-private +/// (Mode 2) without leaking the body CEK relationship. +pub fn derive_cek_comments(cek: &[u8; 32], slot_binder_nonce: &[u8; 32]) -> [u8; 32] { + let mut input = [0u8; 64]; + input[..32].copy_from_slice(cek); + input[32..].copy_from_slice(slot_binder_nonce); + blake3::derive_key("itsgoin/fof-cek-comments/v1", &input) +} + /// Encrypt a post with a provided CEK, wrapping for recipients. /// Returns `(base64_ciphertext, Vec)`. pub fn encrypt_post_with_cek( @@ -1522,4 +1685,83 @@ mod tests { 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); + } }