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

12 KiB
Raw Blame History

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

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)

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).