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

@ -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<Vec<u8>> = (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<NodeId> = 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),
}
}