# Layer 3 — Mode 1: `FOF_CLOSED` Posts > **⚠️ Partially superseded by Layer 2 rewrite (2026-04-24).** Layer 2 now defines the canonical wrap-slot structure (dual read/sign derivation), `pub_post_set`, per-`V_x` signing keypair, and CDN-level verification. Layer 3 inherits all of that unchanged — the Mode 1 vs Mode 2 distinction reduces to "body encrypted under CEK (Mode 1) vs body plaintext (Mode 2)." The sections below still reflect the earlier single-keypair design and will be reconciled when Scott + Opus review Layer 3. **Scope**: New `PostVisibility::FoFClosed` variant. Both post body AND comments are gated to the FoF graph. Body is encrypted; readership emerges from keyring intersection with `wrap_slots`. Builds on Layer 2's `pub_post` / `priv_post` / `wrap_slot` primitives — same structures, just that the CEK encrypting the body is *also* in the wrap slots (alongside `priv_post`). --- ## Goal - Author creates a post with `visibility = FoFClosed`. - Body ciphertext in the CDN; only FoF-reachable readers can decrypt. - `wrap_slots` carry both the post CEK and `priv_post`, wrapped under each `V_x` the author holds. - Readers trial-decrypt slots whose 2-byte prefilter tag matches a held key. - Comments use Layer 2's `group_sig` / `vouch_mac` mechanism — unchanged. - Observers cannot enumerate recipients from the header; slot count padded to power-of-2 buckets. --- ## Lead decisions - **New variant, not extended Encrypted.** `PostVisibility::FoFClosed` is its own variant. Existing `Encrypted{recipients}` wraps per-recipient NodeIds — visible on the wire. FoF wraps anonymously under symmetric keys — no NodeIds. - **One wrap slot per unique `V_x`.** Dedup at the `V_x` byte level — if multiple personas hold the same `V_x`, include one slot. Friends-only post: one slot under `V_me`. FoF post: own `V_me` + every distinct `V_x` the author holds. Custom: subset chosen by author (deferred to v2 power-user UI; v1 ships three presets only: Public / Friends-only / Friends-of-Friends). - **Bucketed slot-count padding.** Deterministic bucket boundaries throughout — observers learn the bucket, not the position within it. Buckets: 8, 16, 32, 64, 128, 256 (power-of-2 up to 256), then 384, 512, 640, 768, … (linear +128 steps above 256). Author publishes `next_bucket(real_count)` slots with random dummies filling the gap. Power-of-2 sub-256 keeps small-author overhead bounded; +128 steps above 256 avoid the 2× waste of pure power-of-2 at scale. Dummy slots are byte-identical to real ones, AEAD-fails on any `V_x`. Dummy entries also added to `pub_post_set` 1:1 (see Layer 2). - **Bucketed body-size padding.** Same shape applied to body ciphertext bytes: power-of-2 buckets up to 256KB (1KB, 2KB, 4KB, …, 256KB), then 256KB-step buckets above (512KB, 768KB, 1024KB, …). 256KB above is the future storage chunk size — once large enough to chunk, padding aligns to chunk boundaries naturally. - **Each slot carries both CEK and priv_x (Layer 2 dual-derivation).** Layer 2's `WrapSlot` dual-derivation (read → CEK, sign → priv_x) is the canonical form. Mode 1 simply also uses the CEK to encrypt the body, where Mode 2 leaves the body plaintext. - **Prefilter tag is `HMAC(V_x, post_id)[:2B]`.** Readers precompute a 2-byte tag for each key in their keyring and skip slots that don't match. Cuts trial-decrypt cost by ~2^16 on average. - **Order of slots is randomized.** No positional leak about which slot corresponds to which voucher. Re-shuffled on every header revision (including access-grant appends from Layer 2 — TBD whether append-only ordering is acceptable, or whether the entire set is re-shuffled at each grant). --- ## Data model ### Extend `PostVisibility` ```rust pub enum PostVisibility { Public, Encrypted { recipients: Vec }, GroupEncrypted { group_id: GroupId, epoch: u32, wrapped_cek: Vec }, FoFClosed { // NEW pub_post: [u8; 32], wrap_slots: Vec, }, } ``` ### `WrapSlot` byte layout TBD — OPUS. Target shape: ``` WrapSlot { prefilter_tag: [u8; 2], // HMAC(V_x, post_id)[:2B] nonce: [u8; 12], // AEAD nonce (unique per slot) ciphertext: [u8; N], // AEAD(V_x, nonce, plaintext) — plaintext is CEK || priv_post tag: [u8; 16], // AEAD auth tag } ``` Plaintext inside the slot: ``` SlotPlaintext { cek: [u8; 32], // ChaCha20-Poly1305 key for body priv_post: [u8; 32], // ed25519 seed for per-post keypair } ``` TBD — OPUS: - AEAD choice (ChaCha20-Poly1305 matches existing usage — confirm). - Whether to include `post_id` as AAD to bind slot to post and prevent slot-reuse across posts. - Padding scheme for dummy slots (same size + random bytes that fail AEAD on any key). --- ## Encryption (author, post creation) 1. Generate per-post ephemeral ed25519 keypair `(priv_post, pub_post)`. 2. Generate random 32B CEK. 3. Encrypt body: `body_ct = ChaCha20-Poly1305(CEK, nonce, body || aad=post_id)`. 4. For each `V_x` in selected set (own `V_me` + held vouches for FoF, or custom subset): - `prefilter_tag = HMAC(V_x, post_id)[:2B]` - `slot_ct = AEAD(V_x, slot_nonce, CEK || priv_post, aad=post_id)` - Append `WrapSlot { prefilter_tag, slot_nonce, slot_ct, tag }` to `wrap_slots` 5. Pad `wrap_slots` to next power of 2 with dummy slots. 6. Randomize slot order. 7. Sign post header with author identity key as normal. --- ## Decryption (reader side) 1. Receive post. Parse header → `pub_post`, `wrap_slots`. 2. For each `V_x` in reader's keyring (all personas): - Compute `candidate_tag = HMAC(V_x, post_id)[:2B]`. - For each slot with matching `prefilter_tag`, attempt `AEAD-open(V_x, slot.nonce, slot.ct)`. - On success: extract `CEK`, `priv_post`. Decrypt body with CEK. Record which `(persona, V_x)` won (Layer 5 cache). 3. If no slot unwraps across all personas: mark post as unreadable (Layer 5 retry table). Expected cost without prefilter: `keyring_size × slot_count` AEAD attempts. With 2B prefilter: `keyring_size × slot_count / 65536` AEAD attempts on average. For 500×500 = 250K attempts → ~4 attempts average. Prefilter is load-bearing for feasibility; see Layer 5. --- ## Receive-path integration Extend `control::receive_post` with a new gate branch for `FoFClosed`: - Verify author identity sig over header (as existing). - Verify `wrap_slots` well-formedness: each slot has correct field sizes; slot count is power of 2. - Accept without being able to decrypt. Non-FoF nodes still store and propagate the ciphertext as part of normal CDN replication. Decryption is a READ-side concern. --- ## Ciphertext propagation Ciphertext `FoFClosed` posts ride the same CDN propagation as other encrypted posts. No change to `BlobHeaderDiff` / `file_holders`. Any node can hold and forward the ciphertext; only FoF-graph nodes can decrypt. --- ## Open questions - **Prefilter false-positive cost.** 1/65536 false positive per slot. With 500 slots × reader iterating 500 keys, expected ~3.8 false-positive AEAD attempts per post. Acceptable. - **Prefilter collision on legitimate hits.** Two different `V_x` could produce the same `prefilter_tag` for the same `post_id`. Reader just tries both. No correctness issue. - **Slot-reuse across posts.** If the same `V_x` is used across many posts, an attacker can observe prefilter tags recur. Since `post_id` is in the HMAC input, tags differ per post. No leak. - **Access-grant re-shuffle vs append-only.** When the author publishes a Layer 2 access-grant comment, do we re-shuffle the entire `wrap_slots` + `pub_post_set` (preserves the random-order property for the full set but invalidates `pub_x_index` values in already-propagated comments), or append-only (`pub_x_index` is stable across the post's lifetime, but the newest entries are always at the tail — small positional leak that grants are recent)? Lead leaning: **append-only**; `pub_x_index` stability is load-bearing for revocation and comment verification on already-stored comments. - **Padding floor for small authors.** Power-of-2 padding on 1 real slot → 1, 2, or 4? Power-of-2-of-1 is 1, but that's no padding. Probably enforce a minimum bucket of 4 or 8 so that a brand-new persona with one vouch doesn't publish a singleton. Lead leaning: minimum 8. ## Resolved (2026-04-24) - **Slot count padding**: bucketed throughout. Power-of-2 buckets to 256 (8, 16, 32, 64, 128, 256), then linear +128 buckets (384, 512, 640, …). Body-size padding follows the same shape with 256KB as the power-of-2 ceiling and 256KB linear steps above. - **Custom mode UI**: deferred. v1 ships only the three presets (Public / Friends-only / FoF). Power-user custom-subset UI is v2. - **Slot deduplication**: dedup at the `V_x` byte level. One slot per unique key. - **Body length padding**: yes — pad to next power of 2 up to 256KB, then 256KB chunks above. --- ## Ship criteria for Layer 3 - `PostVisibility::FoFClosed` exists end-to-end. - Author creation path generates per-post keypairs, wraps CEK+priv_x under each unique `V_x` (deduped), and pads to the next slot bucket: power-of-2 up to 256, then +128 steps above. - Body-size padded to next body bucket: power-of-2 up to 256KB, then 256KB steps above. - Reader decryption path iterates personas × keyring with prefilter tag. - `receive_post` accepts FoFClosed ciphertext without decrypting. - UI surface: post composer has three presets — Public / Friends-only / Friends-of-Friends. Custom subset is v2. - Integration test: A posts FoFClosed. B (direct vouchee) reads. C (FoF via B) reads. D (unrelated) gets ciphertext, cannot decrypt. - Performance: decryption completes within budget at 500-key keyring × 500-slot posts (see Layer 5 for the optimization work that makes this budget feasible).