docs: FoF-gating spec skeleton (hand-off to Opus)

Drafts the Friend-of-Friend post-gating spec with crypto specifics
marked TBD — OPUS for Opus to fill in. Six-layer implementation plan;
each layer independently shippable.

Includes README overview + six layer files:
- Layer 1: V_me vouch primitive (keys, keyring, VouchGrant wire format)
- Layer 2: Mode 2 — public post + FoF-gated comments
- Layer 3: Mode 1 — FoFClosed (encrypted body via wrap_slots + prefilter)
- Layer 4: per-post keypair rotation
- Layer 5: unlock cache + prefilter optimization (perf-critical)
- Layer 6: revocation (stub; likely deferred post-v1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-04-23 23:20:56 -04:00
parent d118daee28
commit 1fdf9a94cc
8 changed files with 867 additions and 0 deletions

View file

@ -0,0 +1,122 @@
# Layer 4 — Per-Post Keypair Rotation
**Scope**: Graceful rotation of `(priv_post, pub_post)` when the author's FoF set changes (new vouches granted, `V_me` rotated, or a vouchee removed). Old comments remain verifiable under the old `pub_post`; new comments require the new `pub_post`.
---
## Goal
- Author can update the FoF set of an existing post without deleting / recreating it.
- A `PostKeyRotation` record, signed by author identity key, carries a new `(priv_post', pub_post')` wrapped under the current keyring.
- Existing comments under old `pub_post` stay cryptographically valid.
- New comments must sign under `priv_post'`.
- Readers who were admitted under the OLD set but not the NEW one retain read access to the body (CEK didn't change) but can no longer produce accepted comments.
---
## Lead decisions
- **Body CEK is NOT rotated.** Only the signing keypair rotates. Rotation's purpose is to narrow (or widen) the *commenter* set going forward, not revoke read access to the body. Read access of already-distributed content is non-recoverable by design — if an author needs that, they delete the post.
- **Rotation is append-only.** Rotation records accumulate; `pub_post_n` is derived from the latest rotation record. Old `pub_post` values are retained for verifying already-posted comments.
- **Rotation is optional.** Simple case is a post with one immutable `pub_post`. Layer 4 adds the escape hatch; most posts never rotate.
- **Author-signed.** Only the post author (identity key) can rotate. Prevents an admitted commenter from rotating others out.
---
## Data model
### `PostKeyRotation` record
```rust
struct PostKeyRotation {
post_id: PostId,
rotation_index: u32, // monotonic; 0 = original keypair (implicit), 1 = first rotation, etc.
new_pub_post: [u8; 32],
new_wrap_slots: Vec<WrapSlot>, // wraps new priv_post under current keyring (same as post creation)
superseded_at: u64, // ms; rotation timestamp
sig: [u8; 64], // author identity-key signature over the above
}
```
Persisted as a sidecar to the post. TBD — OPUS: whether this lives in its own table (`post_key_rotations`) or as a serialized column on the post.
### Comment verification after rotation
Each comment's `group_sig` is verified against a specific `pub_post`. Determination rule:
- If `comment.created_at < rotation_1.superseded_at` → verify against `pub_post_0` (original).
- If `rotation_n.superseded_at ≤ comment.created_at < rotation_{n+1}.superseded_at` → verify against `rotation_n.new_pub_post`.
- If `comment.created_at ≥ latest_rotation.superseded_at` → verify against latest `new_pub_post`.
TBD — OPUS: time-based bucketing requires trusting `comment.created_at`. Alternative: comment carries an explicit `pub_post_index` field pointing at which keypair generation it's signed under. Lead leaning: **explicit index field in comment**, avoids clock-skew ambiguity.
### Extend `InlineComment` (from Layer 2)
```rust
struct InlineComment {
// ... existing fields ...
#[serde(default)]
pub_post_index: u32, // 0 for original keypair, n for rotation n
group_sig: ...,
vouch_mac: ...,
}
```
---
## Rotation flow (author side)
1. Author changes FoF-relevant state (new vouch granted, someone un-vouched, `V_me` rotated).
2. Author decides to re-gate a specific post's comments: UI action "rotate comment keys for this post."
3. Generate new `(priv_post', pub_post')`.
4. Re-wrap `priv_post'` under the author's CURRENT keyring (the same algorithm as initial post creation, Layer 3).
5. Build `PostKeyRotation` record, sign, publish.
6. Rotation record propagates via normal CDN (it's a diff on the post, same mechanism as engagement diffs).
---
## Reader/commenter side
- On receiving a `PostKeyRotation` record, readers store it keyed by `(post_id, rotation_index)`.
- At comment-creation time: look up the **latest** rotation record for the parent post; trial-decrypt the new slots; if success, use `priv_post'` to sign, set `pub_post_index` to latest index.
- At comment-verification time: look up the rotation referenced by `comment.pub_post_index`; verify against that `pub_post`.
If a reader can unwrap `rotation_n.wrap_slots` but NOT `rotation_{n+1}.wrap_slots`, they've been rotated out between n and n+1. They can still READ the body (CEK unchanged) and verify historical comments; they cannot author new comments after rotation n+1.
---
## Propagation
Rotation records are signed post-deltas. They reuse the existing `BlobHeaderDiff` propagation mechanism — the post header gains the rotation's `pub_post` and `wrap_slots`; readers pull updated posts through the normal CDN.
TBD — OPUS: whether rotation records are part of the post's BlobHeader diff (natural fit) or a separate post-referencing record (cleaner separation but more protocol surface).
---
## Edge cases
- **Multiple rotations in short succession.** Monotonic index + explicit `pub_post_index` on comments disambiguates. No clock dependency.
- **Comment authored against stale rotation.** If a commenter creates a comment under rotation n, but by the time it propagates, rotation n+1 exists — the comment is still valid against rotation n. Readers verify against the rotation the comment declares.
- **Attacker forges rotation.** Rotation is signed by author identity key. Forgery == identity key compromise, which is outside FoF scope.
- **Reader never sees rotation record but sees new comments.** Until the rotation record arrives, new comments appear "unverified." Filter-out at render leaves them as pending until rotation arrives. Standard eventually-consistent behavior.
---
## Open questions
- **Rotation on `V_me` rotation.** When a persona rotates their own `V_me`, do we auto-rotate every one of their open FoFClosed posts? Cost is O(posts × eligible_keys). Lead leaning: **no auto-rotation**; user opts in per-post. Posts without rotation continue to accept comments from the old keyring (which still holds the old `V_me`).
- **Garbage-collecting old rotation records.** They're needed to verify historical comments. Never GC'd? Or keep only most recent N? Lead leaning: keep all; historical comments don't re-verify often and the data is cheap.
- **UI for rotation.** "Update who can comment" button on post. Simple. No scheduling / batch rotation UI in v1.
- **Rotation without keyring change.** Could be used to kick out a specific commenter by rotating and manually excluding their winning `V_x`. But winning `V_x` isn't known to the author (wrap slots are anonymous). Practical effect: authors can widen or narrow the whole set, not surgically exclude one person, without additional support.
---
## Ship criteria for Layer 4
- `PostKeyRotation` record type exists end-to-end.
- Author UI action: "Update who can comment on this post."
- Rotation records propagate via CDN.
- Comment signing uses latest rotation's `priv_post`; `pub_post_index` attached.
- Comment verification routes to the correct `pub_post` via index.
- Back-compat: posts without rotation records are handled as `pub_post_index = 0` uniformly.
- Integration test: A posts FoFClosed. B comments (admitted). A vouches for C, rotates. C comments (admitted). Old B-comment still verifies; new B-comment still verifies (B still in keyring); new D-comment (never admitted) rejected.