itsgoin/docs/fof-spec/layer-3-mode1-fof-closed.md
Scott Reimers 553fbd3a20 docs: Layer 2 — CDN-verified FoF comments (per-V_x keypair)
Replace single per-post priv_post with per-V_x (pub_x, priv_x). Post
header publishes pub_post_set; comments declare pub_x_index; CDN
propagation nodes verify group_sig + identity_sig against named pubkey
before forwarding. Kills bandwidth-amplification DoS from admitted-but-
malicious FoF members.

Dual-derivation wrap slot (read → CEK, sign → priv_x) with shared
structure Layer 3 inherits. Comments encrypted under CEK_comments so
Mode 2 comments are genuinely FoF-read-gated, not just FoF-sign-filtered.

Author-signed revocation diff appended to post; CDN honors per-chain
revocation. Tradeoff: pub_x_index is a per-post voucher-chain pseudonym,
re-randomized across posts. Accepted.

Layer 3 banner added noting wrap-slot structure is now superseded by
Layer 2's canonical form; full reconciliation deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 08:25:40 -04:00

141 lines
7.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 `V_x` in the author's keyring.** For a Friends-only post, one slot under `V_me`. For FoF, N+1 slots (one per `V_x` the author holds + own `V_me`). For Custom, subset chosen by author.
- **Slot count padded to power-of-2.** Prevents observers from counting vouchers the author has. TBD — OPUS: confirm padding up to next power of 2 with random dummy slots (non-decryptable ciphertext indistinguishable from real slots).
- **Each slot carries both CEK and priv_post.** Wrapped together as a single plaintext. One successful unwrap gives reader everything they need to read body + sign comments.
- **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.
---
## 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
- **Slot size uniformity.** Real slots and dummy padding slots must be byte-identical-sized. Confirmed. TBD — OPUS: should we also pad the body length to a bucket to avoid length-based classification?
- **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.
- **Custom mode slot selection.** Does the author UI let them pick specific vouchers, specific groups of vouchers, or only "all held + own" vs "own only"? Lead leaning: initial UI = only the three preset levels (Friends-only / FoF / Public); custom ships as power-user option later.
- **Deduplication of `V_x` across personas.** If multiple personas hold the same `V_x`, do we include one slot or one-per-persona? Lead leaning: dedup at the `V_x` bytes level; one slot per unique key.
---
## Ship criteria for Layer 3
- `PostVisibility::FoFClosed` exists end-to-end.
- Author creation path generates ephemeral keypair, wraps CEK+priv_post under each eligible `V_x`, pads to power-of-2.
- Reader decryption path iterates personas × keyring with prefilter tag.
- `receive_post` accepts FoFClosed ciphertext without decrypting.
- UI surface: post composer has Public / Friends-only / FoF / Custom picker.
- 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).