# 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)` is per-post.** Author generates a fresh `(pub_x, priv_x)` for each slot when assembling a post, wraps `priv_x` in that slot's sign-part. No coordination with Layer 1. Per-post rotation (Layer 4) just re-generates all keypairs and re-wraps. Not stable across posts. - **`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 retroactive.** Revocation diff is author-signed, appends a revoked `pub_x` entry. Every file-holder that receives the diff **deletes all comments on this post signed by that `pub_x`** from local storage, then forwards the diff. Comments in flight before the diff arrived get deleted when it catches up. Stronger than "stop forwarding" — prior garbage also goes away. - **Random-count dummy padding, not fixed buckets.** Append `rand(32..=128)` dummy slots (each real-shape, indistinguishable). Observers know a random amount was added; they cannot infer the floor count across multiple posts from the same author. Dummy `pub_x` entries are included in `pub_post_set` 1:1 so `pub_post_set.len() == wrap_slots.len()`; dummy pubkeys are 32B random bytes (no one holds the matching `priv_x`, so `group_sig` verify against a dummy always fails — benign). - **Post-hoc read-access grant via author comment.** Author can widen the read-set of an already-published post by publishing an author-signed special comment that appends a new `WrapSlot` (and its `pub_post_set` entry) for a newly-vouched persona. No rotation, no republish; retroactive inclusion. See "Access-grant author comment" below. - **`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]>, // real pub_x + dummy pubkeys, random-order; .len() == wrap_slots.len() wrap_slots: Vec, // real slots + rand(32..=128) dummy slots, shuffled revocation_list: Vec, // initially empty; appended via signed diffs 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. On receiving a revocation diff, every file-holder of the affected post: - **Deletes** all locally-stored comments on this post where `pub_x_index` points to the revoked `pub_x`. - Appends the entry to its local copy of `post.revocation_list`. - Forwards the revocation diff to neighbors. - Rejects any subsequent incoming comments matching the revoked `pub_x` at step (2) of the accept rule. 4. Idempotent: re-receiving a revocation diff is a no-op (the entry is already present; deletion has already happened). 5. Retroactive: comments that propagated before the diff existed get deleted as the diff catches up to each holder. There's a propagation-latency window where deleted comments may still be visible on yet-to-receive holders, but the garbage is bounded and self-cleaning. 6. 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. --- ## Access-grant author comment (post-hoc widening) When the author vouches for a new persona AFTER publishing a FoF post, they may want that persona to retroactively gain read access. Full republish would rewrite the post ID and lose engagement history. Instead, the author publishes a special access-grant comment. ### `AccessGrantComment` (a distinguished `InlineComment` variant) ```rust struct AccessGrantComment { parent_post_id: PostId, new_pub_x: [u8; 32], new_wrap_slot: WrapSlot, // read + sign parts for the new V_x granted_at_ms: u64, commenter_id: NodeId, // must match post.author identity_sig: [u8; 64], // author identity-key sig over (parent_post_id || new_pub_x || new_wrap_slot || granted_at_ms) } ``` ### Propagation-node handling - Accept iff `commenter_id == post.author` AND `identity_sig` validates against `post.author`. No `group_sig` / `pub_x_index` required — this is an author-signed header extension disguised as a comment. - On accept: append `new_pub_x` to local `pub_post_set`, append `new_wrap_slot` to local `wrap_slots`. Forward the access-grant to neighbors. - Subsequent comments from the newly-admitted persona reference the extended-set index (base_set.len() + grant_index). ### Reader-side handling - New persona receives the access-grant via normal comment propagation. Unwraps `new_wrap_slot` with their `V_x` → gets `CEK` + `priv_x_new`. Can now read the body's comments and author their own. - Existing readers see the access-grant as an informational entry (optional UI: "Author granted access to a new friend"). ### Open question - **Does the UI expose this as a discrete action?** "Share this post with my new vouchee Bob" — yes, natural. Lead leaning: surface as a per-post affordance in author's post-detail view. - **Does the access-grant also carry a signing capability for the new persona?** Yes — the `WrapSlot`'s sign-part wraps a fresh `priv_x_new`. New persona can comment going forward. - **Does revocation apply to access-grant `pub_x` entries?** Yes, uniformly. Author can revoke a post-hoc-granted chain same as an originally-granted chain. --- ## 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 real vouchees + `rand(32..=128)` dummies: header payload is 532–628 slots × ~154B = ~82KB to ~97KB. 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), so cross-post correlation is broken. 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. **Vouch-set size:** random `rand(32..=128)` dummy padding means an observer sees `real_count + rand(32..=128)` and cannot infer the true count. Across multiple posts from the same author, the observer also cannot establish a lower bound — each post's padding is independent, so the minimum observed size across many posts only converges to `real_count + 32`, not to `real_count`. **Non-FoF reader UX:** non-FoF readers see the public body + "Comments are private" affordance. Comment count is not shown (no engagement leak). Optional: a "Request access via DM" button that sends the author a note; author can respond by publishing an access-grant author comment (above) that retroactively admits the requester. --- ## 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. - **Rate limiting.** Operational knob. Per-`commenter_id` + per-`pub_x_index` caps in propagation-node config. Out of spec. - **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. - **Access-grant dedup.** If the author accidentally publishes the same access-grant twice (or two devices race), propagation nodes must handle it idempotently. Keying by `(post_id, new_pub_x)` — duplicate = no-op. - **Revocation of a `pub_x` that has no comments yet.** Fine: future comments under that `pub_x` will fail the accept rule, and the deletion step is a no-op. Confirmed harmless. - **GC of `revocation_list`.** Grows unbounded across a post's lifetime. Realistically capped by vouch-set size. No GC needed in v1. ## Resolved - **`(pub_x, priv_x)` lifecycle**: **per-post** (Scott confirmed). Author regenerates for each post's slot assembly. Matches the anonymous-slot model; no Layer 1 coordination. - **`pub_post_set` vs `wrap_slots` alignment**: **1:1 with dummy pubkeys** (Scott's direction). Both padded together with `rand(32..=128)` dummies so observers can't infer vouch count, and can't infer a floor across multiple posts either. - **Author's own entry**: **yes** (Scott confirmed). Author has their own `pub_me/priv_me` entry in `pub_post_set`; own comments pass the same accept rule. - **Non-FoF comment visibility**: front-end shows "Comments are private" when no key unlocks. Optional "Request access via DM" affordance. Count NOT shown. Author can respond to a request by publishing an access-grant author comment. - **Revocation semantics**: **retroactive delete + forward** (Scott's correction). File-holders delete comments signed by the revoked `pub_x` on arrival of the diff, then propagate. --- ## 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, 1:1 with `wrap_slots`, real + dummy entries. - `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 (retroactive delete + forward). - Access-grant author comment mechanism for post-hoc read widening. - Per-post ephemeral-keypair generation. - Random-count dummy padding `rand(32..=128)` on both `wrap_slots` and `pub_post_set`, shuffled together. - 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` pointing at a real entry (rejected at first-hop CDN: `group_sig` fails). D signs junk with `pub_x_index` pointing at a dummy (rejected: `group_sig` fails against a dummy random pubkey). A revokes B's `pub_x`: B's already-propagated comments are deleted on each file-holder as the diff sweeps through; B's subsequent comments rejected at first hop. A vouches for E after the post is live and publishes access-grant author comment; E can now read + comment retroactively.