From 1fdf9a94ccfd9c68772c879b848e2d85c7c14f56 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 23 Apr 2026 23:20:56 -0400 Subject: [PATCH] docs: FoF-gating spec skeleton (hand-off to Opus) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/fof-spec/README.md | 94 +++++++++++++ docs/fof-spec/layer-1-vouch-primitive.md | 130 +++++++++++++++++ docs/fof-spec/layer-2-mode2-fof-comments.md | 127 +++++++++++++++++ docs/fof-spec/layer-3-mode1-fof-closed.md | 139 +++++++++++++++++++ docs/fof-spec/layer-4-keypair-rotation.md | 122 ++++++++++++++++ docs/fof-spec/layer-5-prefilter-and-cache.md | 135 ++++++++++++++++++ docs/fof-spec/layer-6-revocation.md | 94 +++++++++++++ sessions.md | 26 ++++ 8 files changed, 867 insertions(+) create mode 100644 docs/fof-spec/README.md create mode 100644 docs/fof-spec/layer-1-vouch-primitive.md create mode 100644 docs/fof-spec/layer-2-mode2-fof-comments.md create mode 100644 docs/fof-spec/layer-3-mode1-fof-closed.md create mode 100644 docs/fof-spec/layer-4-keypair-rotation.md create mode 100644 docs/fof-spec/layer-5-prefilter-and-cache.md create mode 100644 docs/fof-spec/layer-6-revocation.md diff --git a/docs/fof-spec/README.md b/docs/fof-spec/README.md new file mode 100644 index 0000000..02c1946 --- /dev/null +++ b/docs/fof-spec/README.md @@ -0,0 +1,94 @@ +# Friend-of-Friend (FoF) Gating — Implementation Spec + +**Status**: Skeleton drafted 2026-04-23 by Scott + Lead Claude. Crypto-level byte layouts and algorithm specifics are intentionally left as `TBD — OPUS` markers for Opus to fill in. See each layer file for scope. + +--- + +## What this spec covers + +FoF-gating is a new post-visibility mechanism that gates readership and commenting by **social reachability** rather than explicit membership lists. It complements, rather than replaces, the existing primitives (`Public`, per-recipient `Encrypted`, per-circle `GroupEncrypted`). + +The central primitive is the **per-person vouch key** `V_me` — a symmetric key each persona owns, handed to everyone they vouch for. Cryptographic reachability for an author's FoF post emerges from the union of every `V_x` that author holds (own + received-from-vouchers), and every reader whose keyring intersects that set. + +### User-facing model + +Authors pick a post visibility from four levels: + +| Level | Wrap slots include | Cryptographic reach | +|---|---|---| +| **Public** | (none) | All readers | +| **Friends-only** | `V_me` only | My vouchees | +| **Friends-of-Friends** | `V_me` + every `V_x` I hold | My vouchees + every vouchee of anyone who vouched for me (emergent FoF) | +| **Custom** | Author-selected subset of held `V_x` | Union of reach through each included key | + +No centrally-computed membership list. Reach is a function of the wrap-slot set and which keys each reader holds. + +### Key design properties + +- **Unilateral**: vouching is a one-way act (Alice hands out `V_alice`). No bilateral handshake required for the FoF graph to form. +- **Graph-private**: wrap slots carry no recipient IDs. Observers cannot enumerate "who can read this post." +- **Anonymous prefilter**: each wrap slot carries a 2-byte `HMAC(V_x, post_id)[:2B]` tag. Readers precompute tags for keys in their ring and skip non-matching slots, cutting trial-decrypt cost ~65000× per post. +- **Slot count padding**: author pads wrap slots to power-of-2 buckets to blunt vouch-count leak. +- **Per-post keypair**: each FoF post generates a fresh `(priv_post, pub_post)`. Leak of one post's `priv_post` bounds blast radius to that post's comments; doesn't expose other posts or the author's identity key. + +### Two modes + +- **Mode 1 — `FOF_CLOSED`**: post body encrypted, comments encrypted. FoF-only readership + writership. +- **Mode 2 — `PUBLIC_FOF_COMMENT`**: post body public (indexable, cacheable, shardable), comments can be public OR FoF-encrypted per-comment. + +--- + +## Layering / implementation order + +Build and ship bottom-up. Each layer is independently shippable and exercised before moving to the next. + +1. **[Layer 1](layer-1-vouch-primitive.md) — Vouch primitive.** `V_x` keys, per-persona keyring storage, epoch tag, distribution/exchange mechanism, minimal UI. No posts yet. +2. **[Layer 2](layer-2-mode2-fof-comments.md) — Mode 2: public posts with FoF-gated comments.** Easier implementation path; reuses existing public-post CDN path; extends `CommentPolicy` with a new `GroupMembersOfFoF` variant. +3. **[Layer 3](layer-3-mode1-fof-closed.md) — Mode 1: `FOF_CLOSED` posts.** New `PostVisibility::FoFClosed` variant. Wrap slots, anonymous prefilter tag. Receive-path integration. +4. **[Layer 4](layer-4-keypair-rotation.md) — Per-post keypair rotation.** Graceful `(priv_post', pub_post')` rotation record re-wrapped to current FoF set. Old comments still verifiable under old `pub_post`; new comments require new. +5. **[Layer 5](layer-5-prefilter-and-cache.md) — Unlock cache + prefilter optimization.** Author-direct fast path, winning-V_x-per-author cache, unreadable-posts retry table, re-try-on-new-V_x trigger. Performance-critical at realistic keyring sizes (400–500 keys × 400–500 slots). +6. **[Layer 6](layer-6-revocation.md) — Revocation & rotation cascades.** Deferred; may not be in v1. Drafted as a stub for design review. + +--- + +## Intentionally out of scope (v1) + +- **First-contact DM gating.** Deferred; Scott has a separate approach to sketch later. +- **Spam / abuse mitigation beyond vouch_mac.** Rate limits, PoW, reputation, reporting — tracked as separate work. +- **Intra-circle anonymity.** Ring signatures over the vouch set so even other FoF members cannot tell which voucher's chain a comment arrived through. v2. +- **Zero-knowledge audience gating.** v2. +- **PQ migration** of `priv_post` / identity sigs. The spec shape above is algorithm-agnostic; PQ (ML-DSA ~1.3KB sigs) swaps in without structural change. Tracked separately. + +--- + +## Glossary + +- **Persona**: a user's posting identity. A device may hold multiple. Each persona has its own `V_me` and its own received-vouches keyring. +- **Vouch**: a unilateral act by persona P1 declaring trust in persona P2. P1 gives P2 a copy of `V_P1`. +- **Vouch key (`V_x`)**: a symmetric key owned by persona `x`, distributed by `x` to everyone `x` vouches for. `V_me` refers to the current persona's own vouch key. +- **Keyring**: for a given persona, the set of vouch keys held = `{V_me}` ∪ `{V_x : x vouched for me}`. +- **Wrap slot**: an anonymous ciphertext in a post header, carrying the post's private material encrypted under some `V_x`. Readers trial-decrypt slots whose 2-byte prefilter tag matches an owned key. +- **pub_post / priv_post**: per-post ephemeral ed25519 keypair. `priv_post` is wrapped in the post's wrap slots; `pub_post` is in the header plaintext and used for group signature verification on comments. +- **Identity key**: persona's long-term ed25519 key, used to sign content as that persona. Distinct from `priv_post`. (In current ItsGoin terms: this is the persona's posting key. Worth naming-alignment review when specifying.) +- **Vouch MAC**: `HMAC(V_x, post_id || comment_hash)` — 16B truncated. Identifies which `V_x` a commenter holds. Inside encrypted payload (Mode 1) or alongside plaintext (Mode 2). Used by author strict-mode to verify the commenter is reachable via a known vouch chain. + +--- + +## Integration with existing ItsGoin primitives + +- **`Circle` + `GroupEncrypted`** remain as-is for named explicit-membership groups. FoF is a separate visibility class, not a replacement. +- **`PostVisibility`** gains one new variant (`FoFClosed`, from Layer 3). Mode 2 reuses `PostVisibility::Public` with extended `CommentPolicy`. +- **`CommentPolicy`** gains one new variant for Layer 2 (Mode 2 comment gating). +- **`InlineComment`** gets optional `group_sig` + `vouch_mac` fields (back-compat via `#[serde(default)]`, same pattern as Phase 2e `ref_post_id`). +- **`control::receive_post`** gets new verify-gate branches for `FoFClosed` posts (author_sig + wrap_slots well-formedness) and FoF comments (group_sig verifies against `pub_post` from the referenced parent post). +- **Multi-persona**: keyrings are per-persona. Unlock attempts iterate personas; the persona that successfully unlocks is recorded and drives comment-authorship defaults. See Layer 3 for detail. + +--- + +## How to read this spec + +- Every `TBD — OPUS` marker identifies a crypto specifier / byte layout / test vector that Opus will fill in. +- `Lead decisions` blocks capture commitments that came out of the Scott + Lead Claude conversation; these are not open to further design iteration. +- `Open questions` blocks flag things the Lead wants Opus's input on before proceeding. + +Once Opus fills in the crypto layers, the Lead re-reviews + we move to implementation on a per-layer branch schedule. diff --git a/docs/fof-spec/layer-1-vouch-primitive.md b/docs/fof-spec/layer-1-vouch-primitive.md new file mode 100644 index 0000000..c0561a0 --- /dev/null +++ b/docs/fof-spec/layer-1-vouch-primitive.md @@ -0,0 +1,130 @@ +# Layer 1 — Vouch Primitive + +**Scope**: Introduce `V_x` vouch keys, per-persona keyring storage, and a distribution/exchange mechanism. No post-gating yet — this layer ships first so the primitive is in place and UI-exercised before any post encryption depends on it. + +--- + +## Goal + +After Layer 1 ships: + +- Each persona owns a current `V_me` symmetric key. New personas auto-generate one at creation. +- Each persona has a keyring of received vouch keys: `{V_x : x has vouched for me}`. +- Users can view who they've vouched for, who has vouched for them, and revoke/rotate `V_me`. +- Wire protocol can transfer `V_me` from voucher to vouchee inside an existing encrypted channel. +- No post encryption or comment gating depends on Layer 1 yet — that arrives in Layer 2/3. + +--- + +## Lead decisions + +- **`V_me` is symmetric, not asymmetric.** Every vouchee holds the same `V_me`. This is the property that makes FoF reach emergent (wrap under `V_x`, anyone `x` vouched for decrypts). +- **Distribution is unilateral.** Alice vouching for Bob = Alice gives Bob a copy of `V_alice`. No request/accept handshake. Bob can immediately read FoF posts that include `V_alice` as a wrap slot. +- **Revocation = rotate `V_me`.** There is no per-vouchee revocation. To un-vouch someone, the persona generates a new `V_me'` and re-distributes to every remaining vouchee. Layer 6 stub. +- **Epoch tag is part of the key.** Every `V_x` has an associated `(owner_id, epoch)` so receivers can tell fresh from stale when multiple copies arrive (e.g., if voucher rotated). +- **Keyring is per-persona, not per-device.** Multi-persona users have independent keyrings. Layer 3 reader logic iterates personas when trial-decrypting. + +--- + +## Data model + +### `vouch_keys_own` table + +Per-persona, stores the persona's own `V_me` history (current + recent-past for graceful rotation). + +TBD — OPUS: exact column types, key size in bytes, how many past epochs to retain. + +``` +vouch_keys_own( + persona_id BLOB, + epoch INTEGER, + key_material BLOB, -- TBD — OPUS: symmetric key bytes (32B for a 256-bit key?) + created_at_ms INTEGER, + is_current INTEGER, -- 1 for the active V_me, 0 for retained past epochs + PRIMARY KEY (persona_id, epoch) +) +``` + +### `vouch_keys_received` table + +Per-persona, stores vouch keys received from other personas (one row per `(owner_id, epoch)` currently held). + +``` +vouch_keys_received( + holder_persona_id BLOB, -- whose keyring this entry belongs to + owner_id BLOB, -- the persona who owns (issued) this V_x + epoch INTEGER, + key_material BLOB, + received_at_ms INTEGER, + PRIMARY KEY (holder_persona_id, owner_id, epoch) +) +``` + +Readers compute their keyring as `SELECT key_material FROM vouch_keys_received WHERE holder_persona_id = ? UNION SELECT key_material FROM vouch_keys_own WHERE persona_id = ? AND is_current = 1`. + +--- + +## Key generation + +TBD — OPUS: specify the primitive. Candidates: + +- Random 32B from CSPRNG (simplest; `V_me` is pure symmetric secret) +- HKDF-derived from persona identity key + epoch counter (allows deterministic re-derivation; couples key rotation to identity key exposure) + +Lead leaning: **random 32B CSPRNG, stored encrypted at rest alongside identity key material**. Identity-key coupling offers no defensive benefit given the keyring is already indexed per-persona in the same DB. + +--- + +## Wire format for vouch distribution + +A `VouchGrant` message carries a copy of the voucher's current `V_me` from voucher to vouchee. + +TBD — OPUS: exact byte layout. Target shape: + +``` +VouchGrant { + voucher_persona_id: NodeId, -- sender's persona + epoch: u32, + key_material: [u8; 32], -- V_voucher at this epoch + issued_at_ms: u64, + sig: [u8; 64], -- voucher's identity-key signature over the above +} +``` + +Delivery: wrap `VouchGrant` inside the existing `Direct` (encrypted-to-one-recipient) post primitive. Do not introduce a new top-level control message. Content-type byte distinguishes vouch grants from regular DMs. Recipient's receive-path decodes, verifies signature, inserts into `vouch_keys_received`. + +TBD — OPUS: decide whether vouch grants should use a reserved visibility intent (`VisibilityIntent::VouchGrant`) for filtering out of the Messages tab, or whether they ride under `Direct` and are suppressed client-side by content-type. + +--- + +## UI / UX + +Minimum viable surface for Layer 1 ship: + +- **Persona screen**: "Vouch for someone" button. Picker of contacts. Hands them a `V_me`. +- **Persona screen**: "Who has vouched for me" list (reads `vouch_keys_received` grouped by `owner_id`). +- **Persona screen**: "People I've vouched for" list (local-only; TBD — OPUS on whether to track this explicitly or derive from DM-sent history — see open question below). +- **Settings**: "Rotate my vouch key" button → generates new epoch, queues re-distribution to tracked vouchees. + +Layer 1 ships without any post/comment behavior change. Vouches are visible in UI but don't gate content yet. + +--- + +## Open questions + +- **Do we track outbound vouches explicitly?** Option A: local `vouches_issued(recipient_persona_id, epoch_at_grant)` table. Option B: derive from DM-sent history searching for `VouchGrant` payloads. A is simpler + survives DM history loss; B avoids a redundant record. Lead leaning: A. +- **Re-distribution on rotation.** When persona rotates `V_me`, do we auto-queue `VouchGrant` DMs to every tracked vouchee, or require user-initiated re-vouch? Lead leaning: auto-queue with a confirmation summary ("rotate will re-send vouch to N people"). +- **Key size.** 256-bit symmetric key is standard and matches our ChaCha20-Poly1305 usage. Confirm with Opus there's no reason to prefer 192 or 128 bits. +- **Epoch granularity.** Monotonic counter per persona, or wall-clock-based? Counter is simpler; wall-clock aids debugging. Lead leaning: counter. +- **Should `V_me` ever be exported in the persona backup bundle?** Losing `V_me` means every FoF post gated under it becomes permanently unreadable for every vouchee. Lead leaning: yes, include in identity export. + +--- + +## Ship criteria for Layer 1 + +- All personas auto-generate `V_me` at creation. +- Users can vouch and receive vouches end-to-end via DM-wrapped `VouchGrant`. +- UI shows received and issued vouches per persona. +- `V_me` rotation works; re-distribution to tracked vouchees is demonstrated. +- No change to post visibility / comment behavior. +- Integration test: two personas on two devices, Alice vouches for Bob, Bob's `vouch_keys_received` contains `V_alice` with correct signature. diff --git a/docs/fof-spec/layer-2-mode2-fof-comments.md b/docs/fof-spec/layer-2-mode2-fof-comments.md new file mode 100644 index 0000000..eac2e96 --- /dev/null +++ b/docs/fof-spec/layer-2-mode2-fof-comments.md @@ -0,0 +1,127 @@ +# Layer 2 — Mode 2: Public Posts with FoF-Gated Comments + +**Scope**: Extend `CommentPolicy` with a `FriendsOfFriends` variant. Post body is public (indexable, cacheable, shardable via existing CDN). Comments on the post must prove FoF-reachability to the author before being accepted. + +This layer ships before Mode 1 because it reuses the existing public-post path and only adds a verification gate on comments. No new `PostVisibility` variant needed. + +--- + +## Goal + +- An author creates a public post with `comment_policy = FriendsOfFriends`. +- Body is encrypted to no one — plaintext in the CDN, same as a normal public post today. +- Comments on the post include a proof artifact that lets readers (and the author in strict mode) verify the commenter is reachable through the author's FoF graph. +- Non-FoF readers can still READ the post. They cannot post an accepted comment. + +--- + +## Lead decisions + +- **Mode 2 does not gate readership.** The post body is genuinely public. Only comments are gated. This is intentional — it preserves CDN shardability for the body and gives authors a lightweight "comments to my circle, body to the world" mode. +- **`pub_post` is included in the public post header.** Every FoF-eligible post (both modes) has a per-post ephemeral ed25519 keypair. `pub_post` in the header, `priv_post` in wrap slots (Mode 1) or directly in a control record (Mode 2 — TBD below). +- **Comments carry a `group_sig` signed with `priv_post`.** Any device that can unwrap `priv_post` (i.e., holds a matching `V_x`) can sign. `group_sig` verification against `pub_post` is the cryptographic proof of FoF-reachability. Verifiable by any observer holding the public post. +- **Optional `vouch_mac` for strict mode.** `HMAC(V_x, post_id || comment_hash)` identifies *which* voucher's chain a commenter holds. Author can run in strict mode and reject comments whose `vouch_mac` doesn't match a `V_x` the author has a record of distributing. Non-strict mode accepts any valid `group_sig`. +- **Comment still signed by commenter's identity key as well.** `group_sig` proves FoF-membership; identity sig binds the comment to a specific persona for display / abuse reporting / author-side blocklist. +- **Non-FoF devices can still render the post.** Read path is unchanged — comments missing `group_sig` (or failing verification) are filtered out in the feed renderer rather than hard-rejected in storage. This leaves forensic traces and is cheaper than re-verifying in the renderer vs at ingest. TBD open question below. + +--- + +## Data model + +### Extend `CommentPolicy` + +```rust +pub enum CommentPolicy { + Public, + FollowersOnly, + None, + FriendsOfFriends, // NEW — Layer 2 +} +``` + +### Post header additions (for posts with `comment_policy = FriendsOfFriends`) + +```rust +struct PostHeader { + // ... existing fields ... + + // NEW for Mode 2 comment gating: + pub_post: Option<[u8; 32]>, // ed25519 public key of per-post ephemeral keypair + wrap_slots: Vec, // wraps priv_post under each V_x in author's keyring + + // TBD — OPUS: WrapSlot byte layout (same type as Layer 3 uses) +} +``` + +**Note**: Mode 2 posts carry `wrap_slots` even though the body is public, because commenters need `priv_post` to sign. The wrap slot set IS the FoF membership definition. + +### Extend `InlineComment` + +```rust +struct InlineComment { + // ... existing fields ... + + #[serde(default, skip_serializing_if = "Option::is_none")] + group_sig: Option<[u8; 64]>, // ed25519 signature over comment_hash, verifies against parent post's pub_post + + #[serde(default, skip_serializing_if = "Option::is_none")] + vouch_mac: Option<[u8; 16]>, // HMAC(V_x, post_id || comment_hash), truncated +} +``` + +Back-compat: old comments without these fields are treated as "not FoF-signed" — accepted on non-FoF posts, rejected on FoF posts. + +--- + +## Comment creation (author of the comment) + +1. Commenter fetches parent post. Reads `pub_post` from header. +2. Commenter iterates their keyring. For each `V_x` held, computes `HMAC(V_x, post_id)[:2B]`. Compares against each `WrapSlot.prefilter_tag`. On match, trial-decrypts the slot to get `priv_post`. +3. If any slot unwraps successfully: commenter now holds `priv_post`. Signs `comment_hash` to produce `group_sig`. Computes `vouch_mac = HMAC(V_x_winner, post_id || comment_hash)`. Attaches both to comment. +4. Publishes comment through normal comment-propagation path. + +If NO slot unwraps: commenter is not in the author's FoF set. UI reports "You can't comment on this post." Pure client-side enforcement — no wire attempt. + +--- + +## Comment verification (reader side) + +At feed render time, for every comment on a `FriendsOfFriends`-policy post: + +1. If `group_sig` is missing → filter out (strict) or show with "unverified" badge (permissive). Lead leaning: **filter out**. +2. Verify `group_sig` against parent post's `pub_post` over `comment_hash`. If fail → filter out. +3. Verify identity sig of comment as normal. If fail → filter out. + +At author side (strict mode, optional): + +4. Recompute expected `vouch_mac` candidates from the set of `V_x` the author has distributed. If `comment.vouch_mac` doesn't match any → discard at ingest (don't store, don't propagate). + +--- + +## Propagation + +No changes to comment propagation path. Comments still flow via existing `BlobHeaderDiff` / engagement-diff mechanism. Verification is done at display time (reader) and optionally at ingest (author-strict). + +--- + +## Open questions + +- **Filter-out vs reject-at-storage for bad `group_sig`.** Filter-out preserves forensic trail and avoids double-verify cost. Reject-at-storage saves disk and sync bandwidth. Lead leaning: filter-out at render, reject-at-storage only on author-side strict mode. +- **`priv_post` distribution for Mode 2.** In Mode 1, `priv_post` is inside `wrap_slots`. In Mode 2, body is plaintext, but commenters still need `priv_post`. Options: (A) `wrap_slots` still present in Mode 2 headers, carrying only `priv_post`; (B) separate `comment_priv_key` control record distributed out of band. Lead leaning: A (uniform structure with Mode 1). +- **Wrap slot prefilter tag.** 2B = 65536 buckets, false-positive 1/65536. For Mode 2 this also defines the commenter-eligibility filter. TBD — OPUS: confirm 2B is enough for realistic keyring sizes. +- **Author's own comments.** Author has `priv_post` directly (generated it). Do they self-vouch-mac against `V_me`? Leaning: yes, so strict mode uniformly verifies all comments. +- **Displaying vouch path to the author.** Mode 2 strict mode knows which `V_x` a commenter arrived through. Should the author's UI show "comment arrived via your vouch to Alice" or keep it opaque? Lead leaning: opaque by default; optional power-user setting. +- **Rate-limiting / spam.** A malicious FoF member can flood comments. `vouch_mac` identifies the chain, so author can block a chain. Out-of-scope for Layer 2 ship (tracked separately). + +--- + +## Ship criteria for Layer 2 + +- `CommentPolicy::FriendsOfFriends` variant exists end-to-end (storage, protocol, UI picker). +- Authors can create public posts with FoF-gated comments. +- `pub_post` / `priv_post` / `wrap_slots` generated on post creation, wrapped under author's full keyring. +- Commenters: client-side check of FoF eligibility before offering comment box; `group_sig` + `vouch_mac` attached on send. +- Readers: filter-out comments failing `group_sig` verification. +- Author strict-mode: optional ingress rejection on unknown `vouch_mac`. +- Back-compat: old clients see FoF posts as readable (body is public) but can't comment (missing `priv_post`); old comments on new posts are filtered at render. +- Integration test: 3-node FoF chain (A→B→C). A posts Mode 2. B comments (reachable). C comments (reachable via B). D (unrelated) cannot. diff --git a/docs/fof-spec/layer-3-mode1-fof-closed.md b/docs/fof-spec/layer-3-mode1-fof-closed.md new file mode 100644 index 0000000..6b5d8c0 --- /dev/null +++ b/docs/fof-spec/layer-3-mode1-fof-closed.md @@ -0,0 +1,139 @@ +# Layer 3 — Mode 1: `FOF_CLOSED` Posts + +**Scope**: New `PostVisibility::FoFClosed` variant. Both post body AND comments are gated to the FoF graph. Body is encrypted; readership emerges from keyring intersection with `wrap_slots`. + +Builds on Layer 2's `pub_post` / `priv_post` / `wrap_slot` primitives — same structures, just that the CEK encrypting the body is *also* in the wrap slots (alongside `priv_post`). + +--- + +## Goal + +- Author creates a post with `visibility = FoFClosed`. +- Body ciphertext in the CDN; only FoF-reachable readers can decrypt. +- `wrap_slots` carry both the post CEK and `priv_post`, wrapped under each `V_x` the author holds. +- Readers trial-decrypt slots whose 2-byte prefilter tag matches a held key. +- Comments use Layer 2's `group_sig` / `vouch_mac` mechanism — unchanged. +- Observers cannot enumerate recipients from the header; slot count padded to power-of-2 buckets. + +--- + +## Lead decisions + +- **New variant, not extended Encrypted.** `PostVisibility::FoFClosed` is its own variant. Existing `Encrypted{recipients}` wraps per-recipient NodeIds — visible on the wire. FoF wraps anonymously under symmetric keys — no NodeIds. +- **One wrap slot per `V_x` in the author's keyring.** For a Friends-only post, one slot under `V_me`. For FoF, N+1 slots (one per `V_x` the author holds + own `V_me`). For Custom, subset chosen by author. +- **Slot count padded to power-of-2.** Prevents observers from counting vouchers the author has. TBD — OPUS: confirm padding up to next power of 2 with random dummy slots (non-decryptable ciphertext indistinguishable from real slots). +- **Each slot carries both CEK and priv_post.** Wrapped together as a single plaintext. One successful unwrap gives reader everything they need to read body + sign comments. +- **Prefilter tag is `HMAC(V_x, post_id)[:2B]`.** Readers precompute a 2-byte tag for each key in their keyring and skip slots that don't match. Cuts trial-decrypt cost by ~2^16 on average. +- **Order of slots is randomized.** No positional leak about which slot corresponds to which voucher. + +--- + +## Data model + +### Extend `PostVisibility` + +```rust +pub enum PostVisibility { + Public, + Encrypted { recipients: Vec }, + GroupEncrypted { group_id: GroupId, epoch: u32, wrapped_cek: Vec }, + FoFClosed { // NEW + pub_post: [u8; 32], + wrap_slots: Vec, + }, +} +``` + +### `WrapSlot` byte layout + +TBD — OPUS. Target shape: + +``` +WrapSlot { + prefilter_tag: [u8; 2], // HMAC(V_x, post_id)[:2B] + nonce: [u8; 12], // AEAD nonce (unique per slot) + ciphertext: [u8; N], // AEAD(V_x, nonce, plaintext) — plaintext is CEK || priv_post + tag: [u8; 16], // AEAD auth tag +} +``` + +Plaintext inside the slot: + +``` +SlotPlaintext { + cek: [u8; 32], // ChaCha20-Poly1305 key for body + priv_post: [u8; 32], // ed25519 seed for per-post keypair +} +``` + +TBD — OPUS: +- AEAD choice (ChaCha20-Poly1305 matches existing usage — confirm). +- Whether to include `post_id` as AAD to bind slot to post and prevent slot-reuse across posts. +- Padding scheme for dummy slots (same size + random bytes that fail AEAD on any key). + +--- + +## Encryption (author, post creation) + +1. Generate per-post ephemeral ed25519 keypair `(priv_post, pub_post)`. +2. Generate random 32B CEK. +3. Encrypt body: `body_ct = ChaCha20-Poly1305(CEK, nonce, body || aad=post_id)`. +4. For each `V_x` in selected set (own `V_me` + held vouches for FoF, or custom subset): + - `prefilter_tag = HMAC(V_x, post_id)[:2B]` + - `slot_ct = AEAD(V_x, slot_nonce, CEK || priv_post, aad=post_id)` + - Append `WrapSlot { prefilter_tag, slot_nonce, slot_ct, tag }` to `wrap_slots` +5. Pad `wrap_slots` to next power of 2 with dummy slots. +6. Randomize slot order. +7. Sign post header with author identity key as normal. + +--- + +## Decryption (reader side) + +1. Receive post. Parse header → `pub_post`, `wrap_slots`. +2. For each `V_x` in reader's keyring (all personas): + - Compute `candidate_tag = HMAC(V_x, post_id)[:2B]`. + - For each slot with matching `prefilter_tag`, attempt `AEAD-open(V_x, slot.nonce, slot.ct)`. + - On success: extract `CEK`, `priv_post`. Decrypt body with CEK. Record which `(persona, V_x)` won (Layer 5 cache). +3. If no slot unwraps across all personas: mark post as unreadable (Layer 5 retry table). + +Expected cost without prefilter: `keyring_size × slot_count` AEAD attempts. With 2B prefilter: `keyring_size × slot_count / 65536` AEAD attempts on average. For 500×500 = 250K attempts → ~4 attempts average. Prefilter is load-bearing for feasibility; see Layer 5. + +--- + +## Receive-path integration + +Extend `control::receive_post` with a new gate branch for `FoFClosed`: + +- Verify author identity sig over header (as existing). +- Verify `wrap_slots` well-formedness: each slot has correct field sizes; slot count is power of 2. +- Accept without being able to decrypt. Non-FoF nodes still store and propagate the ciphertext as part of normal CDN replication. Decryption is a READ-side concern. + +--- + +## Ciphertext propagation + +Ciphertext `FoFClosed` posts ride the same CDN propagation as other encrypted posts. No change to `BlobHeaderDiff` / `file_holders`. Any node can hold and forward the ciphertext; only FoF-graph nodes can decrypt. + +--- + +## Open questions + +- **Slot size uniformity.** Real slots and dummy padding slots must be byte-identical-sized. Confirmed. TBD — OPUS: should we also pad the body length to a bucket to avoid length-based classification? +- **Prefilter false-positive cost.** 1/65536 false positive per slot. With 500 slots × reader iterating 500 keys, expected ~3.8 false-positive AEAD attempts per post. Acceptable. +- **Prefilter collision on legitimate hits.** Two different `V_x` could produce the same `prefilter_tag` for the same `post_id`. Reader just tries both. No correctness issue. +- **Slot-reuse across posts.** If the same `V_x` is used across many posts, an attacker can observe prefilter tags recur. Since `post_id` is in the HMAC input, tags differ per post. No leak. +- **Custom mode slot selection.** Does the author UI let them pick specific vouchers, specific groups of vouchers, or only "all held + own" vs "own only"? Lead leaning: initial UI = only the three preset levels (Friends-only / FoF / Public); custom ships as power-user option later. +- **Deduplication of `V_x` across personas.** If multiple personas hold the same `V_x`, do we include one slot or one-per-persona? Lead leaning: dedup at the `V_x` bytes level; one slot per unique key. + +--- + +## Ship criteria for Layer 3 + +- `PostVisibility::FoFClosed` exists end-to-end. +- Author creation path generates ephemeral keypair, wraps CEK+priv_post under each eligible `V_x`, pads to power-of-2. +- Reader decryption path iterates personas × keyring with prefilter tag. +- `receive_post` accepts FoFClosed ciphertext without decrypting. +- UI surface: post composer has Public / Friends-only / FoF / Custom picker. +- Integration test: A posts FoFClosed. B (direct vouchee) reads. C (FoF via B) reads. D (unrelated) gets ciphertext, cannot decrypt. +- Performance: decryption completes within budget at 500-key keyring × 500-slot posts (see Layer 5 for the optimization work that makes this budget feasible). diff --git a/docs/fof-spec/layer-4-keypair-rotation.md b/docs/fof-spec/layer-4-keypair-rotation.md new file mode 100644 index 0000000..68f5339 --- /dev/null +++ b/docs/fof-spec/layer-4-keypair-rotation.md @@ -0,0 +1,122 @@ +# 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. diff --git a/docs/fof-spec/layer-5-prefilter-and-cache.md b/docs/fof-spec/layer-5-prefilter-and-cache.md new file mode 100644 index 0000000..6194a2d --- /dev/null +++ b/docs/fof-spec/layer-5-prefilter-and-cache.md @@ -0,0 +1,135 @@ +# Layer 5 — Unlock Cache & Prefilter Optimization + +**Scope**: Performance layer. Makes FoF post decryption feasible at realistic keyring sizes (target: 400–500 keys × 400–500 wrap slots per post). Three mechanisms: author-direct fast path, winning-`V_x`-per-author cache, and unreadable-posts retry table. + +This layer is load-bearing, not optional. Without it, a modest keyring × slot matrix makes per-post unlock cost user-visible in feed scroll. + +--- + +## Goal + +- Trial-decryption cost on any single FoF post approaches O(1) once the reader has successfully read any prior post from that author. +- Author-direct posts (author = one of the reader's personas) skip wrap-slot iteration entirely. +- Posts that couldn't be decrypted when received get re-attempted automatically when the reader's keyring changes (new `V_x` received). + +--- + +## Lead decisions + +- **Cache the winning `(persona, V_x)` per author.** First time persona `P` decrypts an FoFClosed post from author `A` using `V_x`, remember the tuple. Next post from `A`: try that `(P, V_x)` first. Author almost always re-wraps under the same set. +- **Track unreadable posts.** If no key currently held unwraps a post, insert into `vouch_unreadable_posts` for later retry. Clearing this set is cheap and necessary — a newly-received `V_x` potentially unlocks an arbitrary number of old posts. +- **Author-direct fast path.** If `post.author` is one of the reader's persona IDs, the reader is the author and holds `priv_post` implicitly (author-local cache of per-post keypairs). No wrap-slot iteration needed. +- **Prefilter tag precompute per-post, not per-feed-fetch.** At ingest time (once per post received), compute the reader's full set of `HMAC(V_x, post_id)[:2B]` tags and note which slots have matching prefilter values. Cache that index. Avoids recomputing HMACs on every re-render. + +--- + +## Data model + +### `vouch_unlock_cache` table + +``` +vouch_unlock_cache( + reader_persona_id BLOB, + author_id BLOB, + winning_v_x_owner BLOB, -- who issued the V_x that unlocked + winning_v_x_epoch INTEGER, + last_hit_ms INTEGER, + hit_count INTEGER, + PRIMARY KEY (reader_persona_id, author_id) +) +``` + +One row per `(reader_persona, author)` pair. Updated on every successful unlock (bump hit_count, touch last_hit_ms). On miss-then-hit with a different `V_x`, overwrite the tuple. + +### `vouch_unreadable_posts` table + +``` +vouch_unreadable_posts( + post_id BLOB PRIMARY KEY, + author_id BLOB, + first_seen_ms INTEGER, + last_attempt_ms INTEGER, + keyring_sig_at_attempt BLOB -- hash of reader's keyring state at last attempt; skip re-attempt if unchanged +) +``` + +When a post can't be decrypted, insert here. On keyring change (new `V_x` arrives), sweep this table and re-attempt with the new key only. + +### `post_slot_prefilter_index` table (optional, perf-heavy workloads only) + +``` +post_slot_prefilter_index( + post_id BLOB, + slot_index INTEGER, + prefilter_tag BLOB(2), + PRIMARY KEY (post_id, slot_index) +) +``` + +Cached per-slot tags. On first ingest, parse and insert. Query by tag when trying to decrypt. + +TBD — OPUS: whether this table is worth it. Alternative: keep `wrap_slots` as a parsed struct in memory on read; iterate linearly. At 500 slots × 20B overhead = 10KB per post header, in-memory iteration is probably faster than SQLite index lookup. Lead leaning: **skip the table; iterate in-memory after parsing**. + +--- + +## Fast path algorithm + +On incoming FoFClosed post from author `A`: + +1. **Author-direct check**: Is `A` in reader's list of personas? If yes → reader authored it; pull `priv_post` from local author cache. Done. +2. **Cache lookup**: Query `vouch_unlock_cache` for `(any_persona, A)`. For each cached winning `V_x`: + - Compute `prefilter_tag = HMAC(V_x, post_id)[:2B]`. + - Find matching slot(s) in post's `wrap_slots`; attempt AEAD-open. + - On success → done. Update `last_hit_ms`, increment `hit_count`. +3. **Full scan fallback**: If cache missed, iterate reader's full keyring × wrap_slots with prefilter. On success → insert/update `vouch_unlock_cache`. Done. +4. **Unreadable**: If full scan fails → insert into `vouch_unreadable_posts`. + +Step 2 expected cost: 1 HMAC + 1 AEAD attempt (hot path, recurring author). +Step 3 cost: `keyring_size × slot_count / 65536` AEAD attempts on average. +Step 1 cost: single table lookup. + +--- + +## Keyring-change trigger + +When a new `V_x` is inserted into `vouch_keys_received` (Layer 1 distribution): + +1. Compute the prefilter tag for the new `V_x` against each unreadable post's ID. +2. For every matching slot in `vouch_unreadable_posts` entries → attempt AEAD-open with the new `V_x`. +3. On success → move row out of `vouch_unreadable_posts`, insert into `vouch_unlock_cache`, render post in feed. +4. Emit UI signal: "N new posts from your friends-of-friends are now readable." + +Cost: proportional to size of `vouch_unreadable_posts`, but only fires when the user receives a new vouch (rare event). + +--- + +## Feed-rendering budget + +Target: feed scroll renders within existing budget (16ms/frame on desktop, 33ms on mobile). + +- Post already cached (99% hit rate in steady state): ~microseconds per post. +- Cache miss (first post from a new author): tens of microseconds per post. +- Cold full-scan: milliseconds; ideally done once per post during ingest, not during render. + +**Ingest-time decrypt vs render-time decrypt.** Lead leaning: **decrypt at ingest**. Store cleartext body keyed by post_id in a local `post_body_cache` (encrypted at rest by persona key). Feed rendering reads from cache; no per-frame crypto. TBD — OPUS: confirm ingest-time decrypt is acceptable for the privacy model (cleartext at rest is already standard for posts the user can read). + +--- + +## Open questions + +- **Cache invalidation on persona switch.** If user switches default persona, cache queries filter by reader_persona_id, so this is automatic. Check. +- **Multi-persona unlock priority.** If two personas could both decrypt the same post, which wins? Probably doesn't matter — whichever tries first. Consequence: comments on FoF posts default to the persona that decrypted (Layer 2 UX). TBD — OPUS: confirm deterministic iteration order of personas. +- **Cache size bounds.** `vouch_unlock_cache` is O(personas × authors_ever_read). Authors-ever-read can grow unbounded. GC old entries past some threshold? Lead leaning: no GC; memory cost is trivial at realistic scales. +- **Unreadable retry storm on large keyring arrival.** Receiving an entire bundle of new vouches at once (e.g., account import) could trigger a storm of retries. Throttle / batch. Lead leaning: background task, non-blocking on UI. +- **Negative-cache on bad `V_x`.** If a reader holds a `V_x` that has NO overlap with any post they've seen, every unreadable post retry pays the full prefilter scan. Store a "tried and failed" marker per `(post_id, V_x)` to skip. TBD — OPUS: is this worth the storage overhead? Realistically, new `V_x` arrivals are rare enough that the scan cost per arrival is acceptable. + +--- + +## Ship criteria for Layer 5 + +- `vouch_unlock_cache` and `vouch_unreadable_posts` tables exist. +- Author-direct fast path: reader's own posts skip wrap-slot iteration. +- `(reader_persona, author)` cache hit → single AEAD attempt on known post from recurring author. +- New `V_x` arrival triggers retry sweep on `vouch_unreadable_posts`. +- Feed-scroll performance: no user-visible stutter at 500-key keyring × 500-slot posts. +- Integration benchmark: 100-post feed from 20 authors, 400-key keyring, 400-slot posts → total decrypt < 100ms cold, < 10ms warm. diff --git a/docs/fof-spec/layer-6-revocation.md b/docs/fof-spec/layer-6-revocation.md new file mode 100644 index 0000000..cea5e37 --- /dev/null +++ b/docs/fof-spec/layer-6-revocation.md @@ -0,0 +1,94 @@ +# Layer 6 — Revocation & Rotation Cascades + +**Status**: Stub. May not be in v1. Drafted for design review only. + +**Scope**: Mechanism for a persona to un-vouch a specific vouchee without rotating `V_me` (which affects everyone), and for cascading rotations when a down-chain vouchee is un-vouched. + +--- + +## The problem + +In Layers 1–5, the only way to revoke a vouch is to rotate `V_me` and re-distribute to every remaining vouchee. This is: + +- **Coarse**: rotates everyone to remove one. +- **Expensive at scale**: O(remaining_vouchees) DM sends. +- **Traceable**: observers can't see who, but a flurry of DMs hints at a change. +- **Cascading**: if Alice rotates her `V_me`, every FoFClosed post Alice authored that was open to "friends" was open under `V_alice_old`; new readers on the updated `V_alice_new` can't decrypt old posts. Post-level Layer 4 rotation fixes this only if the author re-wraps every post under the new set. + +Layer 6 is where we explore whether any of these frictions is worth addressing, and at what cost. + +--- + +## Out of scope for v1 + +Without Layer 6, here's what breaks vs. what holds: + +- **Holds**: FoF gating works. Revocation works (via coarse `V_me` rotation). Privacy properties intact. +- **Breaks**: surgical un-vouch. If Alice wants to remove Bob but keep Charlie, she must rotate `V_alice`, re-give to Charlie. If she has 50 vouchees, that's 49 re-grants. Probably tolerable for a social-graph change that's inherently rare. Sharp corner only at large vouchee fanout. + +Lead leaning: **v1 ships without Layer 6**. Revocation is via coarse `V_me` rotation. Revisit after usage data shows whether surgical revocation is load-bearing. + +--- + +## Candidate designs (if we do Layer 6 later) + +### Candidate A: Revocation List signed by voucher + +Voucher publishes `(revoked_vouchee_id, since_epoch)`. Readers exclude posts whose `vouch_mac` identifies a chain through a revoked vouchee. + +Drawbacks: +- Requires `vouch_mac` to be public (not just for author strict mode). +- Publishes the social graph — the thing we worked to keep private. +- Non-starter without Zero-Knowledge proofs. + +### Candidate B: Per-vouchee key derivation + +Instead of one `V_me`, derive a per-vouchee key `V_me_bob = HKDF(V_me_master, bob_id)`. To revoke Bob, rotate `V_me_master` (affects everyone again — same problem). + +Alternative: author re-wraps posts under `V_me_current \ {V_me_bob}`. This is per-post rotation (Layer 4) with deliberate exclusion. Feasible but requires the author to know which slot was Bob's — which breaks anonymous wrap slots. + +### Candidate C: Forward-secrecy ratchet + +`V_me` ratchets forward on a cadence. Old vouchees retain access to content encrypted before revocation; lose access to new. Avoids explicit revocation. Close to Signal's group ratchet. + +Complexity is significant. Would require a per-persona state machine, out-of-band sync of current epoch between persona's devices, etc. + +### Candidate D: Accept the coarse rotation + +Acknowledge `V_me` rotation IS the revocation primitive. Smooth the UX: +- UI: "Remove Bob from your Friends" → warns "This will re-distribute your Friends key to your other 49 Friends." +- Background rotation task handles the N DMs. +- Existing FoFClosed posts don't auto-re-wrap (remain under old `V_alice`). Author can opt to re-wrap specific posts via Layer 4. + +Lead leaning: **Candidate D**. The "problem" is mostly UX friction, addressable with good affordances. + +--- + +## Open design questions (deferred) + +- Is there user demand for surgical revocation that doesn't rotate everyone? +- What's the actual fanout distribution? If p95 vouchee count is <20, coarse rotation is a ~20-DM operation — tolerable. +- Does Candidate D's UX feel heavy enough that users avoid revoking at all? (Anti-pattern: graph accumulates stale vouches.) +- For Mode 2 (public posts with FoF comments), revocation has different urgency — author cares about who can still COMMENT, not about access to past content. Is surgical comment-only revocation simpler? (Hint: Layer 4 rotation on a specific post already does this coarsely — rotate `priv_post`, re-wrap under the narrowed set.) + +--- + +## What Layer 6 should deliver IF we build it + +Not decided. Placeholder: + +- A defined revocation primitive (candidate selected). +- Cascading / not-cascading behavior specified. +- UI surface consistent with Layers 1–5. +- Does NOT introduce per-vouchee public identifiers in any wire format. + +--- + +## Decision point + +**Revisit after Layers 1–5 ship and 30 days of production usage.** Signals that would move Layer 6 into scope: +- p95 vouchee count > 50 (coarse rotation cost meaningfully high). +- User reports of "I want to remove X but not everyone." +- Privacy audit finding that the DM-flurry of coarse rotation leaks social-graph change timing. + +Absent those, Layer 6 stays deferred. diff --git a/sessions.md b/sessions.md index e12327a..bad3cfe 100644 --- a/sessions.md +++ b/sessions.md @@ -6,6 +6,32 @@ See `CONTRIBUTING.md` for the protocol. See `AGENTS.md` for the Claude-specific --- +## 2026-04-23 — primary Claude (Lead) — `docs/fof-spec-skeleton` + +**Started**: late April 23 UTC +**Instance**: Scott's primary Claude (Lead role) +**Issue**: none (spec-drafting work; hand-off to Opus for crypto fill-in) +**Branch**: `docs/fof-spec-skeleton` +**Scope**: Skeleton spec for Friend-of-Friend (FoF) post gating. Lays out the per-person vouch-key (`V_me`) primitive, four visibility levels (Public / Friends-only / FoF / Custom), Mode 1 (`FOF_CLOSED`) and Mode 2 (public post + FoF comments), and a six-layer implementation plan. Crypto byte layouts and algorithm specifics are marked `TBD — OPUS` for Opus to fill in. + +**Completed in this session**: +- `docs/fof-spec/README.md` — top-level overview, user-facing model, design properties, layering plan, out-of-scope, glossary, integration with existing primitives. +- `docs/fof-spec/layer-1-vouch-primitive.md` — `V_x` keys, per-persona keyring, `VouchGrant` wire format (DM-wrapped). +- `docs/fof-spec/layer-2-mode2-fof-comments.md` — `CommentPolicy::FriendsOfFriends`, `pub_post` / `priv_post` / wrap-slot primitives, `group_sig` + `vouch_mac` on comments. +- `docs/fof-spec/layer-3-mode1-fof-closed.md` — `PostVisibility::FoFClosed`, wrap-slot byte layout, anonymous 2B prefilter, power-of-2 slot padding. +- `docs/fof-spec/layer-4-keypair-rotation.md` — `PostKeyRotation` record, explicit `pub_post_index` on comments, per-post re-gating. +- `docs/fof-spec/layer-5-prefilter-and-cache.md` — `vouch_unlock_cache`, `vouch_unreadable_posts`, author-direct fast path, keyring-change retry sweep. +- `docs/fof-spec/layer-6-revocation.md` — stub; candidate designs A–D; Lead leaning is coarse-rotation with UX polish (Candidate D); revisit after 30 days of production data. + +**Pending after this PR merges**: +- Opus review pass: fill in `TBD — OPUS` markers (AEAD specifier, key sizes, WrapSlot byte layout, prefilter tag algorithm confirmation, epoch granularity, etc.). +- Lead re-review after Opus fills in crypto. +- Per-layer branch schedule for implementation (Layer 1 ships first, independently exercised). + +**Stopping point**: session ending after Lead self-merges `docs/fof-spec-skeleton` to master. Branch to be deleted locally + remote. + +--- + ## 2026-04-23 — primary Claude (Lead) — `chore/workflow-adoption` **Started**: late April 23 UTC