itsgoin/docs/fof-spec/layer-3-mode1-fof-closed.md
Scott Reimers 73b1e24f9a docs: spec cleanup — Layer 5 wording, Layer 3 banner, Layer 6 superseded
Layer 5: replace two priv_post references in author-direct fast path
with the correct per-V_x CEK + priv_x lookup. Cache/prefilter logic
unchanged.

Layer 3: replace the "partially superseded" warning banner with a
plain scope note explaining the Mode 1/Mode 2 distinction reduces to
"body encrypted vs body plaintext"; wrap-slot canonical form lives in
Layer 2.

Layer 6: mark as superseded by Layer 4. README updated to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:10:43 -04:00

148 lines
9.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Layer 3 — Mode 1: `FOF_CLOSED` Posts
**Scope**: New `PostVisibility::FoFClosed` variant. Both post body AND comments are gated to the FoF graph. Body is encrypted under CEK; readership emerges from keyring intersection with `wrap_slots`.
The wrap-slot structure, `pub_post_set`, per-`V_x` signing keypair, and CDN-level verification are all defined in [Layer 2](layer-2-mode2-fof-comments.md) (the canonical form). Layer 3 inherits Layer 2's structures unchanged — the Mode 1 vs Mode 2 distinction reduces to: **body encrypted under CEK (Mode 1) vs body plaintext (Mode 2)**. Wrap-slot read-part still carries the CEK in both modes; in Mode 2 the CEK is used only to derive `CEK_comments`, in Mode 1 it is used for both body and comments.
The legacy `pub_post`/`priv_post` field names that appear in some sections below are retained for readability; semantically read them as the canonical per-V_x `(pub_x, priv_x)` from Layer 2.
---
## 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 from a minimum of 8 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. Minimum bucket of 8 means a brand-new persona's first post still publishes 8 slots — no "this persona has no vouchees" signal. 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<NodeId> },
GroupEncrypted { group_id: GroupId, epoch: u32, wrapped_cek: Vec<u8> },
FoFClosed { // NEW
pub_post: [u8; 32],
wrap_slots: Vec<WrapSlot>,
},
}
```
### `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.
## Resolved (2026-04-24)
- **Slot count padding**: bucketed throughout. Power-of-2 buckets from 8 to 256 (minimum bucket is 8 — singleton/tiny-set posts pad up to 8 to avoid leaking "new persona with no vouchees"), 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.
- **Access-grant ordering**: **append at tail** (Scott's call). New entries land at the end of `pub_post_set` + `wrap_slots`. `pub_x_index` values in already-stored comments stay valid. Small positional-recency leak (tail = recent grants) is the accepted cost.
- **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).