itsgoin/docs/fof-spec/layer-2-mode2-fof-comments.md
Scott Reimers 553fbd3a20 docs: Layer 2 — CDN-verified FoF comments (per-V_x keypair)
Replace single per-post priv_post with per-V_x (pub_x, priv_x). Post
header publishes pub_post_set; comments declare pub_x_index; CDN
propagation nodes verify group_sig + identity_sig against named pubkey
before forwarding. Kills bandwidth-amplification DoS from admitted-but-
malicious FoF members.

Dual-derivation wrap slot (read → CEK, sign → priv_x) with shared
structure Layer 3 inherits. Comments encrypted under CEK_comments so
Mode 2 comments are genuinely FoF-read-gated, not just FoF-sign-filtered.

Author-signed revocation diff appended to post; CDN honors per-chain
revocation. Tradeoff: pub_x_index is a per-post voucher-chain pseudonym,
re-randomized across posts. Accepted.

Layer 3 banner added noting wrap-slot structure is now superseded by
Layer 2's canonical form; full reconciliation deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 08:25:40 -04:00

15 KiB

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

pub enum CommentPolicy {
    Public,
    FollowersOnly,
    None,
    FriendsOfFriends,   // NEW — Layer 2
}

Post header additions (for comment_policy = FriendsOfFriends)

struct PostHeader {
    // ... existing fields ...

    pub_post_set:    Vec<[u8; 32]>,     // all admitted pub_x, random-order
    wrap_slots:      Vec<WrapSlot>,     // one per V_x (padded to power-of-2 bucket)
    revocation_list: Vec<RevocationEntry>,  // 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)

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<u8>,                // 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

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

struct InlineComment {
    // CDN-visible:
    parent_post_id:  PostId,
    ciphertext:      Vec<u8>,           // 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<CommentId>
}

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