diff --git a/docs/fof-spec/layer-1-vouch-primitive.md b/docs/fof-spec/layer-1-vouch-primitive.md index 7556f70..5d87ebe 100644 --- a/docs/fof-spec/layer-1-vouch-primitive.md +++ b/docs/fof-spec/layer-1-vouch-primitive.md @@ -30,7 +30,7 @@ After Layer 1 ships: - **Wrapper order shuffled on every publish.** Prevents positional inference of which vouchee slot changed between bio-post revisions. - **Epoch tag is part of the key.** Each `V_x` has associated `(owner_id, epoch)` so receivers can distinguish fresh from stale when the voucher rotates. - **Keyring is per-persona, not per-device.** Multi-persona users have independent keyrings. Layer 3 reader logic iterates personas when trial-decrypting. -- **Rotation is coarse.** Revocation = rotate `V_me`, republish bio post with wrappers for the remaining vouchees. See Layer 6 for design discussion. +- **`V_me` rotation IS the persona-wide revocation primitive.** To remove a vouchee, generate `V_me_new` and distribute via the next bio-post batch to every current vouchee EXCEPT the revoked one. The revoked person retains `V_me_old`. Old posts sealed under `V_me_old` stay accessible to anyone who still holds `V_me_old` (grandfathered by default). See [Layer 4](layer-4-keypair-rotation.md) for the full lifecycle, optional cascade, and key-burn primitive. --- @@ -207,7 +207,7 @@ Minimum viable surface for Layer 1 ship: - **Persona screen**: "Vouch for someone" action. Picker of contacts. Adds their persona to `own_vouch_targets`; republishes bio post with new batch on save. - **Persona screen**: "Who has vouched for me" list (reads `vouch_keys_received` grouped by `owner_id`). - **Persona screen**: "People I've vouched for" list (reads `own_vouch_targets` where `current = 1`). -- **Settings**: "Rotate my vouch key" → generates new `V_me` epoch, republishes bio post with wrappers under the new key for every current target. +- **Settings**: "Rotate my vouch key" → generates new `V_me` epoch in `vouch_keys_own` (prior epoch retained, marked `is_current = 0`). Optionally offers to issue the new key to existing vouchees minus any marked-revoked. Defaults to single-epoch bio-post batch; advanced multi-epoch toggle available for cases where vouchees on device-wipe need multi-epoch re-bootstrap. See [Layer 4](layer-4-keypair-rotation.md). - **Post detail**: manual "Check this person's bio for a vouch for me" button (non-followed author case). Layer 1 ships without any post/comment behavior change. Vouches are visible in UI but don't gate content yet. diff --git a/docs/fof-spec/layer-4-keypair-rotation.md b/docs/fof-spec/layer-4-keypair-rotation.md index 68f5339..7399487 100644 --- a/docs/fof-spec/layer-4-keypair-rotation.md +++ b/docs/fof-spec/layer-4-keypair-rotation.md @@ -1,122 +1,144 @@ -# Layer 4 — Per-Post Keypair Rotation +# Layer 4 — Rotation, Revocation, and Key Lifecycle -**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`. +**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 1–2. Layer 4's job is to lock in the policy and add the local-only provenance table that makes selective cascades possible. --- ## 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. +- 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 -- **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. +- **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"). --- -## Data model +## What goes away from the original skeleton -### `PostKeyRotation` record +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: -```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 -} +- 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) +) ``` -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. +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. -### 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) +### Optional `supersedes_post_id` field on `PostHeader` ```rust -struct InlineComment { +struct PostHeader { // ... existing fields ... - #[serde(default)] - pub_post_index: u32, // 0 for original keypair, n for rotation n - group_sig: ..., - vouch_mac: ..., + + #[serde(default, skip_serializing_if = "Option::is_none")] + supersedes_post_id: Option, } ``` ---- +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." -## Rotation flow (author side) +### `KeyBurnDiff` (header-diff type) -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). +```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). --- -## Reader/commenter side +## Author UX surfaces -- 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. +- **"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. --- -## Propagation +## Cascade decision tree (for the author) -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. +| 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 -- **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. +- **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 -- `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. +- `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). diff --git a/sessions.md b/sessions.md index e4d5219..5e86a7b 100644 --- a/sessions.md +++ b/sessions.md @@ -65,6 +65,35 @@ See `CONTRIBUTING.md` for the protocol. See `AGENTS.md` for the Claude-specific **Stopping point**: commit `b8b38a6` (Layer 1) + new commit for Layer 2 both on branch; not merged. Awaiting Scott. +### Update 2026-05-13 — Layer 4 written (rotation + revocation + key lifecycle) + +Iterative session with Scott. Recap of where the model landed: + +**Rotation/revocation model (now in spec)**: +- Default narrowing of comment authority on a post = Layer 2 revocation (existing mechanism). No new wire primitive. +- Advanced narrowing of read access = full re-issue with `supersedes_post_id` link. Discouraged due to network overhead. +- `V_me` rotation = the persona-wide revocation primitive. Generate new V_me, distribute via next bio-post batch to non-revoked vouchees only. Revoked person retains old V_me. +- Receiver-chain model: receiver appends new V_me to `vouch_keys_received` (does NOT overwrite). Trial-unwrap iterates the chain. UX-wise the "current" key is the newest; older epochs are archived but kept for historical decrypts. +- **Grandfather-by-default**: CDN is V_me-blind, so rotation does NOT auto-cascade comment deletion. Revoked vouchee keeps comment authority on old posts unless the author opts to cascade per-pub_x revocations. +- **Per-post cascade is opt-in**: author can query a local `own_post_slot_provenance` table to find pub_x's sealed under V_me_old in any of their posts, then publish per-pub_x RevocationEntries to cascade. +- **Key-burn primitive (new, optional)**: signed `KeyBurnDiff` swaps an old wrap_slot for a new one in-place on a specific post. Used when V_me leaked and the author wants to scrub it from the CDN copy of old posts. Body CEK unchanged; affects future fresh-decrypts only. + +**Cryptographic stack confirmed (Scott reconfirmed)**: +- Body encryption: symmetric ChaCha20-Poly1305 under CEK. PQ-safe. +- Wrap_slots: AEAD under V_x. PQ-safe. +- Comment signing: **asymmetric Ed25519** (per-V_x per-post `(pub_x, priv_x)`). NOT PQ-safe; ML-DSA-65 migration deferred. Scott confirmed the asymmetric-for-signing tradeoff is intentional — it's what makes CDN-level bandwidth-DoS filtering work. + +**Files touched in this round**: +- `docs/fof-spec/layer-4-keypair-rotation.md`: full rewrite from skeleton. +- `docs/fof-spec/layer-1-vouch-primitive.md`: rotation language updated to point at Layer 4's append-only model; multi-epoch UI hook added. + +**Branch state**: `docs/fof-spec-layer1-bio-grants` (despite the name, holds all Layer 1–4 spec work). Commit pending. Not merged per Scott's standing instruction. + +**Pending**: +- Layer 5 (unlock cache + prefilter): existing skeleton text still reflects single per-post keypair model. Needs reconciliation with per-V_x model from Layer 2. +- Layer 3 (Mode 1): partially-superseded banner still present. Needs Scott/Opus reconciliation pass. +- Layer 6 (revocation): stub still. Largely obviated by Layer 4 work. + ### Update 2026-04-24 — Layer 3 round 2 (last two open questions) Two follow-up questions resolved: