itsgoin/docs/fof-spec/layer-3-mode1-fof-closed.md
Scott Reimers 1fdf9a94cc 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>
2026-04-23 23:20:56 -04:00

7.1 KiB
Raw Permalink Blame History

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

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).