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

307 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`
```rust
pub enum CommentPolicy {
Public,
FollowersOnly,
None,
FriendsOfFriends, // NEW — Layer 2
}
```
### Post header additions (for `comment_policy = FriendsOfFriends`)
```rust
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)
```rust
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`
```rust
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`
```rust
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_x``group_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)
```rust
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.