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

14 KiB
Raw Blame History

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:

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.