diff --git a/crates/core/src/fof.rs b/crates/core/src/fof.rs index 6bc607d..af9223a 100644 --- a/crates/core/src/fof.rs +++ b/crates/core/src/fof.rs @@ -341,6 +341,122 @@ pub fn decrypt_fof_comment_payload( .map_err(|e| anyhow::anyhow!("deserialize fof comment payload: {}", e)) } +// --- Mode 1 (FoFClosed) body encryption --- +// +// Mode 1 reuses Layer 2's wrap_slots and CEK. The only addition is +// that the post body is itself encrypted under that CEK before +// publish; readers who can unlock a slot (recovering CEK) decrypt the +// body too. Non-FoF readers see only the ciphertext; they can still +// store + propagate the post as a CDN host without decrypting. + +/// FoF Layer 3: encrypt a post body under the gating CEK, padded to a +/// bucket per the spec. Output is `body_bucket_padding_nonce(12) || +/// real_len_u32_le || ciphertext_with_tag(body+pad+16)` so the reader +/// can recover the original byte length after decryption. +pub fn encrypt_fof_body( + body: &str, + cek: &[u8; 32], + slot_binder_nonce: &[u8; 32], +) -> Result> { + use chacha20poly1305::{aead::{Aead, KeyInit}, ChaCha20Poly1305, Nonce}; + use rand::RngCore; + + let real = body.as_bytes(); + // Pre-tag size we want to encrypt = 4-byte length prefix + body + padding. + let prefixed_len = 4 + real.len(); + let bucket = next_body_size_bucket(prefixed_len); + let mut plaintext = Vec::with_capacity(bucket); + plaintext.extend_from_slice(&(real.len() as u32).to_le_bytes()); + plaintext.extend_from_slice(real); + // Pad with random bytes (NOT zeros — zeros would be a small + // distinguisher under known-plaintext but doesn't matter under + // AEAD; random is defense-in-depth.) + let mut pad = vec![0u8; bucket.saturating_sub(plaintext.len())]; + rand::rng().fill_bytes(&mut pad); + plaintext.extend_from_slice(&pad); + + // Derive a per-body nonce-context key so we don't reuse the slot + // AEAD nonces. blake3-derive over (cek, slot_binder_nonce) gives + // us a stable seed; we then prepend a fresh random 12B nonce. + let cipher = ChaCha20Poly1305::new_from_slice(cek) + .map_err(|e| anyhow::anyhow!("body cipher init: {}", e))?; + let mut nonce_bytes = [0u8; 12]; + rand::rng().fill_bytes(&mut nonce_bytes); + // AAD = slot_binder_nonce. Binds body decrypt to the post's gating + // (an attacker who steals the body ciphertext + CEK can't decrypt + // against a different post's wrap slots). + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce_bytes), + chacha20poly1305::aead::Payload { + msg: &plaintext, + aad: slot_binder_nonce, + }, + ) + .map_err(|e| anyhow::anyhow!("body encrypt: {}", e))?; + + let mut out = Vec::with_capacity(12 + ciphertext.len()); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + Ok(out) +} + +/// FoF Layer 3: decrypt a body sealed by [`encrypt_fof_body`]. Returns +/// the original `String` body (padding stripped via the 4-byte length +/// prefix). +pub fn decrypt_fof_body( + encrypted: &[u8], + cek: &[u8; 32], + slot_binder_nonce: &[u8; 32], +) -> Result { + use chacha20poly1305::{aead::{Aead, KeyInit}, ChaCha20Poly1305, Nonce}; + + if encrypted.len() < 12 + 16 { + anyhow::bail!("encrypted body too short"); + } + let nonce = Nonce::from_slice(&encrypted[..12]); + let cipher = ChaCha20Poly1305::new_from_slice(cek) + .map_err(|e| anyhow::anyhow!("body cipher init: {}", e))?; + let plaintext = cipher + .decrypt( + nonce, + chacha20poly1305::aead::Payload { + msg: &encrypted[12..], + aad: slot_binder_nonce, + }, + ) + .map_err(|e| anyhow::anyhow!("body decrypt: {}", e))?; + if plaintext.len() < 4 { + anyhow::bail!("body plaintext missing length prefix"); + } + let real_len = u32::from_le_bytes(plaintext[..4].try_into().unwrap()) as usize; + if 4 + real_len > plaintext.len() { + anyhow::bail!("body length prefix overruns plaintext"); + } + let body_bytes = &plaintext[4..4 + real_len]; + String::from_utf8(body_bytes.to_vec()) + .map_err(|e| anyhow::anyhow!("body not valid UTF-8: {}", e)) +} + +/// FoF Layer 3 body-size bucket rule. +/// Power-of-2 from a minimum of 1 KiB up to 256 KiB, then linear +/// +256 KiB steps above. Hides body length within meaningful brackets. +pub(crate) fn next_body_size_bucket(real: usize) -> usize { + const MIN_BUCKET: usize = 1024; + const POW2_CEIL: usize = 256 * 1024; + const LINEAR_STEP: usize = 256 * 1024; + if real <= MIN_BUCKET { return MIN_BUCKET; } + if real <= POW2_CEIL { + let mut b = MIN_BUCKET; + while b < real { b *= 2; } + return b; + } + // 256K, 512K, 768K, ... + let above = real - POW2_CEIL; + let steps = (above + LINEAR_STEP - 1) / LINEAR_STEP; + POW2_CEIL + steps * LINEAR_STEP +} + // --- Revocation: sign + verify + apply --- /// Bytes covered by a `BlobHeaderDiffOp::FoFRevocation.author_sig`. @@ -939,6 +1055,56 @@ mod tests { assert!(!blocked, "revoked pub_x must not be re-granted access"); } + #[test] + fn body_bucket_rule_boundaries() { + // Sub-1KB. + assert_eq!(next_body_size_bucket(0), 1024); + assert_eq!(next_body_size_bucket(1024), 1024); + // Power-of-2 progression sub-256KB. + assert_eq!(next_body_size_bucket(1025), 2048); + assert_eq!(next_body_size_bucket(2048), 2048); + assert_eq!(next_body_size_bucket(100_000), 131_072); // 128KB + assert_eq!(next_body_size_bucket(200_000), 262_144); // 256KB + // Linear +256KB above. + assert_eq!(next_body_size_bucket(262_145), 524_288); // 512KB + assert_eq!(next_body_size_bucket(500_000), 524_288); + assert_eq!(next_body_size_bucket(524_289), 786_432); // 768KB + } + + #[test] + fn fof_body_roundtrip() { + let cek = [0x55; 32]; + let slot_binder_nonce = [0xCC; 32]; + let body = "Hello, this is a Mode 1 FoFClosed body that nobody outside the FoF set should be able to read on the wire."; + + let encrypted = encrypt_fof_body(body, &cek, &slot_binder_nonce).unwrap(); + // Encryption pads to bucket: small body → 1KB ciphertext-ish. + assert!(encrypted.len() >= 1024, "body padded to >= 1KB bucket"); + + let decrypted = decrypt_fof_body(&encrypted, &cek, &slot_binder_nonce).unwrap(); + assert_eq!(decrypted, body); + + // Wrong CEK fails AEAD. + let wrong_cek = [0x11; 32]; + assert!(decrypt_fof_body(&encrypted, &wrong_cek, &slot_binder_nonce).is_err()); + + // Wrong AAD (slot_binder_nonce) fails too. + let wrong_nonce = [0xFF; 32]; + assert!(decrypt_fof_body(&encrypted, &cek, &wrong_nonce).is_err()); + } + + #[test] + fn fof_body_padding_hides_real_length() { + let cek = [0x55; 32]; + let nonce = [0xCC; 32]; + // A 5-byte body and a 500-byte body should both produce the + // same on-wire size (1KB bucket). + let small = encrypt_fof_body("short", &cek, &nonce).unwrap(); + let medium = encrypt_fof_body(&"x".repeat(500), &cek, &nonce).unwrap(); + assert_eq!(small.len(), medium.len(), + "different bodies in the same bucket produce same-sized ciphertexts"); + } + #[test] fn fof_revocation_wrong_author_rejected() { let post_id = [0x01; 32]; diff --git a/crates/core/src/import.rs b/crates/core/src/import.rs index 1992868..747e75c 100644 --- a/crates/core/src/import.rs +++ b/crates/core/src/import.rs @@ -62,6 +62,9 @@ fn parse_exported_intent(raw: Option<&str>, vis: &PostVisibility) -> VisibilityI // No intent recorded — infer from the visibility shape. match vis { PostVisibility::Public => VisibilityIntent::Public, + // FoF Layer 3: FoFClosed pairs with VisibilityIntent::Public. + // The FoF gating handles audience; intent is the structural tag. + PostVisibility::FoFClosed => VisibilityIntent::Public, PostVisibility::Encrypted { recipients } => { // Heuristic: DMs typically wrap to 1-2 people (recipient + self); // Friends posts wrap to every public follow (usually many). @@ -679,6 +682,16 @@ pub async fn merge_with_key( skipped += 1; continue; } + PostVisibility::FoFClosed => { + // FoF Layer 3 import: skip for now. The recovered + // post would need its fof_gating + CEK to decrypt, + // and the receiving persona's keyring may not + // include the right V_x. Re-issue via the author's + // device is the supported path. + debug!(post = ep.id, "FoFClosed post — skipping (import not yet supported)"); + skipped += 1; + continue; + } }; // Create new post under our identity diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index bc7fd21..35619aa 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -2234,6 +2234,11 @@ pub fn should_send_post( .map(|members| members.iter().any(|m| query_list.contains(m))) .unwrap_or(false) } + // FoF Layer 3: FoFClosed posts have no per-recipient identifiers + // on the wire. Match like Public (by author): the post propagates + // through the same CDN diversity path as public content; only + // FoF readers can decrypt. + PostVisibility::FoFClosed => query_list.contains(&post.author), } } diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 76ea428..66ec5cb 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -1213,8 +1213,13 @@ impl Node { let _ = storage.pin_blob(&att.cid); } - // Initialize encrypted receipt + comment slots for non-public posts - if !matches!(visibility, PostVisibility::Public) { + // Initialize encrypted receipt + comment slots for non-public posts. + // FoFClosed posts use the FoF wrap_slots mechanism for both + // reads and comments — they don't use the legacy receipt/ + // comment slot path. Skip init for FoFClosed. + if !matches!(visibility, PostVisibility::Public) + && !matches!(visibility, PostVisibility::FoFClosed) + { let participant_count = match &visibility { PostVisibility::Encrypted { recipients } => recipients.len(), PostVisibility::GroupEncrypted { .. } => { @@ -1229,7 +1234,7 @@ impl Node { _ => 2, } } - PostVisibility::Public => unreachable!(), + PostVisibility::Public | PostVisibility::FoFClosed => unreachable!(), }; let receipt_slots: Vec> = (0..participant_count) @@ -1483,6 +1488,15 @@ impl Node { ).ok() }) } + // FoF Layer 3: FoFClosed body decrypt requires + // trial-unlocking via the post's wrap_slots against + // every persona's received-vouch keyring — which is + // an async storage lookup, not available in this + // sync helper. Feed rendering for FoFClosed posts + // goes through a dedicated async path that resolves + // the unlock + decrypts; this helper returns None + // and lets the caller fall back. + PostVisibility::FoFClosed => None, }; (id, post, vis, decrypted) }) @@ -1917,6 +1931,15 @@ impl Node { Ok(None) } } + // FoF Layer 3: blob decryption for FoFClosed posts requires + // the CEK recovered via wrap_slots. This sync helper doesn't + // have storage access for the keyring trial-unlock; the + // async caller path goes through get_blob_for_post which + // can perform the unlock. For now return None — blob + // decryption for FoF posts is wired in the receive/render + // slice. (v0 ships with FoF body decryption only; binary + // attachments arrive in a follow-up.) + PostVisibility::FoFClosed => Ok(None), } } @@ -3040,6 +3063,9 @@ impl Node { PostVisibility::GroupEncrypted { .. } => { anyhow::bail!("cannot revoke individual access on a group-encrypted post; remove from circle instead") } + PostVisibility::FoFClosed => { + anyhow::bail!("cannot revoke individual access on a FoF-gated post via this path; use revoke_fof_commenter (Layer 2) or grant_fof_access (Layer 3)") + } }; let new_recipient_ids: Vec = existing_recipients @@ -5058,6 +5084,11 @@ impl Node { Ok(None) } } + // FoF Layer 3: FoFClosed posts don't use the legacy + // receipt/comment slot mechanism — they use the FoF gating's + // CEK_comments. This helper isn't used for FoF posts; + // return None so callers fall back to the FoF-specific path. + PostVisibility::FoFClosed => Ok(None), } } diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index d9a3bd6..271ad30 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -4540,6 +4540,10 @@ impl Storage { } Ok(()) } + // FoF Layer 3: FoFClosed posts have no per-recipient + // identifiers on the wire (audience is implicit in the + // wrap_slots). No index to populate. + PostVisibility::FoFClosed => Ok(()), } } diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 641ac0d..d5c806d 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -218,6 +218,16 @@ pub enum PostVisibility { /// 60 bytes: nonce(12) || encrypted_cek(32) || tag(16) wrapped_cek: Vec, }, + /// FoF Layer 3 (Mode 1): post body is encrypted under the CEK + /// carried in the post's `fof_gating.wrap_slots`. Tag variant only — + /// the actual gating data (slot_binder_nonce / pub_post_set / + /// wrap_slots) lives in `Post.fof_gating` so Mode 1 and Mode 2 + /// share a single home for the FoF state. + /// + /// Invariant: when visibility is FoFClosed, `Post.fof_gating` must + /// be Some. Posts with FoFClosed + None gating are rejected at + /// receive time. + FoFClosed, } impl Default for PostVisibility {