itsgoin/docs/fof-spec/layer-1-vouch-primitive.md
Scott Reimers b8b38a6f03 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>
2026-04-24 07:38:12 -04:00

250 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Layer 1 — Vouch Primitive
**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.
---
## 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}`.
- 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.** 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.
---
## Data model
### `vouch_keys_own` table
Per-persona, stores the persona's own `V_me` history (current + recent-past for graceful rotation).
```
vouch_keys_own(
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 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 issued V_x
epoch INTEGER,
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)
)
```
### `vouch_bio_scan_cache` table
Skips re-scanning bio posts that haven't changed since last attempt.
```
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
}
```
Per-recipient wrapper construction (RFC 9180 HPKE sealing, single-shot mode):
```
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.
---
## UI / UX
Minimum viable surface for Layer 1 ship:
- **Persona screen**: "Vouch for someone" action. Picker of contacts. Adds their persona to `own_vouch_targets`; republishes bio post with new batch on save.
- **Persona screen**: "Who has vouched for me" list (reads `vouch_keys_received` grouped by `owner_id`).
- **Persona screen**: "People I've vouched for" list (reads `own_vouch_targets` where `current = 1`).
- **Settings**: "Rotate my vouch key" generates new `V_me` epoch, republishes bio post with wrappers under the new key for every current target.
- **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
- **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.
- 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.
- 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.