# 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 2–3, 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, // 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, 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.