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>
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_listthat propagation nodes honor on next sync. - Inner
vouch_macis retained for author-side attribution and intra-circle accountability.
Lead decisions
- Per-
V_xsigning keypair(pub_x, priv_x). Generated whenV_xis generated (Layer 1). Rotated as a bundle whenV_xrotates. pub_post_setis inline in the post header. List of all admittedpub_xfor 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_xpriv_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 inrevocation_list,group_sigvalidates,identity_sigvalidates. Any failure → drop, do not propagate. - Revocation is author-signed append-only diff to the post. Appends a revoked
pub_xentry. Propagation nodes honor on sync. Comments under revokedpub_xstop propagating. vouch_macretained. Inside ciphertext. Enables author render-time and intra-circle attribution complementary to CDN-levelpub_x_indexattribution.- Author has their own
pub_me/priv_me. Treated as one of the entries inpub_post_set. Author signs their own comments through the same path. V_mehanded to vouchees already impliespriv_mehandoff. No — correction:priv_xis wrapped in the per-V_xsign-slot of every post, not handed out at vouch time. Only people who can unwrap the slot (= people holdingV_x) receivepriv_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 overpub_post_setwith 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)
- Commenter fetches parent post. Reads
pub_post_set,wrap_slots,revocation_list. - For each
V_xin reader's keyring, match prefilter tag against slot list. On match, attempt read-slot and sign-slot AEAD-open. - On successful unwrap: now holds
CEK,priv_x. DeriveCEK_comments = HKDF(CEK, ...). - Build comment:
- Encrypt
(body || vouch_mac || parent_comment_id)underCEK_commentswith random nonce. - Sign
(ciphertext || parent_post_id || pub_x_index)withpriv_x→group_sig. - Sign the same tuple with commenter's identity key →
identity_sig. - Determine
pub_x_indexby findingpub_xinpub_post_set(wherepub_x = ed25519_pub(priv_x_seed)).
- Encrypt
- Check
pub_x ∉ revocation_list. If revoked: UI tells commenter they can no longer comment on this post; abort. - 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:
- Valid index:
pub_x_index < pub_post_set.len(). Else drop. - Not revoked:
pub_post_set[pub_x_index] ∉ revocation_list. Else drop. group_sigvalid: ed25519 verifygroup_sigover(ciphertext || parent_post_id || pub_x_index)againstpub_post_set[pub_x_index]. Else drop.identity_sigvalid: ed25519 verifyidentity_sigover the same tuple againstcommenter_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)
- For each visible comment, use
CEK_commentsto decrypt ciphertext. - Verify
vouch_macmatches aV_xthe reader (or author in strict mode) can compute against. Optional render-time check; CDN already validated signatures. - 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
- Author's client detects abuse (via
vouch_macattribution, or repeatedpub_x_indexwith bad behavior). - 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). - Propagation nodes merge
revocation_liston next sync. Subsequent comments under the revokedpub_xfail step (2) of the accept rule. - Comments already propagated remain in storage unless GC'd separately (future work).
- If the attacker's voucher-chain is broadly compromised, author can escalate to full
V_xrotation (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;
readslot succeeding already proves FoF-admission. -
Author's own comments. Author holds
priv_medirectly (generated alongsideV_me+pub_meatV_megenesis — OR regenerated per-post as part of the FoF set assembly — see TBD below).pub_meis one of the entries inpub_post_set. Author comments pass the same accept rule. Strict mode optional. -
When is
(pub_x, priv_x)generated? Two options:- (A) At
V_xgenesis (Layer 1): generated once perV_x, stable across all posts.pub_xpublished via bio-post alongsideV_xwrappers;priv_xheld by holders ofV_x. - (B) Per-post: author generates fresh
(pub_x, priv_x)for each slot when assembling a post, wrapspriv_xin 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.
- (A) At
-
revocation_listpropagation 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_setpadding. Currently sized 1:1 withwrap_slots(padded together). Confirm alignment is correct —pub_x_indexindexes intopub_post_setonly; dummy slots need dummy pubkeys? Or:pub_post_setcarries only real pubkeys,wrap_slotsis padded. A comment'spub_x_indexwould 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_indexcaps 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::FriendsOfFriendsend-to-end (storage, protocol, UI picker).- Dual-derivation wrap slot: read → CEK, sign → priv_x. AEAD with
post_idAAD. pub_post_setinline in header;pub_x_indexon 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'spub_x. C's subsequent comments (which went through B's chain) fail CDN verification and stop propagating.