itsgoin/docs/fof-spec/layer-2-mode2-fof-comments.md
Scott Reimers a79cab049f docs: Layer 2 round 2 — resolve 5 questions + access-grant primitive
Fold in Scott's answers:
- Per-post (pub_x, priv_x); confirmed.
- Random rand(32..=128) dummy padding replaces power-of-2 buckets;
  dummy pubkeys in pub_post_set so .len() == wrap_slots.len(). Floor
  count is unrecoverable across multiple posts.
- Non-FoF UX: "Comments are private" + optional "Request access via
  DM" button. No count leak.
- Author's own (pub_me, priv_me) in pub_post_set; confirmed.
- Revocation is retroactive delete + forward: file-holders delete
  locally-stored comments signed by revoked pub_x on diff arrival,
  then propagate. Stronger than stop-forwarding.

New primitive: access-grant author comment. Author appends a
WrapSlot + pub_post_set entry for a newly-vouched persona via a
signed special comment — retroactive read widening without republish.

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

20 KiB
Raw Blame History

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

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]>,     // real pub_x + dummy pubkeys, random-order; .len() == wrap_slots.len()
    wrap_slots:      Vec<WrapSlot>,     // real slots + rand(32..=128) dummy slots, shuffled
    revocation_list: Vec<RevocationEntry>,  // initially empty; appended via signed diffs
    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. 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)

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