itsgoin/docs/fof-spec/layer-4-keypair-rotation.md
Scott Reimers 1fdf9a94cc 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>
2026-04-23 23:20:56 -04:00

122 lines
7.2 KiB
Markdown
Raw Permalink 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 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.