docs: Layer 2 — CDN-verified FoF comments (per-V_x keypair)

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>
This commit is contained in:
Scott Reimers 2026-04-24 08:25:40 -04:00
parent b8b38a6f03
commit 553fbd3a20
4 changed files with 225 additions and 59 deletions

View file

@ -68,7 +68,9 @@ Build and ship bottom-up. Each layer is independently shippable and exercised be
- **Vouch key (`V_x`)**: a symmetric key owned by persona `x`, distributed by `x` to everyone `x` vouches for. `V_me` refers to the current persona's own vouch key.
- **Keyring**: for a given persona, the set of vouch keys held = `{V_me}` `{V_x : x vouched for me}`.
- **Wrap slot**: an anonymous ciphertext in a post header, carrying the post's private material encrypted under some `V_x`. Readers trial-decrypt slots whose 2-byte prefilter tag matches an owned key.
- **pub_post / priv_post**: per-post ephemeral ed25519 keypair. `priv_post` is wrapped in the post's wrap slots; `pub_post` is in the header plaintext and used for group signature verification on comments.
- **pub_x / priv_x**: per-`V_x` ed25519 keypair (Layer 2). `priv_x` is wrapped in the sign-slot of each `V_x`; `pub_x` is published in the post's `pub_post_set`. Comments reference a `pub_x_index` so propagation nodes can verify the comment signature without unwrapping. Replaces the earlier single `pub_post/priv_post` per-post keypair.
- **pub_post_set**: list of all `pub_x` for a post's FoF set, in randomized order. Inline in post header. Comments reference entries by index.
- **revocation_list**: author-appended signed entries that tell CDN propagation nodes to drop comments under a named `pub_x`. Stops compromised voucher-chains at the propagation layer.
- **Identity key**: persona's long-term ed25519 key, used to sign content as that persona. Distinct from `priv_post`. (In current ItsGoin terms: this is the persona's posting key. Worth naming-alignment review when specifying.)
- **Vouch MAC**: `HMAC(V_x, post_id || comment_hash)` — 16B truncated. Identifies which `V_x` a commenter holds. Inside encrypted payload (Mode 1) or alongside plaintext (Mode 2). Used by author strict-mode to verify the commenter is reachable via a known vouch chain.
@ -79,7 +81,8 @@ Build and ship bottom-up. Each layer is independently shippable and exercised be
- **`Circle` + `GroupEncrypted`** remain as-is for named explicit-membership groups. FoF is a separate visibility class, not a replacement.
- **`PostVisibility`** gains one new variant (`FoFClosed`, from Layer 3). Mode 2 reuses `PostVisibility::Public` with extended `CommentPolicy`.
- **`CommentPolicy`** gains one new variant for Layer 2 (Mode 2 comment gating).
- **`InlineComment`** gets optional `group_sig` + `vouch_mac` fields (back-compat via `#[serde(default)]`, same pattern as Phase 2e `ref_post_id`).
- **`InlineComment`** gets `pub_x_index`, `group_sig`, `identity_sig`, encrypted `ciphertext`, and inner `vouch_mac` (inside the ciphertext). Back-compat via `#[serde(default)]`, same pattern as Phase 2e `ref_post_id`.
- **Propagation-node accept rule** for comments on FoF posts: valid `pub_x_index` + not in `revocation_list` + `group_sig` verifies + `identity_sig` verifies. Any failure → drop without forwarding. Makes bandwidth-amplification DoS infeasible.
- **`control::receive_post`** gets new verify-gate branches for `FoFClosed` posts (author_sig + wrap_slots well-formedness) and FoF comments (group_sig verifies against `pub_post` from the referenced parent post).
- **Multi-persona**: keyrings are per-persona. Unlock attempts iterate personas; the persona that successfully unlocks is recorded and drives comment-authorship defaults. See Layer 3 for detail.
- **Bio post (`VisibilityIntent::Profile`)**: Layer 1 adds an optional `vouch_grants` field carrying an HPKE-sealed per-recipient wrapper batch. Existing bio-post CDN propagation carries vouch distribution — no new control-message type. See Layer 1 for wrapper format and scan policy.

