feat(fof-layer3): PostVisibility::FoFClosed + body crypto + bucket padding

Adds the Mode 1 (encrypted body) primitives:

PostVisibility::FoFClosed
- New tag variant. The actual gating data (slot_binder_nonce,
  pub_post_set, wrap_slots) lives in Post.fof_gating — single source
  of truth shared between Mode 2 (Public + fof_gating) and Mode 1
  (FoFClosed + fof_gating). Invariant: FoFClosed implies Some(gating).

fof::encrypt_fof_body / decrypt_fof_body
- ChaCha20-Poly1305 under the gating CEK with slot_binder_nonce as
  AAD (binds body decrypt to the post's gating; an attacker who
  steals CEK can't reuse it against a different post).
- Plaintext format: real_len_u32_le || body_bytes || random_padding.
  Length prefix lets the reader strip padding after decrypt.
- Bucketed body padding: power-of-2 from 1KB up to 256KB, then
  +256KB linear above. Different bodies in the same bucket produce
  identically-sized ciphertexts (test asserts this).

fof::next_body_size_bucket(real) -> usize
- Min 1KB, power-of-2 to 256KB, then +256KB steps. Aligns with the
  future storage chunk size at 256KB+.

Three new tests (145 total):
- body_bucket_rule_boundaries: spec-conformance for the bucket sizes.
- fof_body_roundtrip: encrypt → decrypt; wrong CEK rejects; wrong AAD
  (slot_binder_nonce) rejects.
- fof_body_padding_hides_real_length: 5B body and 500B body produce
  same-sized on-wire ciphertexts (1KB bucket).

8 match arms updated to handle FoFClosed across import, network, node,
storage. Most paths skip FoFClosed-specific handling (it goes through
the FoF wrap_slot path); revoke_post_access bails with a pointer to
the FoF revoke helpers; index_post_recipients no-ops (FoF has no
per-recipient identifiers on the wire).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-14 16:22:46 -04:00
parent 10de3f6108
commit 856f386231
6 changed files with 232 additions and 3 deletions

View file

@ -341,6 +341,122 @@ pub fn decrypt_fof_comment_payload(
.map_err(|e| anyhow::anyhow!("deserialize fof comment payload: {}", e)) .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<Vec<u8>> {
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<String> {
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 --- // --- Revocation: sign + verify + apply ---
/// Bytes covered by a `BlobHeaderDiffOp::FoFRevocation.author_sig`. /// 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"); 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] #[test]
fn fof_revocation_wrong_author_rejected() { fn fof_revocation_wrong_author_rejected() {
let post_id = [0x01; 32]; let post_id = [0x01; 32];

View file

@ -62,6 +62,9 @@ fn parse_exported_intent(raw: Option<&str>, vis: &PostVisibility) -> VisibilityI
// No intent recorded — infer from the visibility shape. // No intent recorded — infer from the visibility shape.
match vis { match vis {
PostVisibility::Public => VisibilityIntent::Public, 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 } => { PostVisibility::Encrypted { recipients } => {
// Heuristic: DMs typically wrap to 1-2 people (recipient + self); // Heuristic: DMs typically wrap to 1-2 people (recipient + self);
// Friends posts wrap to every public follow (usually many). // Friends posts wrap to every public follow (usually many).
@ -679,6 +682,16 @@ pub async fn merge_with_key(
skipped += 1; skipped += 1;
continue; 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 // Create new post under our identity

View file

@ -2234,6 +2234,11 @@ pub fn should_send_post(
.map(|members| members.iter().any(|m| query_list.contains(m))) .map(|members| members.iter().any(|m| query_list.contains(m)))
.unwrap_or(false) .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),
} }
} }

View file

@ -1213,8 +1213,13 @@ impl Node {
let _ = storage.pin_blob(&att.cid); let _ = storage.pin_blob(&att.cid);
} }
// Initialize encrypted receipt + comment slots for non-public posts // Initialize encrypted receipt + comment slots for non-public posts.
if !matches!(visibility, PostVisibility::Public) { // 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 { let participant_count = match &visibility {
PostVisibility::Encrypted { recipients } => recipients.len(), PostVisibility::Encrypted { recipients } => recipients.len(),
PostVisibility::GroupEncrypted { .. } => { PostVisibility::GroupEncrypted { .. } => {
@ -1229,7 +1234,7 @@ impl Node {
_ => 2, _ => 2,
} }
} }
PostVisibility::Public => unreachable!(), PostVisibility::Public | PostVisibility::FoFClosed => unreachable!(),
}; };
let receipt_slots: Vec<Vec<u8>> = (0..participant_count) let receipt_slots: Vec<Vec<u8>> = (0..participant_count)
@ -1483,6 +1488,15 @@ impl Node {
).ok() ).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) (id, post, vis, decrypted)
}) })
@ -1917,6 +1931,15 @@ impl Node {
Ok(None) 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 { .. } => { PostVisibility::GroupEncrypted { .. } => {
anyhow::bail!("cannot revoke individual access on a group-encrypted post; remove from circle instead") 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<NodeId> = existing_recipients let new_recipient_ids: Vec<NodeId> = existing_recipients
@ -5058,6 +5084,11 @@ impl Node {
Ok(None) 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),
} }
} }

View file

@ -4540,6 +4540,10 @@ impl Storage {
} }
Ok(()) 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(()),
} }
} }

View file

@ -218,6 +218,16 @@ pub enum PostVisibility {
/// 60 bytes: nonce(12) || encrypted_cek(32) || tag(16) /// 60 bytes: nonce(12) || encrypted_cek(32) || tag(16)
wrapped_cek: Vec<u8>, wrapped_cek: Vec<u8>,
}, },
/// 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 { impl Default for PostVisibility {