itsgoin/docs/fof-spec/layer-3-mode1-fof-closed.md
Scott Reimers 9040d70bf6 docs: correct padding rule — bucketed throughout, not random above 256
Prior round misread Opus's recommendation: I wrote "rand(0..=256) above
256" for slots and "round up to nearest 256KB above 256KB" for body.
Body was right; slots were wrong. Correct rule: bucketed throughout.

Slot buckets: 8, 16, 32, 64, 128, 256 (power-of-2 sub-256), then
384, 512, 640, 768, ... (+128 steps above).

Body buckets: 1KB, 2KB, ..., 256KB (power-of-2 sub-256KB), then 512KB,
768KB, 1024KB, ... (+256KB steps above; aligns with future chunk size).

Stronger privacy than random: observer learns bucket, never position
within it. Stable across posts; no min-over-many-posts floor attack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:47:44 -04:00

149 lines
9.6 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
> **⚠️ 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<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.
- **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).