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>
9.3 KiB
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 (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_slotscarry both the post CEK andpriv_post, wrapped under eachV_xthe author holds.- Readers trial-decrypt slots whose 2-byte prefilter tag matches a held key.
- Comments use Layer 2's
group_sig/vouch_macmechanism — unchanged. - Observers cannot enumerate recipients from the header; slot count padded to power-of-2 buckets.
Lead decisions
- New variant, not extended Encrypted.
PostVisibility::FoFClosedis its own variant. ExistingEncrypted{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 theV_xbyte level — if multiple personas hold the sameV_x, include one slot. Friends-only post: one slot underV_me. FoF post: ownV_me+ every distinctV_xthe 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 anyV_x. Dummy entries also added topub_post_set1: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
WrapSlotdual-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
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_idas 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)
- Generate per-post ephemeral ed25519 keypair
(priv_post, pub_post). - Generate random 32B CEK.
- Encrypt body:
body_ct = ChaCha20-Poly1305(CEK, nonce, body || aad=post_id). - For each
V_xin selected set (ownV_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 }towrap_slots
- Pad
wrap_slotsto next power of 2 with dummy slots. - Randomize slot order.
- Sign post header with author identity key as normal.
Decryption (reader side)
- Receive post. Parse header →
pub_post,wrap_slots. - For each
V_xin reader's keyring (all personas):- Compute
candidate_tag = HMAC(V_x, post_id)[:2B]. - For each slot with matching
prefilter_tag, attemptAEAD-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).
- Compute
- 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_slotswell-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_xcould produce the sameprefilter_tagfor the samepost_id. Reader just tries both. No correctness issue. - Slot-reuse across posts. If the same
V_xis used across many posts, an attacker can observe prefilter tags recur. Sincepost_idis 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_indexvalues 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_xbyte 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::FoFClosedexists 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_postaccepts 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).