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>
307 lines
20 KiB
Markdown
307 lines
20 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)` 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 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; `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.
|