View file

@ -1,28 +1,44 @@
# Layer 2 — Mode 2: Public Posts with FoF-Gated Comments
# 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 must prove FoF-reachability to the author before being accepted.
**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 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.
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
- 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.
- 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
- **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.
- **Per-`V_x` signing keypair `(pub_x, priv_x)`.** Generated when `V_x` is generated (Layer 1). Rotated as a bundle when `V_x` rotates.
- **`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 author-signed append-only diff to the post.** Appends a revoked `pub_x` entry. Propagation nodes honor on sync. Comments under revoked `pub_x` stop propagating.
- **`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.
---
@ -39,89 +55,203 @@ pub enum CommentPolicy {
}
```
### Post header additions (for posts with `comment_policy = FriendsOfFriends`)
### Post header additions (for `comment_policy = FriendsOfFriends`)
```rust
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)
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
}
```
**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.
### `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 {
// ... existing fields ...
// 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
#[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
// 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: old comments without these fields are treated as "not FoF-signed" — accepted on non-FoF posts, rejected on FoF posts.
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 (author of the comment)
## Comment creation (FoF commenter)
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.
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. UI reports "You can't comment on this post." Pure client-side enforcement — no wire attempt.
If no slot unwraps: commenter is not in the author's FoF set. Client-side: hide the comment box.
---
## Comment verification (reader side)
## Propagation-node accept rule
At feed render time, for every comment on a `FriendsOfFriends`-policy post:
For every incoming `InlineComment` targeting a FoF-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.
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.
At author side (strict mode, optional):
On drop: do not store, do not forward. No error response to sender (avoid oracle).
4. 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).
Rate-limit per `commenter_id` and per `pub_x_index` (operational knob, not in spec).
---
## Propagation
## Reader side (FoF)
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).
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. Propagation nodes merge `revocation_list` on next sync. Subsequent comments under the revoked `pub_x` fail step (2) of the accept rule.
4. Comments already propagated remain in storage unless GC'd separately (future work).
5. 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.
---
## 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
- **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).
- **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.
- **Author's own comments.** Author holds `priv_me` directly (generated alongside `V_me` + `pub_me` at `V_me` genesis — OR regenerated per-post as part of the FoF set assembly — see TBD below). `pub_me` is one of the entries in `pub_post_set`. Author comments pass the same accept rule. Strict mode optional.
- **When is `(pub_x, priv_x)` generated?** Two options:
- (A) At `V_x` genesis (Layer 1): generated once per `V_x`, stable across all posts. `pub_x` published via bio-post alongside `V_x` wrappers; `priv_x` held by holders of `V_x`.
- (B) Per-post: author generates fresh `(pub_x, priv_x)` for each slot when assembling a post, wraps `priv_x` in 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.
- **`revocation_list` propagation 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_set` padding.** Currently sized 1:1 with `wrap_slots` (padded together). Confirm alignment is correct — `pub_x_index` indexes into `pub_post_set` only; dummy slots need dummy pubkeys? Or: `pub_post_set` carries only real pubkeys, `wrap_slots` is padded. A comment's `pub_x_index` would 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_index` caps 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::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.
- `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; `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.
- 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's `pub_x`. C's subsequent comments (which went through B's chain) fail CDN verification and stop propagating.

View file

@ -1,5 +1,7 @@
# Layer 3 — Mode 1: `FOF_CLOSED` Posts
> **⚠️ Partially superseded by Layer 2 rewrite (2026-04-24).** Layer 2 now defines the canonical wrap-slot structure (dual read/sign derivation), `pub_post_set`, per-`V_x` signing keypair, and CDN-level verification. Layer 3 inherits all of that unchanged — the Mode 1 vs Mode 2 distinction reduces to "body encrypted under CEK (Mode 1) vs body plaintext (Mode 2)." The sections below still reflect the earlier single-keypair design and will be reconciled when Scott + Opus review Layer 3.
**Scope**: New `PostVisibility::FoFClosed` variant. Both post body AND comments are gated to the FoF graph. Body is encrypted; readership emerges from keyring intersection with `wrap_slots`.
Builds on Layer 2's `pub_post` / `priv_post` / `wrap_slot` primitives — same structures, just that the CEK encrypting the body is *also* in the wrap slots (alongside `priv_post`).

View file

@ -32,7 +32,38 @@ See `CONTRIBUTING.md` for the protocol. See `AGENTS.md` for the Claude-specific
- Opus confirmation passes still open on other layers (WrapSlot byte layout, AEAD choice for body, padding schemes).
- Layer 26 untouched in this pass.
**Stopping point**: merged to master; branch deleted.
**Stopping point**: Scott asked to hold merges until Layers 26 iterations complete. Branch stays open locally and on Forgejo; continuing to stack commits on it.
### Update 2026-04-24 — Layer 2 rewrite (CDN-level verification)
**Scope**: Scott shared Opus's Layer 2 design answer. Folded in.
**Design commitments added**:
- **Per-`V_x` signing keypair `(pub_x, priv_x)`** — replaces single per-post `pub_post/priv_post`. CDN can now verify comment signatures against a published `pub_post_set` before forwarding, killing the bandwidth-amplification DoS an admitted FoF member could otherwise mount.
- **Dual-derivation wrap slot**: `read_slot → CEK`, `sign_slot → priv_x`. One unwrap yields both capabilities. Slot structure is shared with Layer 3 (canonical form lives here).
- **Comment body encrypted under `CEK_comments = HKDF(CEK, "comments")`** — Mode 2 comments are genuinely FoF-read-gated now, not just FoF-sign-filtered at render (strengthening vs skeleton).
- **Propagation-node four-check accept rule**: valid `pub_x_index`, not in `revocation_list`, `group_sig` verifies, `identity_sig` verifies. Any fail → drop without forwarding.
- **Author-signed revocation diff** appended to post header; CDN honors on next sync. Per-chain revocation at propagation layer.
- **`pub_x_index` is a per-post pseudonym** — leaks "these N comments came through the same chain" within a single post; re-randomizes across posts. Accepted tradeoff for CDN-level DoS resistance.
- **v1 ships Ed25519 inline** (~77KB header at 500 vouchees). **PQ future** requires Merkle-commit over `pub_post_set` with per-comment inclusion proofs; deferred but spec shape doesn't preclude.
**Files touched**:
- `docs/fof-spec/layer-2-mode2-fof-comments.md` — rewritten end-to-end.
- `docs/fof-spec/layer-3-mode1-fof-closed.md` — prominent "partially superseded" banner added; body retained pending reconciliation when Scott + Opus review Layer 3.
- `docs/fof-spec/README.md` — glossary updated (`pub_x`/`priv_x`, `pub_post_set`, `revocation_list`); integration bullet updated for new `InlineComment` fields + CDN accept rule.
**Open questions I raised back to Scott** (awaiting his answer before finalizing):
1. `(pub_x, priv_x)` lifecycle: generated at `V_x` genesis (Layer 1) and stable across posts, vs regenerated per-post by author. Lead leaning per-post. Needs confirmation.
2. `pub_post_set` padding vs `wrap_slots` padding — real/dummy alignment when dummies shouldn't be indexable by `pub_x_index`.
3. Non-FoF rendering of comment count (reveal engagement? suppress?).
4. Who holds `priv_me` (author) — generated alongside `V_me` at Layer 1, vs per-post regeneration. Same as #1 but for author's own entry.
**Pending**:
- Scott reviews / answers open questions.
- Layer 3 reconciliation when Scott + Opus get to Mode 1.
- Layers 46 iterations.
**Stopping point**: commit `b8b38a6` (Layer 1) + new commit for Layer 2 both on branch; not merged. Awaiting Scott.
---