itsgoin/docs/fof-spec/layer-2-mode2-fof-comments.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.6 KiB

Layer 2 — Mode 2: Public Posts with FoF-Gated Comments

Scope: Extend CommentPolicy with a FriendsOfFriends variant. Post body is public (indexable, cacheable, shardable via existing CDN). Comments on the post must prove FoF-reachability to the author before being accepted.

This layer ships before Mode 1 because it reuses the existing public-post path and only adds a verification gate on comments. No new PostVisibility variant needed.


Goal

  • An author creates a public post with comment_policy = FriendsOfFriends.
  • Body is encrypted to no one — plaintext in the CDN, same as a normal public post today.
  • Comments on the post include a proof artifact that lets readers (and the author in strict mode) verify the commenter is reachable through the author's FoF graph.
  • Non-FoF readers can still READ the post. They cannot post an accepted comment.

Lead decisions

  • Mode 2 does not gate readership. The post body is genuinely public. Only comments are gated. This is intentional — it preserves CDN shardability for the body and gives authors a lightweight "comments to my circle, body to the world" mode.
  • pub_post is included in the public post header. Every FoF-eligible post (both modes) has a per-post ephemeral ed25519 keypair. pub_post in the header, priv_post in wrap slots (Mode 1) or directly in a control record (Mode 2 — TBD below).
  • Comments carry a group_sig signed with priv_post. Any device that can unwrap priv_post (i.e., holds a matching V_x) can sign. group_sig verification against pub_post is the cryptographic proof of FoF-reachability. Verifiable by any observer holding the public post.
  • Optional vouch_mac for strict mode. HMAC(V_x, post_id || comment_hash) identifies which voucher's chain a commenter holds. Author can run in strict mode and reject comments whose vouch_mac doesn't match a V_x the author has a record of distributing. Non-strict mode accepts any valid group_sig.
  • Comment still signed by commenter's identity key as well. group_sig proves FoF-membership; identity sig binds the comment to a specific persona for display / abuse reporting / author-side blocklist.
  • Non-FoF devices can still render the post. Read path is unchanged — comments missing group_sig (or failing verification) are filtered out in the feed renderer rather than hard-rejected in storage. This leaves forensic traces and is cheaper than re-verifying in the renderer vs at ingest. TBD open question below.

Data model

Extend CommentPolicy

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

Post header additions (for posts with comment_policy = FriendsOfFriends)

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

    // NEW for Mode 2 comment gating:
    pub_post: Option<[u8; 32]>,    // ed25519 public key of per-post ephemeral keypair
    wrap_slots: Vec<WrapSlot>,     // wraps priv_post under each V_x in author's keyring

    // TBD — OPUS: WrapSlot byte layout (same type as Layer 3 uses)
}

Note: Mode 2 posts carry wrap_slots even though the body is public, because commenters need priv_post to sign. The wrap slot set IS the FoF membership definition.

Extend InlineComment

struct InlineComment {
    // ... existing fields ...

    #[serde(default, skip_serializing_if = "Option::is_none")]
    group_sig: Option<[u8; 64]>,   // ed25519 signature over comment_hash, verifies against parent post's pub_post

    #[serde(default, skip_serializing_if = "Option::is_none")]
    vouch_mac: Option<[u8; 16]>,   // HMAC(V_x, post_id || comment_hash), truncated
}

Back-compat: old comments without these fields are treated as "not FoF-signed" — accepted on non-FoF posts, rejected on FoF posts.


Comment creation (author of the comment)

  1. Commenter fetches parent post. Reads pub_post from header.
  2. Commenter iterates their keyring. For each V_x held, computes HMAC(V_x, post_id)[:2B]. Compares against each WrapSlot.prefilter_tag. On match, trial-decrypts the slot to get priv_post.
  3. If any slot unwraps successfully: commenter now holds priv_post. Signs comment_hash to produce group_sig. Computes vouch_mac = HMAC(V_x_winner, post_id || comment_hash). Attaches both to comment.
  4. Publishes comment through normal comment-propagation path.

If NO slot unwraps: commenter is not in the author's FoF set. UI reports "You can't comment on this post." Pure client-side enforcement — no wire attempt.


Comment verification (reader side)

At feed render time, for every comment on a FriendsOfFriends-policy post:

  1. If group_sig is missing → filter out (strict) or show with "unverified" badge (permissive). Lead leaning: filter out.
  2. Verify group_sig against parent post's pub_post over comment_hash. If fail → filter out.
  3. Verify identity sig of comment as normal. If fail → filter out.

At author side (strict mode, optional):

  1. Recompute expected vouch_mac candidates from the set of V_x the author has distributed. If comment.vouch_mac doesn't match any → discard at ingest (don't store, don't propagate).

Propagation

No changes to comment propagation path. Comments still flow via existing BlobHeaderDiff / engagement-diff mechanism. Verification is done at display time (reader) and optionally at ingest (author-strict).


Open questions

  • Filter-out vs reject-at-storage for bad group_sig. Filter-out preserves forensic trail and avoids double-verify cost. Reject-at-storage saves disk and sync bandwidth. Lead leaning: filter-out at render, reject-at-storage only on author-side strict mode.
  • priv_post distribution for Mode 2. In Mode 1, priv_post is inside wrap_slots. In Mode 2, body is plaintext, but commenters still need priv_post. Options: (A) wrap_slots still present in Mode 2 headers, carrying only priv_post; (B) separate comment_priv_key control record distributed out of band. Lead leaning: A (uniform structure with Mode 1).
  • Wrap slot prefilter tag. 2B = 65536 buckets, false-positive 1/65536. For Mode 2 this also defines the commenter-eligibility filter. TBD — OPUS: confirm 2B is enough for realistic keyring sizes.
  • Author's own comments. Author has priv_post directly (generated it). Do they self-vouch-mac against V_me? Leaning: yes, so strict mode uniformly verifies all comments.
  • Displaying vouch path to the author. Mode 2 strict mode knows which V_x a commenter arrived through. Should the author's UI show "comment arrived via your vouch to Alice" or keep it opaque? Lead leaning: opaque by default; optional power-user setting.
  • Rate-limiting / spam. A malicious FoF member can flood comments. vouch_mac identifies the chain, so author can block a chain. Out-of-scope for Layer 2 ship (tracked separately).

Ship criteria for Layer 2

  • CommentPolicy::FriendsOfFriends variant exists end-to-end (storage, protocol, UI picker).
  • Authors can create public posts with FoF-gated comments.
  • pub_post / priv_post / wrap_slots generated on post creation, wrapped under author's full keyring.
  • Commenters: client-side check of FoF eligibility before offering comment box; group_sig + vouch_mac attached on send.
  • Readers: filter-out comments failing group_sig verification.
  • Author strict-mode: optional ingress rejection on unknown vouch_mac.
  • Back-compat: old clients see FoF posts as readable (body is public) but can't comment (missing priv_post); old comments on new posts are filtered at render.
  • Integration test: 3-node FoF chain (A→B→C). A posts Mode 2. B comments (reachable). C comments (reachable via B). D (unrelated) cannot.