docs: FoF-gating spec skeleton (hand-off to Opus)

Drafts the Friend-of-Friend post-gating spec with crypto specifics
marked TBD — OPUS for Opus to fill in. Six-layer implementation plan;
each layer independently shippable.

Includes README overview + six layer files:
- Layer 1: V_me vouch primitive (keys, keyring, VouchGrant wire format)
- Layer 2: Mode 2 — public post + FoF-gated comments
- Layer 3: Mode 1 — FoFClosed (encrypted body via wrap_slots + prefilter)
- Layer 4: per-post keypair rotation
- Layer 5: unlock cache + prefilter optimization (perf-critical)
- Layer 6: revocation (stub; likely deferred post-v1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-04-23 23:20:56 -04:00
parent d118daee28
commit 1fdf9a94cc
8 changed files with 867 additions and 0 deletions

View file

@ -0,0 +1,139 @@
# 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; 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).