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>
257 lines
15 KiB
Markdown
257 lines
15 KiB
Markdown
# 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)`.** 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.
|
|
|
|
---
|
|
|
|
## 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]>, // 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)
|
|
|
|
```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. 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
|
|
|
|
- **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` 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.
|