docs: FoF-gating spec skeleton (hand-off to Opus)
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) <noreply@anthropic.com>
This commit is contained in:
parent
d118daee28
commit
1fdf9a94cc
8 changed files with 867 additions and 0 deletions
94
docs/fof-spec/README.md
Normal file
94
docs/fof-spec/README.md
Normal file
|
|
@ -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.
|
||||||
130
docs/fof-spec/layer-1-vouch-primitive.md
Normal file
130
docs/fof-spec/layer-1-vouch-primitive.md
Normal file
|
|
@ -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.
|
||||||
127
docs/fof-spec/layer-2-mode2-fof-comments.md
Normal file
127
docs/fof-spec/layer-2-mode2-fof-comments.md
Normal file
|
|
@ -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<WrapSlot>, // 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.
|
||||||
139
docs/fof-spec/layer-3-mode1-fof-closed.md
Normal file
139
docs/fof-spec/layer-3-mode1-fof-closed.md
Normal file
|
|
@ -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<NodeId> },
|
||||||
|
GroupEncrypted { group_id: GroupId, epoch: u32, wrapped_cek: Vec<u8> },
|
||||||
|
FoFClosed { // NEW
|
||||||
|
pub_post: [u8; 32],
|
||||||
|
wrap_slots: Vec<WrapSlot>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `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).
|
||||||
122
docs/fof-spec/layer-4-keypair-rotation.md
Normal file
122
docs/fof-spec/layer-4-keypair-rotation.md
Normal file
|
|
@ -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<WrapSlot>, // 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.
|
||||||
135
docs/fof-spec/layer-5-prefilter-and-cache.md
Normal file
135
docs/fof-spec/layer-5-prefilter-and-cache.md
Normal file
|
|
@ -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.
|
||||||
94
docs/fof-spec/layer-6-revocation.md
Normal file
94
docs/fof-spec/layer-6-revocation.md
Normal file
|
|
@ -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.
|
||||||
26
sessions.md
26
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`
|
## 2026-04-23 — primary Claude (Lead) — `chore/workflow-adoption`
|
||||||
|
|
||||||
**Started**: late April 23 UTC
|
**Started**: late April 23 UTC
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue