docs: Layer 1 — HPKE-sealed vouch grants via bio post

Replace DM-wrapped VouchGrant with HPKE (RFC 9180) per-recipient
wrappers in the voucher's bio post. Recipient anonymity via HPKE key
privacy; readers trial-decrypt per persona. 48B per wrapper, one
ephemeral pubkey per batch. Scan gated to follows + manual gesture.
Bucket padding + per-publish shuffle for size/position opacity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-04-24 07:38:12 -04:00
parent 1fdf9a94cc
commit b8b38a6f03
3 changed files with 209 additions and 58 deletions

View file

@ -42,7 +42,7 @@ No centrally-computed membership list. Reach is a function of the wrap-slot set
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.
1. **[Layer 1](layer-1-vouch-primitive.md) — Vouch primitive.** `V_x` keys, per-persona keyring storage, epoch tag, HPKE-sealed anonymous-wrapper distribution via the voucher's bio post, scan-on-follow + scan cache, minimal UI. No FoF-gated 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.
@ -82,6 +82,7 @@ Build and ship bottom-up. Each layer is independently shippable and exercised be
- **`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.
- **Bio post (`VisibilityIntent::Profile`)**: Layer 1 adds an optional `vouch_grants` field carrying an HPKE-sealed per-recipient wrapper batch. Existing bio-post CDN propagation carries vouch distribution — no new control-message type. See Layer 1 for wrapper format and scan policy.
---

View file

@ -1,6 +1,6 @@
# 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.
**Scope**: Introduce `V_x` vouch keys, per-persona keyring storage, and a distribution mechanism via anonymous per-recipient wrappers in the issuer's bio post. No post-gating yet — this layer ships first so the primitive is in place and UI-exercised before any post encryption depends on it.
---
@ -10,19 +10,27 @@ 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.
- Vouches are distributed via **anonymous wrappers appended to the voucher's bio post**, not via DM. No recipient IDs are visible on the wire.
- Clients auto-scan new/updated bio posts of people they follow; successfully unwrapped `V_x` keys are silently added to the receiving persona's keyring.
- Users can view who has vouched for them (from keyring) and who they've vouched for (from their own bio-post state).
- 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).
- **`V_me` is symmetric.** 32B CSPRNG-generated. 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 includes an anonymous wrapper containing `V_alice` addressed to Bob's persona pubkey in her bio post. No handshake, no acknowledgment.
- **Distribution channel is the bio post, not DM.** Bio posts already propagate via the CDN to followers. Recipient anonymity is preserved by HPKE key privacy (see below). No new control-message type is needed. Scan-on-fetch replaces push-notification.
- **Per-wrapper scheme is HPKE (RFC 9180).** Per-recipient ciphertext is HPKE-sealed under the recipient's X25519 persona key. One ephemeral pubkey per bio-post batch, shared across all wrappers in that batch. Each wrapper is 48B on the wire (32B sealed `V_me` + 16B AEAD tag).
- **HKDF `info` MUST be recipient-free.** `info = "itsgoin/vouch-grant/v1/" || bio_post_id`. Including any recipient identifier in `info` breaks key privacy and is forbidden.
- **No prefilter tag on Layer 1 wrappers.** Unlike FoF post wrap slots (Layers 23, which use HMAC prefilter over `V_x`), vouch grants have no prior shared secret. Readers pay a full X25519 scalar-mult per wrapper per persona. Cost is acceptable at realistic vouch-set sizes (<40ms for 600 trials).
- **Scan is follow-gated by default.** Clients auto-scan bio posts of followed personas. For non-followed personas, scanning is a manual "check this person's bio for a vouch" gesture. Rationale: vouches that would be relevant to a reader overwhelmingly come from people the reader follows.
- **Wrapper count padded to fixed buckets (64 / 128 / 256 / 512).** Dummy wrappers are random bytes shaped identically to real wrappers. Prevents observers from reading vouch-set size off the bio post.
- **Wrapper order shuffled on every publish.** Prevents positional inference of which vouchee slot changed between bio-post revisions.
- **Epoch tag is part of the key.** Each `V_x` has associated `(owner_id, epoch)` so receivers can distinguish fresh from stale when the voucher rotates.
- **Keyring is per-persona, not per-device.** Multi-persona users have independent keyrings. Layer 3 reader logic iterates personas when trial-decrypting.
- **Rotation is coarse.** Revocation = rotate `V_me`, republish bio post with wrappers for the remaining vouchees. See Layer 6 for design discussion.
---
@ -32,68 +40,163 @@ After Layer 1 ships:
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
persona_id BLOB,
epoch INTEGER,
key_material BLOB(32),
created_at_ms INTEGER,
is_current INTEGER, -- 1 for active, 0 for retained past
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).
Per-persona keyring — vouch keys successfully unwrapped from others' bio posts.
```
vouch_keys_received(
holder_persona_id BLOB, -- whose keyring this entry belongs to
owner_id BLOB, -- the persona who owns (issued) this V_x
holder_persona_id BLOB, -- whose keyring this entry belongs to
owner_id BLOB, -- the persona who issued V_x
epoch INTEGER,
key_material BLOB,
key_material BLOB(32),
received_at_ms INTEGER,
source_bio_post_id BLOB, -- provenance (which bio post we unwrapped from)
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`.
### `vouch_bio_scan_cache` table
---
## 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:
Skips re-scanning bio posts that haven't changed since last attempt.
```
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
vouch_bio_scan_cache(
scanner_persona_id BLOB,
bio_author_id BLOB,
bio_epoch INTEGER, -- bio-post revision counter
result INTEGER, -- 0 = no wrapper unlocked; 1 = unlocked
unlocked_v_x_epoch INTEGER, -- NULL if result=0
scanned_at_ms INTEGER,
PRIMARY KEY (scanner_persona_id, bio_author_id, bio_epoch)
)
```
On an unchanged bio, clients short-circuit via this cache. On a bio-post update, clients trial only the *new wrappers* in the diff (not the full batch), bounded by bio_epoch increment.
### Outbound vouches
No separate `vouches_issued` table. The bio post itself IS the authoritative record of whom the persona has vouched for. UI "people I've vouched for" is derived from local author-side state (the recipient pubkey list used at bio-post assembly time), stored in a simple `own_vouch_targets` table:
```
own_vouch_targets(
voucher_persona_id BLOB,
target_persona_id BLOB,
target_x25519_pub BLOB(32),
granted_at_ms INTEGER,
current INTEGER, -- 1 = in latest bio-post batch, 0 = removed
PRIMARY KEY (voucher_persona_id, target_persona_id)
)
```
This is local-only; never transmitted. The wire carries only anonymous wrappers.
---
## Wrapper format
Bio post carries a `VouchGrantBatch`:
```
VouchGrantBatch {
batch_eph_pub: [u8; 32], // shared ephemeral X25519 pubkey for this batch
v_x_epoch: u32, // epoch of V_me being distributed in this batch
wrappers: Vec<Wrapper>, // padded to next bucket in {64, 128, 256, 512}
}
Wrapper {
ciphertext: [u8; 32], // HPKE-sealed V_me (32B key)
tag: [u8; 16], // AEAD auth tag
}
```
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`.
Per-recipient wrapper construction (RFC 9180 HPKE sealing, single-shot mode):
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.
```
shared_secret = X25519(batch_eph_priv, recipient_x25519_pub)
key, nonce = HKDF-Expand(
HKDF-Extract(salt="", ikm=shared_secret),
info = "itsgoin/vouch-grant/v1/" || bio_post_id,
L = key_len + nonce_len
)
(ciphertext, tag) = AEAD-Seal(key, nonce, aad="", plaintext=V_me)
```
All recipients share the same `batch_eph_pub`; each gets a distinct wrapper derived from ECDH between that ephemeral and the recipient's persona X25519 pubkey.
Dummy wrappers: 32B random bytes + 16B random bytes. Shape-identical to real wrappers. Indistinguishable to any party lacking the voucher's target list.
**TBD — OPUS (confirm)**: AEAD choice for HPKE. Default RFC 9180 suite `DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, ChaCha20Poly1305` matches existing ItsGoin crypto usage; confirm no reason to prefer AES-GCM.
---
## Bio-post integration
ItsGoin already has bio posts (control post with `VisibilityIntent::Profile`). Layer 1 adds an optional `vouch_grant_batch` section to the profile-post payload:
```rust
struct ProfilePostPayload {
// ... existing fields (display_name, bio, avatar_cid, etc.) ...
#[serde(default, skip_serializing_if = "Option::is_none")]
vouch_grants: Option<VouchGrantBatch>,
bio_epoch: u32, // monotonic per-persona; incremented on every bio-post revision
}
```
Adding/removing a vouchee is a bio-post update: new batch, new `batch_eph_pub`, wrappers re-shuffled, `bio_epoch` incremented, republish. Propagates via the standard bio-post CDN path.
**Incremental grants via bio-post comments** (deferred): Scott's variant of appending additional wrappers as author-only comments on the bio post (to avoid full republish on every small change) is a future optimization. v1 ships with full republish per change; simpler mental model and keeps the wire contract symmetric. Revisit if bio-post bandwidth becomes a concern.
---
## Reader / scanner behavior
### On follow (new)
1. Fetch the followed persona's latest bio post.
2. If `vouch_grants.is_some()` AND `(scanner_persona, bio_author, bio_epoch)` not in scan cache:
- For each persona `P` in the reader's persona set:
- For each wrapper in the batch:
- Derive `shared_secret = X25519(P.x25519_priv, batch_eph_pub)`.
- Derive `key, nonce = HKDF(shared_secret, info="itsgoin/vouch-grant/v1/" || bio_post_id, ...)`.
- Attempt `AEAD-Open(wrapper.ciphertext, wrapper.tag, key, nonce)`.
- On success: extract `V_me`, insert into `vouch_keys_received` as `(holder=P, owner=bio_author, epoch=v_x_epoch)`. Record scan-cache hit.
3. If no wrapper unlocked across any persona: record scan-cache miss. Done.
### On bio-post update (existing follow)
Same as above, but trial only the wrappers that are new relative to the prior bio_epoch. Since wrappers are shuffled on every publish, "new wrappers" = all wrappers on every update. **TBD**: whether to persist the exact wrapper-ciphertext set per bio_epoch so deltas can be computed, or just rescan the full batch (pay the cost, skip the bookkeeping). Lead leaning: **rescan full batch**; the cost (~40ms × number of followed personas publishing updates) is tolerable and the bookkeeping adds state-sync complexity.
### On manual "check bio" gesture
Treat as new-follow scan. User-invoked. Useful when a non-followed persona has vouched for the user.
### Multi-persona trial order
Personas iterated in stable sorted order (by persona_id). First unlock wins; stop scanning wrappers once any persona succeeds on any wrapper (the wrapper was addressed to that persona specifically — other wrappers in the batch are for other recipients).
**TBD — OPUS**: confirm early-exit is correct. One caveat: the voucher may grant to MULTIPLE of the reader's personas (e.g., "I vouch for your work-persona AND your private-persona"). In that case we want to unlock all matching wrappers, not just the first. Lead leaning: do not early-exit on persona; continue scanning with remaining personas so all applicable grants are received.
---
## Cost analysis
Per wrapper per persona: 1 X25519 scalar mult + 1 HKDF + 1 AEAD-Open attempt. ~60µs on ARM mobile; faster on desktop.
Typical bio update: 200 wrappers × 3 personas = 600 trials ≈ 36ms. Invisible to user.
Padded bucket at 512 × 3 personas = 1536 trials ≈ 90ms. Still acceptable for a one-time ingest per bio update.
Scan-cache prevents re-pay on unchanged bios.
---
@ -101,30 +204,47 @@ TBD — OPUS: decide whether vouch grants should use a reserved visibility inten
Minimum viable surface for Layer 1 ship:
- **Persona screen**: "Vouch for someone" button. Picker of contacts. Hands them a `V_me`.
- **Persona screen**: "Vouch for someone" action. Picker of contacts. Adds their persona to `own_vouch_targets`; republishes bio post with new batch on save.
- **Persona screen**: "Who has vouched for me" list (reads `vouch_keys_received` grouped by `owner_id`).
- **Persona screen**: "People I've vouched for" list (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.
- **Persona screen**: "People I've vouched for" list (reads `own_vouch_targets` where `current = 1`).
- **Settings**: "Rotate my vouch key" → generates new `V_me` epoch, republishes bio post with wrappers under the new key for every current target.
- **Post detail**: manual "Check this person's bio for a vouch for me" button (non-followed author case).
Layer 1 ships without any post/comment behavior change. Vouches are visible in UI but don't gate content yet.
---
## Privacy properties (Layer 1 scope)
- **Recipient anonymity.** HPKE key privacy: wrapper ciphertext reveals nothing about the recipient's pubkey. Only trial decryption identifies recipients.
- **No identifiers on the wire.** No recipient NodeIds, no persona IDs, no derived recipient tags in the bio post.
- **Set-size opacity.** Bucket padding hides actual vouch-set size to within 2× granularity.
- **Rotation unobservability.** Shuffled wrapper order per publish prevents positional inference of which slot changed.
Timing leak is acknowledged: a bio-post update with a changed vouch set leaks "someone new was vouched for today" to observers who see the update. Batching (daily cadence instead of immediate) would blur this; out of scope for v1.
---
## 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.
- **Batching cadence.** Immediate (republish on every vouch-add) vs daily-batched (aggregate changes). Lead leaning: immediate in v1; observe whether timing leak is a concern in practice.
- **Dummy-wrapper count strategy.** Always pad to next bucket, or pad to a fixed persona-local target size (e.g., "always 128")? Fixed target hides growth trajectory but wastes bandwidth early on. Lead leaning: next-bucket padding.
- **`V_me` in identity export.** Losing `V_me` means every FoF post gated under it becomes permanently unreadable for every vouchee. Lead leaning: include `vouch_keys_own` (current + retained epochs) AND `vouch_keys_received` in persona export bundles.
- **Rescan triggering.** Scan on fetch of the bio post is natural. Should we also opportunistically rescan on keyring-change events (when the persona acquires a new X25519 keypair — unlikely but possible during import)? Lead leaning: yes, rescan-on-keyring-change, cheap.
- **Bio post size.** 512 wrappers × 48B = 24KB of vouch-grant payload. Plus ephemeral pubkey, epoch, headers. Negligible relative to profile-post overhead including avatars. No concern.
- **Does the scanner need to verify the voucher's identity sig over the batch?** Bio posts are already signed by the author's identity key, so the `VouchGrantBatch` inherits that signature. Forgery requires identity-key compromise (out of FoF scope).
---
## 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.
- Bio-post publish path can embed a `VouchGrantBatch`.
- `own_vouch_targets` table tracks who the persona has vouched for locally.
- `vouch_keys_received` populated via auto-scan on bio-post fetch; gated to followed personas + manual gesture.
- `vouch_bio_scan_cache` prevents re-scanning unchanged bios.
- UI: vouch someone, list given vouches, list received vouches, rotate `V_me`.
- 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.
- HPKE wrapper construction matches RFC 9180 with recipient-free `info`.
- Bucket-padding + per-publish wrapper shuffle implemented.
- Integration test: two personas on two devices. A vouches for B. B auto-scans A's bio post after follow. B's `vouch_keys_received` contains `V_alice` at the correct epoch. B un-follows A then manually scans: same result. A rotates `V_me`, republishes. B auto-rescans, gets new epoch.

View file

@ -6,6 +6,36 @@ See `CONTRIBUTING.md` for the protocol. See `AGENTS.md` for the Claude-specific
---
## 2026-04-24 — primary Claude (Lead) — `docs/fof-spec-layer1-bio-grants`
**Started**: April 24 UTC
**Instance**: Scott's primary Claude (Lead)
**Issue**: none (spec refinement)
**Branch**: `docs/fof-spec-layer1-bio-grants`
**Scope**: Fold Scott + Opus's Layer 1 design answer into the spec. Vouch distribution moves from DM-wrapped `VouchGrant` to HPKE-sealed per-recipient wrappers carried in the voucher's bio post, leveraging existing bio-post CDN propagation and HPKE (RFC 9180) key privacy for recipient anonymity.
**Key design commitments added to Layer 1**:
- HPKE RFC 9180 (DHKEM X25519 + HKDF-SHA256 + ChaCha20Poly1305) for per-recipient wrappers; one ephemeral pubkey per batch; 48B per wrapper.
- HKDF `info = "itsgoin/vouch-grant/v1/" || bio_post_id` — recipient-free (non-negotiable for key privacy).
- No prefilter tag on grants (no prior shared secret); full X25519 trial at ~60µs per wrapper per persona is tolerable (≤90ms even at 512×3 worst case).
- Scan policy: auto-scan bio posts of followed personas; manual "check bio" gesture for non-followed; scan cache keyed by `(scanner_persona, bio_author, bio_epoch)`.
- Bucket-padding (64/128/256/512) and per-publish wrapper shuffle for size/position opacity.
- No separate `vouches_issued` table on the wire; bio post IS the authoritative record. Local-only `own_vouch_targets` tracks what the persona has granted.
- Incremental grant-as-comment path (Scott's suggestion for avoiding full republish) deferred; v1 ships with full republish per change.
**Completed**:
- Rewrote `docs/fof-spec/layer-1-vouch-primitive.md` end-to-end.
- README updated: Layer 1 scope line + added bio-post integration bullet.
- Self-merged to master.
**Pending**:
- Opus confirmation passes still open on other layers (WrapSlot byte layout, AEAD choice for body, padding schemes).
- Layer 26 untouched in this pass.
**Stopping point**: merged to master; branch deleted.
---
## 2026-04-23 — primary Claude (Lead) — `docs/fof-spec-skeleton`
**Started**: late April 23 UTC