# 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, // 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.