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>
20 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)is per-post. Author generates a fresh(pub_x, priv_x)for each slot when assembling a post, wrapspriv_xin 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_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 retroactive. Revocation diff is author-signed, appends a revoked
pub_xentry. Every file-holder that receives the diff deletes all comments on this post signed by thatpub_xfrom 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. Dummypub_xentries are included inpub_post_set1:1 sopub_post_set.len() == wrap_slots.len(); dummy pubkeys are 32B random bytes (no one holds the matchingpriv_x, sogroup_sigverify 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 itspub_post_setentry) for a newly-vouched persona. No rotation, no republish; retroactive inclusion. See "Access-grant author comment" below. 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]>, // 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)
- 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). - On receiving a revocation diff, every file-holder of the affected post:
- Deletes all locally-stored comments on this post where
pub_x_indexpoints to the revokedpub_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_xat step (2) of the accept rule.
- Deletes all locally-stored comments on this post where
- Idempotent: re-receiving a revocation diff is a no-op (the entry is already present; deletion has already happened).
- 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.
- 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.
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.authorANDidentity_sigvalidates againstpost.author. Nogroup_sig/pub_x_indexrequired — this is an author-signed header extension disguised as a comment. - On accept: append
new_pub_xto localpub_post_set, appendnew_wrap_slotto localwrap_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_slotwith theirV_x→ getsCEK+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 freshpriv_x_new. New persona can comment going forward. - Does revocation apply to access-grant
pub_xentries? 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;
readslot succeeding already proves FoF-admission. - Rate limiting. Operational knob. Per-
commenter_id+ per-pub_x_indexcaps 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_xthat has no comments yet. Fine: future comments under thatpub_xwill 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_setvswrap_slotsalignment: 1:1 with dummy pubkeys (Scott's direction). Both padded together withrand(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_meentry inpub_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_xon arrival of the diff, then propagate.
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, 1:1 withwrap_slots, real + dummy entries.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 (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 bothwrap_slotsandpub_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_indexpointing at a real entry (rejected at first-hop CDN:group_sigfails). D signs junk withpub_x_indexpointing at a dummy (rejected:group_sigfails against a dummy random pubkey). A revokes B'spub_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.