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>