# Layer 2 — Mode 2: Public Posts with FoF-Gated Comments (CDN-Verified) **Scope**: Extend `CommentPolicy` with a `FriendsOfFriends` variant. Post body is public (indexable, cacheable, shardable via existing CDN). Comments on the post are encrypted, signed under a per-voucher-chain keypair, and **verified at the CDN/propagation layer** before forwarding. This layer also defines the shared wrap-slot + `pub_post_set` structure reused by Layer 3 (Mode 1). The slot design here is the canonical form; Layer 3 inherits it and adds body encryption on top. --- ## Why CDN-level verification Naïve single-keypair design (initial skeleton) had an attacker-in-FoF-set problem: any admitted FoF member can sign junk with the shared `priv_post` and amplify bandwidth across the mesh before the author's render-time filter catches it. `vouch_mac` attribution helps the author trace abuse but doesn't stop propagation — an attacker who refuses to include `vouch_mac` simply produces CDN-valid junk at will. **Fix**: each `V_x` gets its own `(pub_x, priv_x)` keypair. The post header publishes the full `pub_post_set` of all admitted `pub_x`. Comments declare which `pub_x` signed. Propagation nodes verify the signature against the named pubkey before forwarding. Propagation nodes also honor an author-published revocation list, stopping a compromised chain at the CDN. Cost: `pub_x_index` is a per-post pseudonym for the voucher-chain — leaks "these N comments came through the same chain" to observers. Bounded to a single post (new post → new mapping order). Acceptable tradeoff for propagation-level DoS resistance. --- ## Goal - Author creates a public post with `comment_policy = FriendsOfFriends`. - Body is plaintext in the CDN (unchanged public-post path). - Comments are ChaCha20-Poly1305 encrypted under a CEK that only FoF members can unwrap; non-FoF observers cannot read comment bodies at all (stronger than skeleton draft). - Every comment carries `pub_x_index + group_sig + identity_sig`. Propagation nodes verify all three before forwarding. - Author can append to a signed `revocation_list` that propagation nodes honor on next sync. - Inner `vouch_mac` is retained for author-side attribution and intra-circle accountability. --- ## Lead decisions - **Per-`V_x` signing keypair `(pub_x, priv_x)`.** Generated when `V_x` is generated (Layer 1). Rotated as a bundle when `V_x` rotates. - **`pub_post_set` is inline in the post header.** List of all admitted `pub_x` for this post's FoF set, random-order per publish. Comments reference entries by index. - **Dual-derivation wrap slots.** Each slot yields BOTH a shared `CEK` (read) and a per-`V_x` `priv_x` (sign). One wrap slot unwrapped = reader gets both capabilities. - **Comments are encrypted.** `CEK_comments = HKDF(CEK, "comments")`. Non-FoF observers see only ciphertext + signatures on comments. Read-gating is a side effect of the same slot unwrap. - **CDN propagation verifies before forwarding.** Four-check accept rule: valid `pub_x_index`, not in `revocation_list`, `group_sig` validates, `identity_sig` validates. Any failure → drop, do not propagate. - **Revocation is author-signed append-only diff to the post.** Appends a revoked `pub_x` entry. Propagation nodes honor on sync. Comments under revoked `pub_x` stop propagating. - **`vouch_mac` retained.** Inside ciphertext. Enables author render-time and intra-circle attribution complementary to CDN-level `pub_x_index` attribution. - **Author has their own `pub_me/priv_me`.** Treated as one of the entries in `pub_post_set`. Author signs their own comments through the same path. - **`V_me` handed to vouchees already implies `priv_me` handoff.** No — correction: `priv_x` is wrapped in the per-`V_x` sign-slot of every post, not handed out at vouch time. Only people who can unwrap the slot (= people holding `V_x`) receive `priv_x`. This is what makes per-post rotation (Layer 4) a coherent revocation surface. - **v1 ships at Ed25519 sizes with inline `pub_post_set`.** PQ migration (ML-DSA-65 at ~2KB/pubkey) requires Merkle-commit over `pub_post_set` with per-comment inclusion proofs. Design must not preclude — spec shape above is algorithm-agnostic. --- ## Data model ### Extend `CommentPolicy` ```rust pub enum CommentPolicy { Public, FollowersOnly, None, FriendsOfFriends, // NEW — Layer 2 } ``` ### Post header additions (for `comment_policy = FriendsOfFriends`) ```rust struct PostHeader { // ... existing fields ... pub_post_set: Vec<[u8; 32]>, // all admitted pub_x, random-order wrap_slots: Vec, // one per V_x (padded to power-of-2 bucket) revocation_list: Vec, // initially empty; appended via signed diffs slot_count: u32, // padded count (for bucket alignment) author_sig: [u8; 64], // ed25519 sig over the header } ``` ### `WrapSlot` byte layout (shared with Layer 3) ```rust struct WrapSlot { prefilter_tag: [u8; 2], // HMAC(V_x, post_id)[:2B] read_slot: SlotPart, // AEAD under key = KDF(V_x, post_id, "read") sign_slot: SlotPart, // AEAD under key = KDF(V_x, post_id, "sign") } struct SlotPart { nonce: [u8; 12], ciphertext: Vec, // read: 32B CEK; sign: 32B priv_x seed tag: [u8; 16], } ``` Read-slot plaintext: 32B CEK. Sign-slot plaintext: 32B `priv_x` ed25519 seed. AAD for both AEAD invocations: `post_id` (prevents slot-reuse across posts). **TBD — OPUS**: confirm ChaCha20-Poly1305 for slot AEAD; confirm AAD choice. ### `RevocationEntry` ```rust struct RevocationEntry { revoked_pub_x: [u8; 32], // the pub_x to drop from acceptance revoked_at_ms: u64, reason_code: u8, // opaque to CDN; for author-side UI author_sig: [u8; 64], // author identity-key sig over (post_id || revoked_pub_x || revoked_at_ms || reason_code) } ``` ### Extend `InlineComment` ```rust struct InlineComment { // CDN-visible: parent_post_id: PostId, ciphertext: Vec, // AEAD(CEK_comments, nonce, plaintext, aad=parent_post_id) nonce: [u8; 12], aead_tag: [u8; 16], pub_x_index: u32, // index into parent post's pub_post_set group_sig: [u8; 64], // ed25519 under priv_x over (ciphertext || parent_post_id || pub_x_index) commenter_id: NodeId, // commenter's long-term identity pubkey identity_sig: [u8; 64], // ed25519 under commenter's identity key over same tuple // Plaintext inside ciphertext (FoF-readable): // comment_body // vouch_mac: [u8; 16] // HMAC(V_x, post_id || comment_hash)[:16B] // parent_comment_id: Option } ``` Back-compat: older clients / non-FoF posts use the existing `InlineComment` shape unchanged. New fields appear only for comments on FoF-policy posts. ### `CEK_comments` derivation ``` CEK_comments = HKDF-Expand( HKDF-Extract(salt=post_id, ikm=CEK), info = "itsgoin/fof-comments/v1", L = 32 ) ``` **TBD — OPUS**: confirm domain separation. One CEK per post, one derived comments-CEK per post. All comments on the post share the same `CEK_comments` (nonce uniqueness per comment via random nonce). --- ## Comment creation (FoF commenter) 1. Commenter fetches parent post. Reads `pub_post_set`, `wrap_slots`, `revocation_list`. 2. For each `V_x` in reader's keyring, match prefilter tag against slot list. On match, attempt read-slot and sign-slot AEAD-open. 3. On successful unwrap: now holds `CEK`, `priv_x`. Derive `CEK_comments = HKDF(CEK, ...)`. 4. Build comment: - Encrypt `(body || vouch_mac || parent_comment_id)` under `CEK_comments` with random nonce. - Sign `(ciphertext || parent_post_id || pub_x_index)` with `priv_x` → `group_sig`. - Sign the same tuple with commenter's identity key → `identity_sig`. - Determine `pub_x_index` by finding `pub_x` in `pub_post_set` (where `pub_x = ed25519_pub(priv_x_seed)`). 5. Check `pub_x ∉ revocation_list`. If revoked: UI tells commenter they can no longer comment on this post; abort. 6. Publish via normal comment-propagation path. If no slot unwraps: commenter is not in the author's FoF set. Client-side: hide the comment box. --- ## Propagation-node accept rule For every incoming `InlineComment` targeting a FoF-policy post: 1. **Valid index**: `pub_x_index < pub_post_set.len()`. Else drop. 2. **Not revoked**: `pub_post_set[pub_x_index] ∉ revocation_list`. Else drop. 3. **`group_sig` valid**: ed25519 verify `group_sig` over `(ciphertext || parent_post_id || pub_x_index)` against `pub_post_set[pub_x_index]`. Else drop. 4. **`identity_sig` valid**: ed25519 verify `identity_sig` over the same tuple against `commenter_id`. Else drop. On drop: do not store, do not forward. No error response to sender (avoid oracle). Rate-limit per `commenter_id` and per `pub_x_index` (operational knob, not in spec). --- ## Reader side (FoF) 1. For each visible comment, use `CEK_comments` to decrypt ciphertext. 2. Verify `vouch_mac` matches a `V_x` the reader (or author in strict mode) can compute against. Optional render-time check; CDN already validated signatures. 3. Render comment body, parent threading via `parent_comment_id`. Reader side (non-FoF): sees comment ciphertext + signature fields only. Body unreadable. UI can show "N FoF comments (not visible to you)" or suppress entirely. --- ## Revocation flow 1. Author's client detects abuse (via `vouch_mac` attribution, or repeated `pub_x_index` with bad behavior). 2. Author builds `RevocationEntry { revoked_pub_x, timestamp, reason_code, author_sig }`, publishes as a header-diff on the post (same propagation primitive as engagement diffs). 3. Propagation nodes merge `revocation_list` on next sync. Subsequent comments under the revoked `pub_x` fail step (2) of the accept rule. 4. Comments already propagated remain in storage unless GC'd separately (future work). 5. If the attacker's voucher-chain is broadly compromised, author can escalate to full `V_x` rotation (Layer 1), which affects every post using that chain — coarse but definitive. --- ## Size budget **v1 (Ed25519)** | Per-vouchee slot cost | Bytes | |---|---| | `pub_x` | 32 | | `read_slot` (32B CEK + 12B nonce + 16B tag) | 60 | | `sign_slot` (32B priv_x + 12B nonce + 16B tag) | 60 | | `prefilter_tag` | 2 | | **Subtotal per slot** | **~154** | At 500 vouchees: ~77KB header. Padded to 512 bucket ≈ 79KB. Acceptable for a header carried once per post. Per-comment CDN overhead (vs shared-keypair baseline): +64B (`group_sig`) + 4B (`pub_x_index`) + 64B (`identity_sig` — already in baseline) ≈ 68B additional. Negligible. **PQ future (ML-DSA-65)** `pub_x` ~1952B, sig ~3300B. Inline `pub_post_set` at 500 vouchees = ~976KB header. Not viable. Plan: Merkle-commit `pub_post_set`. Header carries 32B root. Each comment carries its `pub_x + inclusion_proof` (~288B at n=500) + the ~3.3KB sig. Header stays O(1); per-comment grows O(log n). Decision: **defer Merkle variant to PQ migration**. v1 ships inline. --- ## Privacy tradeoff (accepted) `pub_x_index` leaks per-post voucher-chain pseudonym to CDN + public observers. "All comments signed under slot 42 came through the same chain" — within a single post. Cross-post, the index-to-chain mapping re-randomizes (new `pub_post_set` order on each publish). The reason this is acceptable: the alternative is no propagation-level verification, which means any one admitted FoF member can DoS the mesh. Per-post pseudonym is the minimum viable disclosure for CDN-level filtering. --- ## Open questions - **Reader-only clients can skip the sign-slot.** They can short-circuit AEAD on sign-slot and save some work. Worth implementing? Lead leaning: yes, minor perf win; `read` slot succeeding already proves FoF-admission. - **Author's own comments.** Author holds `priv_me` directly (generated alongside `V_me` + `pub_me` at `V_me` genesis — OR regenerated per-post as part of the FoF set assembly — see TBD below). `pub_me` is one of the entries in `pub_post_set`. Author comments pass the same accept rule. Strict mode optional. - **When is `(pub_x, priv_x)` generated?** Two options: - (A) At `V_x` genesis (Layer 1): generated once per `V_x`, stable across all posts. `pub_x` published via bio-post alongside `V_x` wrappers; `priv_x` held by holders of `V_x`. - (B) Per-post: author generates fresh `(pub_x, priv_x)` for each slot when assembling a post, wraps `priv_x` in the sign-slot. **(B) is cleaner** because per-post rotation (Layer 4) already re-wraps slots; no coordination with Layer 1 genesis. **(A) is cheaper** because keypair generation is once-per-vouch-grant instead of once-per-post-per-vouchee. Lead leaning: **(B) per-post**, matches the anonymous-slot model and keeps Layer 1 untouched. - **`revocation_list` propagation ordering.** Comments that arrive at a propagation node before the revocation diff arrives will propagate. The revocation applies from its sync-time forward. Acceptable window; not a correctness issue but a propagation-latency consideration. - **Dummy padding slots.** Real slot = prefilter + read + sign ≈ 154B. Dummy must be byte-identical in size. Random bytes that AEAD-fail on any `V_x`. Confirmed indistinguishable structurally. - **`pub_post_set` padding.** Currently sized 1:1 with `wrap_slots` (padded together). Confirm alignment is correct — `pub_x_index` indexes into `pub_post_set` only; dummy slots need dummy pubkeys? Or: `pub_post_set` carries only real pubkeys, `wrap_slots` is padded. A comment's `pub_x_index` would always point at real entries; dummies exist only in wrap_slots to hide the vouch count. Needs resolution. - **Rate limiting.** Operational knob. Per-`commenter_id` + per-`pub_x_index` caps in propagation-node config. Out of spec. - **Non-FoF rendering of comment count.** Do non-FoF readers see "42 FoF comments" or nothing? Privacy: the count leaks engagement. Lead leaning: show count (same as existing Public comment counts; engagement is already a public signal). - **Author's copy of CEK.** Author generated CEK at post creation; does author store it locally keyed by post_id, or unwrap via their own slot like any reader? Lead leaning: local-cache at creation; unwrap-fallback if cache lost. --- ## Ship criteria for Layer 2 - `CommentPolicy::FriendsOfFriends` end-to-end (storage, protocol, UI picker). - Dual-derivation wrap slot: read → CEK, sign → priv_x. AEAD with `post_id` AAD. - `pub_post_set` inline in header; `pub_x_index` on comments. - CEK_comments derived via HKDF; all comment bodies encrypted. - Propagation nodes enforce four-check accept rule before forwarding. - Revocation diff format + CDN honor path. - Per-post ephemeral-keypair generation (option B above, pending decision). - Back-compat: old clients can still render existing non-FoF posts; old comments on new FoF posts are rejected at CDN (missing required fields). - Integration test: 3-node FoF chain (A→B→C). A posts Mode 2 FoF. B comments (accepted, propagates). C comments (accepted via B's chain). D (unrelated) signs junk with fake `pub_x_index` (rejected at first-hop CDN). A revokes B's `pub_x`. C's subsequent comments (which went through B's chain) fail CDN verification and stop propagating.