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:
Scott Reimers 2026-04-23 23:20:56 -04:00
parent d118daee28
commit 1fdf9a94cc
8 changed files with 867 additions and 0 deletions

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