itsgoin/docs/fof-spec/layer-4-keypair-rotation.md
Scott Reimers 971766cb3c docs: Layer 4 — rotation, revocation, key lifecycle
Captures the decisions from the Layer 4 conversation with Scott:

Default narrowing on a single post = Layer 2 revocation (existing).
Advanced narrowing of read access = full re-issue with optional
supersedes_post_id link (network-heavy, opt-in).

V_me rotation = the persona-wide revocation primitive. Generate new
V_me, distribute to non-revoked vouchees via next bio-post batch.
Receiver-chain model: receivers append new V_me alongside old (not
overwrite). Trial-unwrap iterates the chain.

Grandfather by default: CDN is V_me-blind, so rotation does NOT
auto-cascade comment deletions. Revoked vouchee retains comment
authority on old posts unless author opts to cascade per-pub_x
revocations.

Per-post cascade is opt-in. Local-only own_post_slot_provenance
table lets author query "which pub_x's in my posts were sealed
under V_me_old?" and publish per-pub_x RevocationEntries.

New optional KeyBurnDiff primitive (signed header-diff) swaps a
V_me_old wrap_slot for a V_me_new one in-place on a specific post.
For the leaked-V_me scenario. Body CEK unchanged.

Skeleton's PostKeyRotation record removed entirely.

Layer 1 updated: rotation is append-only at receivers; pointer to
Layer 4. Multi-epoch bio-post-batch toggle hook added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:07:04 -04:00

144 lines
12 KiB
Markdown
Raw 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 — Rotation, Revocation, and Key Lifecycle
**Scope**: How an author narrows access on a published post, and how a persona rotates their own `V_me`. Most of the mechanism already exists in Layers 12. Layer 4's job is to lock in the policy and add the local-only provenance table that makes selective cascades possible.
---
## Goal
- An author can narrow comment authority on a published post via Layer 2 revocation (default, cheap).
- An author can narrow read access by republishing the post with a new key and narrower wrap_slots (advanced, network-heavy, opt-in per-post).
- A persona rotates `V_me` to remove a vouchee. The new `V_me` is issued to all non-revoked vouchees via the next bio-post wrapper batch. The revoked vouchee retains the old `V_me`.
- Old posts (sealed under any era of `V_me`) stay readable by their original audience — no automatic rewrite. The CDN does not auto-cascade comment deletions on rotation.
- Authors retain *per-post discretion* to cascade comment revocations onto old posts when they want to, by publishing per-pub_x revocation diffs against pub_x's they know were sealed under the old `V_me`.
- An optional **key-burn** primitive lets an author scrub a specific `V_me` from a specific post's wrap_slots in-place, for the narrow case of a leaked `V_me`.
---
## Lead decisions
- **Default = Layer 2 revocation.** Narrowing comment authority on a single published post: author signs a `RevocationEntry` for a specific `pub_x`, propagation nodes delete locally-stored comments by that signer, remove the entry from `pub_post_set`, and forward. No new wire primitive.
- **Advanced = full re-issue.** Author publishes a fresh post with new `(CEK, pub_post_set, wrap_slots)` under a narrower V_x set, optionally with a `supersedes_post_id` link for engagement continuity. Old post stays at its old `post_id` or is locally deleted. Heavy: re-encrypts body, rebuilds engagement context, costs bandwidth. Used only when narrowing READ access matters more than retaining the post in its current form.
- **`V_me` rotation IS the persona-wide revocation primitive.** No separate "kill V_me" wire message. To remove a vouchee, the persona generates a new `V_me_new` and distributes it to every current vouchee except the revoked one via the next bio-post batch (Layer 1 mechanism, unchanged). The revoked vouchee's only key remains `V_me_old`.
- **Receiver keeps the chain (Option A).** When a persona receives `V_B_new` from Party B, they append it to `vouch_keys_received` rather than overwrite. They now hold `{V_B_old, V_B_new}` (and any earlier epochs). On any wrap_slot unwrap attempt, the client trials every epoch in the chain. UX: the "current" key for outgoing operations is the latest received; older epochs are archived but kept for reading historical content.
- **Old posts are grandfathered by default.** `V_me` rotation does NOT automatically trigger CDN comment deletion. CDN's revocation primitive operates on `pub_x`, not `V_me` — and CDN is V_me-blind. After rotation, the revoked vouchee retains the old `V_me`, retains read access to posts with V_me_old slots, and retains comment authority on those posts unless the author explicitly publishes per-pub_x revocations.
- **Per-post cascade is opt-in by the author.** The author can choose to cascade a V_me rotation onto a specific old post by publishing a `RevocationEntry` for the pub_x's that were sealed under the old V_me. The author knows this mapping locally (see `own_post_slot_provenance` below); the CDN does not. Cascade can be all posts (batch action) or a chosen subset.
- **Key burn is optional, narrow scope.** For the case where `V_me_old` has *leaked* (not just rotated for turnover), the author may publish a signed header-diff on a specific post that swaps the V_old wrap_slot for a V_new wrap_slot in-place. Same propagation primitive as revocation and access-grant. Scrubs V_old from the CDN copy of that post. Future observers acquiring leaked V_old can no longer fresh-decrypt the body. Locally-cached plaintext on existing readers' devices is unrecoverable by any wire mechanism (out of scope).
- **`vouch_keys_own` retains multi-epoch rows.** Old `V_me` epochs are never deleted automatically. May be deleted by explicit user action with a prominent warning ("this prevents future re-keys / cascades on any post sealed under this epoch").
---
## What goes away from the original skeleton
The skeleton's `PostKeyRotation` record (with `rotation_index`, `pub_post_index` on comments, time-bucketed signature verification) is removed entirely. Its job is done by:
- Layer 2's `RevocationEntry` for the comment-narrowing case.
- Standard post-publish + optional `supersedes_post_id` for the re-issue case.
- In-place wrap_slot swap as a new, optional key-burn diff (below).
What goes away:
- `PostKeyRotation` record type.
- `rotation_index` / `pub_post_index` field on comments.
- Time-bucketed cross-rotation signature verification.
What stays:
- `RevocationEntry` from Layer 2.
- Standard post-publish path.
- `vouch_keys_own` and `vouch_keys_received` from Layer 1, both with multi-epoch retention.
---
## Data model additions
### `own_post_slot_provenance` (local only, never on wire)
The author needs to know which slot in each of their posts was sealed under which V_x, so they can selectively cascade a V_me rotation onto old posts. This is author-local state, not transmitted.
```
own_post_slot_provenance(
author_persona_id BLOB,
post_id BLOB,
slot_index INTEGER, -- index into pub_post_set / wrap_slots
sealed_under_v_x_owner BLOB, -- which persona issued the V_x used (== author_persona_id for the author's own V_me slots)
sealed_under_v_x_epoch INTEGER,
pub_x BLOB(32), -- the pub_x in the post's pub_post_set for this slot
PRIMARY KEY (author_persona_id, post_id, slot_index)
)
```
Populated at post-publish time. Used at cascade time: `SELECT pub_x FROM own_post_slot_provenance WHERE author_persona_id = ? AND sealed_under_v_x_owner = ? AND sealed_under_v_x_epoch = ?` returns the pub_x list to revoke.
### Optional `supersedes_post_id` field on `PostHeader`
```rust
struct PostHeader {
// ... existing fields ...
#[serde(default, skip_serializing_if = "Option::is_none")]
supersedes_post_id: Option<PostId>,
}
```
Used by the advanced re-issue path. Author identity sig covers it. Readers may display "this is a re-issued version of an earlier post."
### `KeyBurnDiff` (header-diff type)
```rust
struct KeyBurnDiff {
post_id: PostId,
slot_index: u32, // which slot to swap
new_wrap_slot: WrapSlot, // sealed under V_x_new (typically author's V_me_new)
new_pub_x: [u8; 32], // corresponding pub_x for pub_post_set replacement
sealed_at_ms: u64,
author_sig: [u8; 64], // author identity-key sig over the above + parent_post_id
}
```
Propagation: same path as revocation diffs. File-holders apply by replacing `wrap_slots[slot_index]` and `pub_post_set[slot_index]` in their local copy of the post header. Forward to neighbors. Idempotent (re-applying with the same slot_index + same new pub_x is a no-op).
---
## Author UX surfaces
- **"Remove this commenter from this post"** → `RevocationEntry` for that specific `pub_x`. Standard Layer 2 path.
- **"Rotate my vouch key"** (Settings) → generates a new `V_me` epoch in `vouch_keys_own`, sets current. Old epoch retained. Then offers: "Issue the new key to your existing vouchees?" → if yes, queues a fresh bio-post batch wrapping V_me_new for all current targets except those marked revoked. Standard Layer 1 mechanism.
- **"Cascade this rotation onto my old posts"** (offered after a rotation if it was triggered by a revoke action) → batch action that queries `own_post_slot_provenance` for the rotated-out epoch and publishes per-pub_x revocations on each affected post. Costs N RevocationEntries; can be done in background. Optional. Per-post selection allowed.
- **"Re-issue this post with narrower access"** (advanced) → opens compose with body pre-filled and `supersedes_post_id` set. New audience pickable. Old post optionally deleted on publish.
- **"Burn this leaked key from a post"** (rare) → publishes a `KeyBurnDiff` swapping the V_me_old slot for a V_me_new slot on a specific post. Offered when the user marks `V_me_old` as leaked. Can be batched across all the author's posts via the provenance table.
---
## Cascade decision tree (for the author)
| Scenario | Default action | Optional escalation |
|---|---|---|
| Vouchee unfollowed, casual cleanup | Rotate V_me. Old posts grandfathered. | None. |
| Vouchee misbehaving on one specific post | Layer 2 revocation on that post's `pub_x`. | None. |
| Vouchee misbehaving across many posts | Rotate V_me. Cascade revocations onto every post they ever had access to. | Optional follow-up: key-burn if their continued read access is unacceptable. |
| V_me_old leaked | Rotate V_me. Cascade revocations onto all affected posts. | Key-burn V_me_old slots on every affected post. |
---
## Open questions
- **Bio-post batch contents during rotation.** Default: wrap only the new V_me epoch in the next batch. Advanced UI option: wrap multiple epochs for vouchees who lost their device and need to re-bootstrap. Lead leaning: default to new-only; advanced toggle for multi-epoch.
- **`supersedes_post_id` binding strength.** The post-header `author_sig` already covers the field. Sufficient, or do we want a reciprocal "this post has been superseded" diff on the old post for symmetric discoverability? Lead leaning: one-way link is sufficient; old post being deleted or not is independent.
- **Key-burn vs. body re-encryption.** Key-burn swaps wrap_slots but keeps the body ciphertext (still encrypted under the same CEK). A reader who unwrapped via V_me_old still has CEK cached locally; their local plaintext copy is unaffected. Key-burn only prevents *fresh* decryption of the wire ciphertext. Is that sufficient, or should key-burn also imply CEK rotation? CEK rotation = re-encrypting the body = essentially full re-issue. Lead leaning: key-burn does NOT rotate CEK; it's specifically the "scrub V_old from headers" primitive. Full re-issue remains available if CEK rotation is wanted.
- **`own_post_slot_provenance` export.** Lost on device wipe means the author can't cascade-revoke after a fresh install. Lead leaning: include in identity export bundle.
- **Garbage-collecting `vouch_keys_own` ancient epochs.** Never auto-GC. User-explicit only, with warning.
---
## Ship criteria for Layer 4
- `vouch_keys_own` retains multi-epoch rows without auto-deletion on rotation.
- `vouch_keys_received` retains multi-epoch rows; trial-unwrap iterates the chain per voucher.
- `own_post_slot_provenance` table populated at every post-publish.
- Author UI: "Rotate my vouch key" with optional follow-up "Issue to existing vouchees."
- Author UI: "Cascade revocations onto my old posts" as a post-rotation action.
- Author UI: "Re-issue this post with narrower access" (advanced).
- Author UI: "Burn leaked key" as a rare/explicit action.
- `KeyBurnDiff` propagation: same path as revocation diffs; idempotent application.
- `supersedes_post_id` field on `PostHeader` is wire-defined and back-compat (default None).
- No `PostKeyRotation` record exists.
- Integration test: A vouches for B and C. A posts FoF post P sealed under V_a (and other V_x's). B and C read + comment. A rotates V_a → V_a' to remove C; issues V_a' to B only. C still holds V_a; A's new posts (sealed only under V_a') invisible to C, visible to B. C can still read P (V_a-sealed slot still in P's wrap_slots); A optionally cascades a revocation on P that removes C's pub_x and deletes C's comments on P. A optionally key-burns V_a from P, swapping the V_a slot for a V_a' slot — C can no longer fresh-decrypt P from the wire (already-cached plaintext on C's device unaffected, out of scope).