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

@ -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