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))
}
// --- 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 ---
/// 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];