From b8b38a6f030bf750d0be41f670b8dba102d64940 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Fri, 24 Apr 2026 07:38:12 -0400 Subject: [PATCH 01/34] =?UTF-8?q?docs:=20Layer=201=20=E2=80=94=20HPKE-seal?= =?UTF-8?q?ed=20vouch=20grants=20via=20bio=20post?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/fof-spec/README.md | 3 +- docs/fof-spec/layer-1-vouch-primitive.md | 234 +++++++++++++++++------ sessions.md | 30 +++ 3 files changed, 209 insertions(+), 58 deletions(-) diff --git a/docs/fof-spec/README.md b/docs/fof-spec/README.md index 02c1946..b10a731 100644 --- a/docs/fof-spec/README.md +++ b/docs/fof-spec/README.md @@ -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. --- diff --git a/docs/fof-spec/layer-1-vouch-primitive.md b/docs/fof-spec/layer-1-vouch-primitive.md index c0561a0..7556f70 100644 --- a/docs/fof-spec/layer-1-vouch-primitive.md +++ b/docs/fof-spec/layer-1-vouch-primitive.md @@ -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 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. --- @@ -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, // 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, + + 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. diff --git a/sessions.md b/sessions.md index bad3cfe..6106d6c 100644 --- a/sessions.md +++ b/sessions.md @@ -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 2–6 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 From 553fbd3a2091b27386c282b67cf3f2c71b57f8ec Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Fri, 24 Apr 2026 08:25:40 -0400 Subject: [PATCH 02/34] =?UTF-8?q?docs:=20Layer=202=20=E2=80=94=20CDN-verif?= =?UTF-8?q?ied=20FoF=20comments=20(per-V=5Fx=20keypair)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace single per-post priv_post with per-V_x (pub_x, priv_x). Post header publishes pub_post_set; comments declare pub_x_index; CDN propagation nodes verify group_sig + identity_sig against named pubkey before forwarding. Kills bandwidth-amplification DoS from admitted-but- malicious FoF members. Dual-derivation wrap slot (read → CEK, sign → priv_x) with shared structure Layer 3 inherits. Comments encrypted under CEK_comments so Mode 2 comments are genuinely FoF-read-gated, not just FoF-sign-filtered. Author-signed revocation diff appended to post; CDN honors per-chain revocation. Tradeoff: pub_x_index is a per-post voucher-chain pseudonym, re-randomized across posts. Accepted. Layer 3 banner added noting wrap-slot structure is now superseded by Layer 2's canonical form; full reconciliation deferred. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/fof-spec/README.md | 7 +- docs/fof-spec/layer-2-mode2-fof-comments.md | 242 +++++++++++++++----- docs/fof-spec/layer-3-mode1-fof-closed.md | 2 + sessions.md | 33 ++- 4 files changed, 225 insertions(+), 59 deletions(-) diff --git a/docs/fof-spec/README.md b/docs/fof-spec/README.md index b10a731..34f04c8 100644 --- a/docs/fof-spec/README.md +++ b/docs/fof-spec/README.md @@ -68,7 +68,9 @@ Build and ship bottom-up. Each layer is independently shippable and exercised be - **Vouch key (`V_x`)**: a symmetric key owned by persona `x`, distributed by `x` to everyone `x` vouches for. `V_me` refers to the current persona's own vouch key. - **Keyring**: for a given persona, the set of vouch keys held = `{V_me}` ∪ `{V_x : x vouched for me}`. - **Wrap slot**: an anonymous ciphertext in a post header, carrying the post's private material encrypted under some `V_x`. Readers trial-decrypt slots whose 2-byte prefilter tag matches an owned key. -- **pub_post / priv_post**: per-post ephemeral ed25519 keypair. `priv_post` is wrapped in the post's wrap slots; `pub_post` is in the header plaintext and used for group signature verification on comments. +- **pub_x / priv_x**: per-`V_x` ed25519 keypair (Layer 2). `priv_x` is wrapped in the sign-slot of each `V_x`; `pub_x` is published in the post's `pub_post_set`. Comments reference a `pub_x_index` so propagation nodes can verify the comment signature without unwrapping. Replaces the earlier single `pub_post/priv_post` per-post keypair. +- **pub_post_set**: list of all `pub_x` for a post's FoF set, in randomized order. Inline in post header. Comments reference entries by index. +- **revocation_list**: author-appended signed entries that tell CDN propagation nodes to drop comments under a named `pub_x`. Stops compromised voucher-chains at the propagation layer. - **Identity key**: persona's long-term ed25519 key, used to sign content as that persona. Distinct from `priv_post`. (In current ItsGoin terms: this is the persona's posting key. Worth naming-alignment review when specifying.) - **Vouch MAC**: `HMAC(V_x, post_id || comment_hash)` — 16B truncated. Identifies which `V_x` a commenter holds. Inside encrypted payload (Mode 1) or alongside plaintext (Mode 2). Used by author strict-mode to verify the commenter is reachable via a known vouch chain. @@ -79,7 +81,8 @@ Build and ship bottom-up. Each layer is independently shippable and exercised be - **`Circle` + `GroupEncrypted`** remain as-is for named explicit-membership groups. FoF is a separate visibility class, not a replacement. - **`PostVisibility`** gains one new variant (`FoFClosed`, from Layer 3). Mode 2 reuses `PostVisibility::Public` with extended `CommentPolicy`. - **`CommentPolicy`** gains one new variant for Layer 2 (Mode 2 comment gating). -- **`InlineComment`** gets optional `group_sig` + `vouch_mac` fields (back-compat via `#[serde(default)]`, same pattern as Phase 2e `ref_post_id`). +- **`InlineComment`** gets `pub_x_index`, `group_sig`, `identity_sig`, encrypted `ciphertext`, and inner `vouch_mac` (inside the ciphertext). Back-compat via `#[serde(default)]`, same pattern as Phase 2e `ref_post_id`. +- **Propagation-node accept rule** for comments on FoF posts: valid `pub_x_index` + not in `revocation_list` + `group_sig` verifies + `identity_sig` verifies. Any failure → drop without forwarding. Makes bandwidth-amplification DoS infeasible. - **`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. diff --git a/docs/fof-spec/layer-2-mode2-fof-comments.md b/docs/fof-spec/layer-2-mode2-fof-comments.md index eac2e96..83a3a8c 100644 --- a/docs/fof-spec/layer-2-mode2-fof-comments.md +++ b/docs/fof-spec/layer-2-mode2-fof-comments.md @@ -1,28 +1,44 @@ -# Layer 2 — Mode 2: Public Posts with FoF-Gated Comments +# Layer 2 — Mode 2: Public Posts with FoF-Gated Comments (CDN-Verified) -**Scope**: Extend `CommentPolicy` with a `FriendsOfFriends` variant. Post body is public (indexable, cacheable, shardable via existing CDN). Comments on the post must prove FoF-reachability to the author before being accepted. +**Scope**: Extend `CommentPolicy` with a `FriendsOfFriends` variant. Post body is public (indexable, cacheable, shardable via existing CDN). Comments on the post are encrypted, signed under a per-voucher-chain keypair, and **verified at the CDN/propagation layer** before forwarding. -This layer ships before Mode 1 because it reuses the existing public-post path and only adds a verification gate on comments. No new `PostVisibility` variant needed. +This layer also defines the shared wrap-slot + `pub_post_set` structure reused by Layer 3 (Mode 1). The slot design here is the canonical form; Layer 3 inherits it and adds body encryption on top. + +--- + +## Why CDN-level verification + +Naïve single-keypair design (initial skeleton) had an attacker-in-FoF-set problem: any admitted FoF member can sign junk with the shared `priv_post` and amplify bandwidth across the mesh before the author's render-time filter catches it. `vouch_mac` attribution helps the author trace abuse but doesn't stop propagation — an attacker who refuses to include `vouch_mac` simply produces CDN-valid junk at will. + +**Fix**: each `V_x` gets its own `(pub_x, priv_x)` keypair. The post header publishes the full `pub_post_set` of all admitted `pub_x`. Comments declare which `pub_x` signed. Propagation nodes verify the signature against the named pubkey before forwarding. Propagation nodes also honor an author-published revocation list, stopping a compromised chain at the CDN. + +Cost: `pub_x_index` is a per-post pseudonym for the voucher-chain — leaks "these N comments came through the same chain" to observers. Bounded to a single post (new post → new mapping order). Acceptable tradeoff for propagation-level DoS resistance. --- ## Goal -- An author creates a public post with `comment_policy = FriendsOfFriends`. -- Body is encrypted to no one — plaintext in the CDN, same as a normal public post today. -- Comments on the post include a proof artifact that lets readers (and the author in strict mode) verify the commenter is reachable through the author's FoF graph. -- Non-FoF readers can still READ the post. They cannot post an accepted comment. +- Author creates a public post with `comment_policy = FriendsOfFriends`. +- Body is plaintext in the CDN (unchanged public-post path). +- Comments are ChaCha20-Poly1305 encrypted under a CEK that only FoF members can unwrap; non-FoF observers cannot read comment bodies at all (stronger than skeleton draft). +- Every comment carries `pub_x_index + group_sig + identity_sig`. Propagation nodes verify all three before forwarding. +- Author can append to a signed `revocation_list` that propagation nodes honor on next sync. +- Inner `vouch_mac` is retained for author-side attribution and intra-circle accountability. --- ## Lead decisions -- **Mode 2 does not gate readership.** The post body is genuinely public. Only comments are gated. This is intentional — it preserves CDN shardability for the body and gives authors a lightweight "comments to my circle, body to the world" mode. -- **`pub_post` is included in the public post header.** Every FoF-eligible post (both modes) has a per-post ephemeral ed25519 keypair. `pub_post` in the header, `priv_post` in wrap slots (Mode 1) or directly in a control record (Mode 2 — TBD below). -- **Comments carry a `group_sig` signed with `priv_post`.** Any device that can unwrap `priv_post` (i.e., holds a matching `V_x`) can sign. `group_sig` verification against `pub_post` is the cryptographic proof of FoF-reachability. Verifiable by any observer holding the public post. -- **Optional `vouch_mac` for strict mode.** `HMAC(V_x, post_id || comment_hash)` identifies *which* voucher's chain a commenter holds. Author can run in strict mode and reject comments whose `vouch_mac` doesn't match a `V_x` the author has a record of distributing. Non-strict mode accepts any valid `group_sig`. -- **Comment still signed by commenter's identity key as well.** `group_sig` proves FoF-membership; identity sig binds the comment to a specific persona for display / abuse reporting / author-side blocklist. -- **Non-FoF devices can still render the post.** Read path is unchanged — comments missing `group_sig` (or failing verification) are filtered out in the feed renderer rather than hard-rejected in storage. This leaves forensic traces and is cheaper than re-verifying in the renderer vs at ingest. TBD open question below. +- **Per-`V_x` signing keypair `(pub_x, priv_x)`.** Generated when `V_x` is generated (Layer 1). Rotated as a bundle when `V_x` rotates. +- **`pub_post_set` is inline in the post header.** List of all admitted `pub_x` for this post's FoF set, random-order per publish. Comments reference entries by index. +- **Dual-derivation wrap slots.** Each slot yields BOTH a shared `CEK` (read) and a per-`V_x` `priv_x` (sign). One wrap slot unwrapped = reader gets both capabilities. +- **Comments are encrypted.** `CEK_comments = HKDF(CEK, "comments")`. Non-FoF observers see only ciphertext + signatures on comments. Read-gating is a side effect of the same slot unwrap. +- **CDN propagation verifies before forwarding.** Four-check accept rule: valid `pub_x_index`, not in `revocation_list`, `group_sig` validates, `identity_sig` validates. Any failure → drop, do not propagate. +- **Revocation is author-signed append-only diff to the post.** Appends a revoked `pub_x` entry. Propagation nodes honor on sync. Comments under revoked `pub_x` stop propagating. +- **`vouch_mac` retained.** Inside ciphertext. Enables author render-time and intra-circle attribution complementary to CDN-level `pub_x_index` attribution. +- **Author has their own `pub_me/priv_me`.** Treated as one of the entries in `pub_post_set`. Author signs their own comments through the same path. +- **`V_me` handed to vouchees already implies `priv_me` handoff.** No — correction: `priv_x` is wrapped in the per-`V_x` sign-slot of every post, not handed out at vouch time. Only people who can unwrap the slot (= people holding `V_x`) receive `priv_x`. This is what makes per-post rotation (Layer 4) a coherent revocation surface. +- **v1 ships at Ed25519 sizes with inline `pub_post_set`.** PQ migration (ML-DSA-65 at ~2KB/pubkey) requires Merkle-commit over `pub_post_set` with per-comment inclusion proofs. Design must not preclude — spec shape above is algorithm-agnostic. --- @@ -39,89 +55,203 @@ pub enum CommentPolicy { } ``` -### Post header additions (for posts with `comment_policy = FriendsOfFriends`) +### Post header additions (for `comment_policy = FriendsOfFriends`) ```rust struct PostHeader { // ... existing fields ... - // NEW for Mode 2 comment gating: - pub_post: Option<[u8; 32]>, // ed25519 public key of per-post ephemeral keypair - wrap_slots: Vec, // wraps priv_post under each V_x in author's keyring - - // TBD — OPUS: WrapSlot byte layout (same type as Layer 3 uses) + pub_post_set: Vec<[u8; 32]>, // all admitted pub_x, random-order + wrap_slots: Vec, // one per V_x (padded to power-of-2 bucket) + revocation_list: Vec, // initially empty; appended via signed diffs + slot_count: u32, // padded count (for bucket alignment) + author_sig: [u8; 64], // ed25519 sig over the header } ``` -**Note**: Mode 2 posts carry `wrap_slots` even though the body is public, because commenters need `priv_post` to sign. The wrap slot set IS the FoF membership definition. +### `WrapSlot` byte layout (shared with Layer 3) + +```rust +struct WrapSlot { + prefilter_tag: [u8; 2], // HMAC(V_x, post_id)[:2B] + read_slot: SlotPart, // AEAD under key = KDF(V_x, post_id, "read") + sign_slot: SlotPart, // AEAD under key = KDF(V_x, post_id, "sign") +} + +struct SlotPart { + nonce: [u8; 12], + ciphertext: Vec, // read: 32B CEK; sign: 32B priv_x seed + tag: [u8; 16], +} +``` + +Read-slot plaintext: 32B CEK. +Sign-slot plaintext: 32B `priv_x` ed25519 seed. + +AAD for both AEAD invocations: `post_id` (prevents slot-reuse across posts). + +**TBD — OPUS**: confirm ChaCha20-Poly1305 for slot AEAD; confirm AAD choice. + +### `RevocationEntry` + +```rust +struct RevocationEntry { + revoked_pub_x: [u8; 32], // the pub_x to drop from acceptance + revoked_at_ms: u64, + reason_code: u8, // opaque to CDN; for author-side UI + author_sig: [u8; 64], // author identity-key sig over (post_id || revoked_pub_x || revoked_at_ms || reason_code) +} +``` ### Extend `InlineComment` ```rust struct InlineComment { - // ... existing fields ... + // CDN-visible: + parent_post_id: PostId, + ciphertext: Vec, // AEAD(CEK_comments, nonce, plaintext, aad=parent_post_id) + nonce: [u8; 12], + aead_tag: [u8; 16], + pub_x_index: u32, // index into parent post's pub_post_set + group_sig: [u8; 64], // ed25519 under priv_x over (ciphertext || parent_post_id || pub_x_index) + commenter_id: NodeId, // commenter's long-term identity pubkey + identity_sig: [u8; 64], // ed25519 under commenter's identity key over same tuple - #[serde(default, skip_serializing_if = "Option::is_none")] - group_sig: Option<[u8; 64]>, // ed25519 signature over comment_hash, verifies against parent post's pub_post - - #[serde(default, skip_serializing_if = "Option::is_none")] - vouch_mac: Option<[u8; 16]>, // HMAC(V_x, post_id || comment_hash), truncated + // Plaintext inside ciphertext (FoF-readable): + // comment_body + // vouch_mac: [u8; 16] // HMAC(V_x, post_id || comment_hash)[:16B] + // parent_comment_id: Option } ``` -Back-compat: old comments without these fields are treated as "not FoF-signed" — accepted on non-FoF posts, rejected on FoF posts. +Back-compat: older clients / non-FoF posts use the existing `InlineComment` shape unchanged. New fields appear only for comments on FoF-policy posts. + +### `CEK_comments` derivation + +``` +CEK_comments = HKDF-Expand( + HKDF-Extract(salt=post_id, ikm=CEK), + info = "itsgoin/fof-comments/v1", + L = 32 +) +``` + +**TBD — OPUS**: confirm domain separation. One CEK per post, one derived comments-CEK per post. All comments on the post share the same `CEK_comments` (nonce uniqueness per comment via random nonce). --- -## Comment creation (author of the comment) +## Comment creation (FoF commenter) -1. Commenter fetches parent post. Reads `pub_post` from header. -2. Commenter iterates their keyring. For each `V_x` held, computes `HMAC(V_x, post_id)[:2B]`. Compares against each `WrapSlot.prefilter_tag`. On match, trial-decrypts the slot to get `priv_post`. -3. If any slot unwraps successfully: commenter now holds `priv_post`. Signs `comment_hash` to produce `group_sig`. Computes `vouch_mac = HMAC(V_x_winner, post_id || comment_hash)`. Attaches both to comment. -4. Publishes comment through normal comment-propagation path. +1. Commenter fetches parent post. Reads `pub_post_set`, `wrap_slots`, `revocation_list`. +2. For each `V_x` in reader's keyring, match prefilter tag against slot list. On match, attempt read-slot and sign-slot AEAD-open. +3. On successful unwrap: now holds `CEK`, `priv_x`. Derive `CEK_comments = HKDF(CEK, ...)`. +4. Build comment: + - Encrypt `(body || vouch_mac || parent_comment_id)` under `CEK_comments` with random nonce. + - Sign `(ciphertext || parent_post_id || pub_x_index)` with `priv_x` → `group_sig`. + - Sign the same tuple with commenter's identity key → `identity_sig`. + - Determine `pub_x_index` by finding `pub_x` in `pub_post_set` (where `pub_x = ed25519_pub(priv_x_seed)`). +5. Check `pub_x ∉ revocation_list`. If revoked: UI tells commenter they can no longer comment on this post; abort. +6. Publish via normal comment-propagation path. -If NO slot unwraps: commenter is not in the author's FoF set. UI reports "You can't comment on this post." Pure client-side enforcement — no wire attempt. +If no slot unwraps: commenter is not in the author's FoF set. Client-side: hide the comment box. --- -## Comment verification (reader side) +## Propagation-node accept rule -At feed render time, for every comment on a `FriendsOfFriends`-policy post: +For every incoming `InlineComment` targeting a FoF-policy post: -1. If `group_sig` is missing → filter out (strict) or show with "unverified" badge (permissive). Lead leaning: **filter out**. -2. Verify `group_sig` against parent post's `pub_post` over `comment_hash`. If fail → filter out. -3. Verify identity sig of comment as normal. If fail → filter out. +1. **Valid index**: `pub_x_index < pub_post_set.len()`. Else drop. +2. **Not revoked**: `pub_post_set[pub_x_index] ∉ revocation_list`. Else drop. +3. **`group_sig` valid**: ed25519 verify `group_sig` over `(ciphertext || parent_post_id || pub_x_index)` against `pub_post_set[pub_x_index]`. Else drop. +4. **`identity_sig` valid**: ed25519 verify `identity_sig` over the same tuple against `commenter_id`. Else drop. -At author side (strict mode, optional): +On drop: do not store, do not forward. No error response to sender (avoid oracle). -4. Recompute expected `vouch_mac` candidates from the set of `V_x` the author has distributed. If `comment.vouch_mac` doesn't match any → discard at ingest (don't store, don't propagate). +Rate-limit per `commenter_id` and per `pub_x_index` (operational knob, not in spec). --- -## Propagation +## Reader side (FoF) -No changes to comment propagation path. Comments still flow via existing `BlobHeaderDiff` / engagement-diff mechanism. Verification is done at display time (reader) and optionally at ingest (author-strict). +1. For each visible comment, use `CEK_comments` to decrypt ciphertext. +2. Verify `vouch_mac` matches a `V_x` the reader (or author in strict mode) can compute against. Optional render-time check; CDN already validated signatures. +3. Render comment body, parent threading via `parent_comment_id`. + +Reader side (non-FoF): sees comment ciphertext + signature fields only. Body unreadable. UI can show "N FoF comments (not visible to you)" or suppress entirely. + +--- + +## Revocation flow + +1. Author's client detects abuse (via `vouch_mac` attribution, or repeated `pub_x_index` with bad behavior). +2. Author builds `RevocationEntry { revoked_pub_x, timestamp, reason_code, author_sig }`, publishes as a header-diff on the post (same propagation primitive as engagement diffs). +3. Propagation nodes merge `revocation_list` on next sync. Subsequent comments under the revoked `pub_x` fail step (2) of the accept rule. +4. Comments already propagated remain in storage unless GC'd separately (future work). +5. If the attacker's voucher-chain is broadly compromised, author can escalate to full `V_x` rotation (Layer 1), which affects every post using that chain — coarse but definitive. + +--- + +## Size budget + +**v1 (Ed25519)** + +| Per-vouchee slot cost | Bytes | +|---|---| +| `pub_x` | 32 | +| `read_slot` (32B CEK + 12B nonce + 16B tag) | 60 | +| `sign_slot` (32B priv_x + 12B nonce + 16B tag) | 60 | +| `prefilter_tag` | 2 | +| **Subtotal per slot** | **~154** | + +At 500 vouchees: ~77KB header. Padded to 512 bucket ≈ 79KB. Acceptable for a header carried once per post. + +Per-comment CDN overhead (vs shared-keypair baseline): +64B (`group_sig`) + 4B (`pub_x_index`) + 64B (`identity_sig` — already in baseline) ≈ 68B additional. Negligible. + +**PQ future (ML-DSA-65)** + +`pub_x` ~1952B, sig ~3300B. Inline `pub_post_set` at 500 vouchees = ~976KB header. Not viable. + +Plan: Merkle-commit `pub_post_set`. Header carries 32B root. Each comment carries its `pub_x + inclusion_proof` (~288B at n=500) + the ~3.3KB sig. Header stays O(1); per-comment grows O(log n). + +Decision: **defer Merkle variant to PQ migration**. v1 ships inline. + +--- + +## Privacy tradeoff (accepted) + +`pub_x_index` leaks per-post voucher-chain pseudonym to CDN + public observers. "All comments signed under slot 42 came through the same chain" — within a single post. Cross-post, the index-to-chain mapping re-randomizes (new `pub_post_set` order on each publish). + +The reason this is acceptable: the alternative is no propagation-level verification, which means any one admitted FoF member can DoS the mesh. Per-post pseudonym is the minimum viable disclosure for CDN-level filtering. --- ## Open questions -- **Filter-out vs reject-at-storage for bad `group_sig`.** Filter-out preserves forensic trail and avoids double-verify cost. Reject-at-storage saves disk and sync bandwidth. Lead leaning: filter-out at render, reject-at-storage only on author-side strict mode. -- **`priv_post` distribution for Mode 2.** In Mode 1, `priv_post` is inside `wrap_slots`. In Mode 2, body is plaintext, but commenters still need `priv_post`. Options: (A) `wrap_slots` still present in Mode 2 headers, carrying only `priv_post`; (B) separate `comment_priv_key` control record distributed out of band. Lead leaning: A (uniform structure with Mode 1). -- **Wrap slot prefilter tag.** 2B = 65536 buckets, false-positive 1/65536. For Mode 2 this also defines the commenter-eligibility filter. TBD — OPUS: confirm 2B is enough for realistic keyring sizes. -- **Author's own comments.** Author has `priv_post` directly (generated it). Do they self-vouch-mac against `V_me`? Leaning: yes, so strict mode uniformly verifies all comments. -- **Displaying vouch path to the author.** Mode 2 strict mode knows which `V_x` a commenter arrived through. Should the author's UI show "comment arrived via your vouch to Alice" or keep it opaque? Lead leaning: opaque by default; optional power-user setting. -- **Rate-limiting / spam.** A malicious FoF member can flood comments. `vouch_mac` identifies the chain, so author can block a chain. Out-of-scope for Layer 2 ship (tracked separately). +- **Reader-only clients can skip the sign-slot.** They can short-circuit AEAD on sign-slot and save some work. Worth implementing? Lead leaning: yes, minor perf win; `read` slot succeeding already proves FoF-admission. +- **Author's own comments.** Author holds `priv_me` directly (generated alongside `V_me` + `pub_me` at `V_me` genesis — OR regenerated per-post as part of the FoF set assembly — see TBD below). `pub_me` is one of the entries in `pub_post_set`. Author comments pass the same accept rule. Strict mode optional. +- **When is `(pub_x, priv_x)` generated?** Two options: + - (A) At `V_x` genesis (Layer 1): generated once per `V_x`, stable across all posts. `pub_x` published via bio-post alongside `V_x` wrappers; `priv_x` held by holders of `V_x`. + - (B) Per-post: author generates fresh `(pub_x, priv_x)` for each slot when assembling a post, wraps `priv_x` in the sign-slot. + + **(B) is cleaner** because per-post rotation (Layer 4) already re-wraps slots; no coordination with Layer 1 genesis. **(A) is cheaper** because keypair generation is once-per-vouch-grant instead of once-per-post-per-vouchee. Lead leaning: **(B) per-post**, matches the anonymous-slot model and keeps Layer 1 untouched. +- **`revocation_list` propagation ordering.** Comments that arrive at a propagation node before the revocation diff arrives will propagate. The revocation applies from its sync-time forward. Acceptable window; not a correctness issue but a propagation-latency consideration. +- **Dummy padding slots.** Real slot = prefilter + read + sign ≈ 154B. Dummy must be byte-identical in size. Random bytes that AEAD-fail on any `V_x`. Confirmed indistinguishable structurally. +- **`pub_post_set` padding.** Currently sized 1:1 with `wrap_slots` (padded together). Confirm alignment is correct — `pub_x_index` indexes into `pub_post_set` only; dummy slots need dummy pubkeys? Or: `pub_post_set` carries only real pubkeys, `wrap_slots` is padded. A comment's `pub_x_index` would always point at real entries; dummies exist only in wrap_slots to hide the vouch count. Needs resolution. +- **Rate limiting.** Operational knob. Per-`commenter_id` + per-`pub_x_index` caps in propagation-node config. Out of spec. +- **Non-FoF rendering of comment count.** Do non-FoF readers see "42 FoF comments" or nothing? Privacy: the count leaks engagement. Lead leaning: show count (same as existing Public comment counts; engagement is already a public signal). +- **Author's copy of CEK.** Author generated CEK at post creation; does author store it locally keyed by post_id, or unwrap via their own slot like any reader? Lead leaning: local-cache at creation; unwrap-fallback if cache lost. --- ## Ship criteria for Layer 2 -- `CommentPolicy::FriendsOfFriends` variant exists end-to-end (storage, protocol, UI picker). -- Authors can create public posts with FoF-gated comments. -- `pub_post` / `priv_post` / `wrap_slots` generated on post creation, wrapped under author's full keyring. -- Commenters: client-side check of FoF eligibility before offering comment box; `group_sig` + `vouch_mac` attached on send. -- Readers: filter-out comments failing `group_sig` verification. -- Author strict-mode: optional ingress rejection on unknown `vouch_mac`. -- Back-compat: old clients see FoF posts as readable (body is public) but can't comment (missing `priv_post`); old comments on new posts are filtered at render. -- Integration test: 3-node FoF chain (A→B→C). A posts Mode 2. B comments (reachable). C comments (reachable via B). D (unrelated) cannot. +- `CommentPolicy::FriendsOfFriends` end-to-end (storage, protocol, UI picker). +- Dual-derivation wrap slot: read → CEK, sign → priv_x. AEAD with `post_id` AAD. +- `pub_post_set` inline in header; `pub_x_index` on comments. +- CEK_comments derived via HKDF; all comment bodies encrypted. +- Propagation nodes enforce four-check accept rule before forwarding. +- Revocation diff format + CDN honor path. +- Per-post ephemeral-keypair generation (option B above, pending decision). +- Back-compat: old clients can still render existing non-FoF posts; old comments on new FoF posts are rejected at CDN (missing required fields). +- Integration test: 3-node FoF chain (A→B→C). A posts Mode 2 FoF. B comments (accepted, propagates). C comments (accepted via B's chain). D (unrelated) signs junk with fake `pub_x_index` (rejected at first-hop CDN). A revokes B's `pub_x`. C's subsequent comments (which went through B's chain) fail CDN verification and stop propagating. diff --git a/docs/fof-spec/layer-3-mode1-fof-closed.md b/docs/fof-spec/layer-3-mode1-fof-closed.md index 6b5d8c0..795b744 100644 --- a/docs/fof-spec/layer-3-mode1-fof-closed.md +++ b/docs/fof-spec/layer-3-mode1-fof-closed.md @@ -1,5 +1,7 @@ # Layer 3 — Mode 1: `FOF_CLOSED` Posts +> **⚠️ Partially superseded by Layer 2 rewrite (2026-04-24).** Layer 2 now defines the canonical wrap-slot structure (dual read/sign derivation), `pub_post_set`, per-`V_x` signing keypair, and CDN-level verification. Layer 3 inherits all of that unchanged — the Mode 1 vs Mode 2 distinction reduces to "body encrypted under CEK (Mode 1) vs body plaintext (Mode 2)." The sections below still reflect the earlier single-keypair design and will be reconciled when Scott + Opus review Layer 3. + **Scope**: New `PostVisibility::FoFClosed` variant. Both post body AND comments are gated to the FoF graph. Body is encrypted; readership emerges from keyring intersection with `wrap_slots`. Builds on Layer 2's `pub_post` / `priv_post` / `wrap_slot` primitives — same structures, just that the CEK encrypting the body is *also* in the wrap slots (alongside `priv_post`). diff --git a/sessions.md b/sessions.md index 6106d6c..94498be 100644 --- a/sessions.md +++ b/sessions.md @@ -32,7 +32,38 @@ See `CONTRIBUTING.md` for the protocol. See `AGENTS.md` for the Claude-specific - Opus confirmation passes still open on other layers (WrapSlot byte layout, AEAD choice for body, padding schemes). - Layer 2–6 untouched in this pass. -**Stopping point**: merged to master; branch deleted. +**Stopping point**: Scott asked to hold merges until Layers 2–6 iterations complete. Branch stays open locally and on Forgejo; continuing to stack commits on it. + +### Update 2026-04-24 — Layer 2 rewrite (CDN-level verification) + +**Scope**: Scott shared Opus's Layer 2 design answer. Folded in. + +**Design commitments added**: +- **Per-`V_x` signing keypair `(pub_x, priv_x)`** — replaces single per-post `pub_post/priv_post`. CDN can now verify comment signatures against a published `pub_post_set` before forwarding, killing the bandwidth-amplification DoS an admitted FoF member could otherwise mount. +- **Dual-derivation wrap slot**: `read_slot → CEK`, `sign_slot → priv_x`. One unwrap yields both capabilities. Slot structure is shared with Layer 3 (canonical form lives here). +- **Comment body encrypted under `CEK_comments = HKDF(CEK, "comments")`** — Mode 2 comments are genuinely FoF-read-gated now, not just FoF-sign-filtered at render (strengthening vs skeleton). +- **Propagation-node four-check accept rule**: valid `pub_x_index`, not in `revocation_list`, `group_sig` verifies, `identity_sig` verifies. Any fail → drop without forwarding. +- **Author-signed revocation diff** appended to post header; CDN honors on next sync. Per-chain revocation at propagation layer. +- **`pub_x_index` is a per-post pseudonym** — leaks "these N comments came through the same chain" within a single post; re-randomizes across posts. Accepted tradeoff for CDN-level DoS resistance. +- **v1 ships Ed25519 inline** (~77KB header at 500 vouchees). **PQ future** requires Merkle-commit over `pub_post_set` with per-comment inclusion proofs; deferred but spec shape doesn't preclude. + +**Files touched**: +- `docs/fof-spec/layer-2-mode2-fof-comments.md` — rewritten end-to-end. +- `docs/fof-spec/layer-3-mode1-fof-closed.md` — prominent "partially superseded" banner added; body retained pending reconciliation when Scott + Opus review Layer 3. +- `docs/fof-spec/README.md` — glossary updated (`pub_x`/`priv_x`, `pub_post_set`, `revocation_list`); integration bullet updated for new `InlineComment` fields + CDN accept rule. + +**Open questions I raised back to Scott** (awaiting his answer before finalizing): +1. `(pub_x, priv_x)` lifecycle: generated at `V_x` genesis (Layer 1) and stable across posts, vs regenerated per-post by author. Lead leaning per-post. Needs confirmation. +2. `pub_post_set` padding vs `wrap_slots` padding — real/dummy alignment when dummies shouldn't be indexable by `pub_x_index`. +3. Non-FoF rendering of comment count (reveal engagement? suppress?). +4. Who holds `priv_me` (author) — generated alongside `V_me` at Layer 1, vs per-post regeneration. Same as #1 but for author's own entry. + +**Pending**: +- Scott reviews / answers open questions. +- Layer 3 reconciliation when Scott + Opus get to Mode 1. +- Layers 4–6 iterations. + +**Stopping point**: commit `b8b38a6` (Layer 1) + new commit for Layer 2 both on branch; not merged. Awaiting Scott. --- From a79cab049f2f59d0441214f0fb9bce131b742104 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Fri, 24 Apr 2026 10:37:24 -0400 Subject: [PATCH 03/34] =?UTF-8?q?docs:=20Layer=202=20round=202=20=E2=80=94?= =?UTF-8?q?=20resolve=205=20questions=20+=20access-grant=20primitive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold in Scott's answers: - Per-post (pub_x, priv_x); confirmed. - Random rand(32..=128) dummy padding replaces power-of-2 buckets; dummy pubkeys in pub_post_set so .len() == wrap_slots.len(). Floor count is unrecoverable across multiple posts. - Non-FoF UX: "Comments are private" + optional "Request access via DM" button. No count leak. - Author's own (pub_me, priv_me) in pub_post_set; confirmed. - Revocation is retroactive delete + forward: file-holders delete locally-stored comments signed by revoked pub_x on diff arrival, then propagate. Stronger than stop-forwarding. New primitive: access-grant author comment. Author appends a WrapSlot + pub_post_set entry for a newly-vouched persona via a signed special comment — retroactive read widening without republish. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/fof-spec/layer-2-mode2-fof-comments.md | 98 ++++++++++++++++----- sessions.md | 18 ++++ 2 files changed, 92 insertions(+), 24 deletions(-) diff --git a/docs/fof-spec/layer-2-mode2-fof-comments.md b/docs/fof-spec/layer-2-mode2-fof-comments.md index 83a3a8c..b53f84d 100644 --- a/docs/fof-spec/layer-2-mode2-fof-comments.md +++ b/docs/fof-spec/layer-2-mode2-fof-comments.md @@ -29,12 +29,14 @@ Cost: `pub_x_index` is a per-post pseudonym for the voucher-chain — leaks "the ## Lead decisions -- **Per-`V_x` signing keypair `(pub_x, priv_x)`.** Generated when `V_x` is generated (Layer 1). Rotated as a bundle when `V_x` rotates. +- **Per-`V_x` signing keypair `(pub_x, priv_x)` is per-post.** Author generates a fresh `(pub_x, priv_x)` for each slot when assembling a post, wraps `priv_x` in that slot's sign-part. No coordination with Layer 1. Per-post rotation (Layer 4) just re-generates all keypairs and re-wraps. Not stable across posts. - **`pub_post_set` is inline in the post header.** List of all admitted `pub_x` for this post's FoF set, random-order per publish. Comments reference entries by index. - **Dual-derivation wrap slots.** Each slot yields BOTH a shared `CEK` (read) and a per-`V_x` `priv_x` (sign). One wrap slot unwrapped = reader gets both capabilities. - **Comments are encrypted.** `CEK_comments = HKDF(CEK, "comments")`. Non-FoF observers see only ciphertext + signatures on comments. Read-gating is a side effect of the same slot unwrap. - **CDN propagation verifies before forwarding.** Four-check accept rule: valid `pub_x_index`, not in `revocation_list`, `group_sig` validates, `identity_sig` validates. Any failure → drop, do not propagate. -- **Revocation is author-signed append-only diff to the post.** Appends a revoked `pub_x` entry. Propagation nodes honor on sync. Comments under revoked `pub_x` stop propagating. +- **Revocation is retroactive.** Revocation diff is author-signed, appends a revoked `pub_x` entry. Every file-holder that receives the diff **deletes all comments on this post signed by that `pub_x`** from local storage, then forwards the diff. Comments in flight before the diff arrived get deleted when it catches up. Stronger than "stop forwarding" — prior garbage also goes away. +- **Random-count dummy padding, not fixed buckets.** Append `rand(32..=128)` dummy slots (each real-shape, indistinguishable). Observers know a random amount was added; they cannot infer the floor count across multiple posts from the same author. Dummy `pub_x` entries are included in `pub_post_set` 1:1 so `pub_post_set.len() == wrap_slots.len()`; dummy pubkeys are 32B random bytes (no one holds the matching `priv_x`, so `group_sig` verify against a dummy always fails — benign). +- **Post-hoc read-access grant via author comment.** Author can widen the read-set of an already-published post by publishing an author-signed special comment that appends a new `WrapSlot` (and its `pub_post_set` entry) for a newly-vouched persona. No rotation, no republish; retroactive inclusion. See "Access-grant author comment" below. - **`vouch_mac` retained.** Inside ciphertext. Enables author render-time and intra-circle attribution complementary to CDN-level `pub_x_index` attribution. - **Author has their own `pub_me/priv_me`.** Treated as one of the entries in `pub_post_set`. Author signs their own comments through the same path. - **`V_me` handed to vouchees already implies `priv_me` handoff.** No — correction: `priv_x` is wrapped in the per-`V_x` sign-slot of every post, not handed out at vouch time. Only people who can unwrap the slot (= people holding `V_x`) receive `priv_x`. This is what makes per-post rotation (Layer 4) a coherent revocation surface. @@ -61,10 +63,9 @@ pub enum CommentPolicy { struct PostHeader { // ... existing fields ... - pub_post_set: Vec<[u8; 32]>, // all admitted pub_x, random-order - wrap_slots: Vec, // one per V_x (padded to power-of-2 bucket) + pub_post_set: Vec<[u8; 32]>, // real pub_x + dummy pubkeys, random-order; .len() == wrap_slots.len() + wrap_slots: Vec, // real slots + rand(32..=128) dummy slots, shuffled revocation_list: Vec, // initially empty; appended via signed diffs - slot_count: u32, // padded count (for bucket alignment) author_sig: [u8; 64], // ed25519 sig over the header } ``` @@ -186,9 +187,50 @@ Reader side (non-FoF): sees comment ciphertext + signature fields only. Body unr 1. Author's client detects abuse (via `vouch_mac` attribution, or repeated `pub_x_index` with bad behavior). 2. Author builds `RevocationEntry { revoked_pub_x, timestamp, reason_code, author_sig }`, publishes as a header-diff on the post (same propagation primitive as engagement diffs). -3. Propagation nodes merge `revocation_list` on next sync. Subsequent comments under the revoked `pub_x` fail step (2) of the accept rule. -4. Comments already propagated remain in storage unless GC'd separately (future work). -5. If the attacker's voucher-chain is broadly compromised, author can escalate to full `V_x` rotation (Layer 1), which affects every post using that chain — coarse but definitive. +3. On receiving a revocation diff, every file-holder of the affected post: + - **Deletes** all locally-stored comments on this post where `pub_x_index` points to the revoked `pub_x`. + - Appends the entry to its local copy of `post.revocation_list`. + - Forwards the revocation diff to neighbors. + - Rejects any subsequent incoming comments matching the revoked `pub_x` at step (2) of the accept rule. +4. Idempotent: re-receiving a revocation diff is a no-op (the entry is already present; deletion has already happened). +5. Retroactive: comments that propagated before the diff existed get deleted as the diff catches up to each holder. There's a propagation-latency window where deleted comments may still be visible on yet-to-receive holders, but the garbage is bounded and self-cleaning. +6. If the attacker's voucher-chain is broadly compromised, author can escalate to full `V_x` rotation (Layer 1), which affects every post using that chain — coarse but definitive. + +--- + +## Access-grant author comment (post-hoc widening) + +When the author vouches for a new persona AFTER publishing a FoF post, they may want that persona to retroactively gain read access. Full republish would rewrite the post ID and lose engagement history. Instead, the author publishes a special access-grant comment. + +### `AccessGrantComment` (a distinguished `InlineComment` variant) + +```rust +struct AccessGrantComment { + parent_post_id: PostId, + new_pub_x: [u8; 32], + new_wrap_slot: WrapSlot, // read + sign parts for the new V_x + granted_at_ms: u64, + commenter_id: NodeId, // must match post.author + identity_sig: [u8; 64], // author identity-key sig over (parent_post_id || new_pub_x || new_wrap_slot || granted_at_ms) +} +``` + +### Propagation-node handling + +- Accept iff `commenter_id == post.author` AND `identity_sig` validates against `post.author`. No `group_sig` / `pub_x_index` required — this is an author-signed header extension disguised as a comment. +- On accept: append `new_pub_x` to local `pub_post_set`, append `new_wrap_slot` to local `wrap_slots`. Forward the access-grant to neighbors. +- Subsequent comments from the newly-admitted persona reference the extended-set index (base_set.len() + grant_index). + +### Reader-side handling + +- New persona receives the access-grant via normal comment propagation. Unwraps `new_wrap_slot` with their `V_x` → gets `CEK` + `priv_x_new`. Can now read the body's comments and author their own. +- Existing readers see the access-grant as an informational entry (optional UI: "Author granted access to a new friend"). + +### Open question + +- **Does the UI expose this as a discrete action?** "Share this post with my new vouchee Bob" — yes, natural. Lead leaning: surface as a per-post affordance in author's post-detail view. +- **Does the access-grant also carry a signing capability for the new persona?** Yes — the `WrapSlot`'s sign-part wraps a fresh `priv_x_new`. New persona can comment going forward. +- **Does revocation apply to access-grant `pub_x` entries?** Yes, uniformly. Author can revoke a post-hoc-granted chain same as an originally-granted chain. --- @@ -204,7 +246,7 @@ Reader side (non-FoF): sees comment ciphertext + signature fields only. Body unr | `prefilter_tag` | 2 | | **Subtotal per slot** | **~154** | -At 500 vouchees: ~77KB header. Padded to 512 bucket ≈ 79KB. Acceptable for a header carried once per post. +At 500 real vouchees + `rand(32..=128)` dummies: header payload is 532–628 slots × ~154B = ~82KB to ~97KB. Acceptable for a header carried once per post. Per-comment CDN overhead (vs shared-keypair baseline): +64B (`group_sig`) + 4B (`pub_x_index`) + 64B (`identity_sig` — already in baseline) ≈ 68B additional. Negligible. @@ -220,27 +262,32 @@ Decision: **defer Merkle variant to PQ migration**. v1 ships inline. ## Privacy tradeoff (accepted) -`pub_x_index` leaks per-post voucher-chain pseudonym to CDN + public observers. "All comments signed under slot 42 came through the same chain" — within a single post. Cross-post, the index-to-chain mapping re-randomizes (new `pub_post_set` order on each publish). +`pub_x_index` leaks per-post voucher-chain pseudonym to CDN + public observers. "All comments signed under slot 42 came through the same chain" — within a single post. Cross-post, the index-to-chain mapping re-randomizes (new `pub_post_set` order on each publish), so cross-post correlation is broken. The reason this is acceptable: the alternative is no propagation-level verification, which means any one admitted FoF member can DoS the mesh. Per-post pseudonym is the minimum viable disclosure for CDN-level filtering. +**Vouch-set size:** random `rand(32..=128)` dummy padding means an observer sees `real_count + rand(32..=128)` and cannot infer the true count. Across multiple posts from the same author, the observer also cannot establish a lower bound — each post's padding is independent, so the minimum observed size across many posts only converges to `real_count + 32`, not to `real_count`. + +**Non-FoF reader UX:** non-FoF readers see the public body + "Comments are private" affordance. Comment count is not shown (no engagement leak). Optional: a "Request access via DM" button that sends the author a note; author can respond by publishing an access-grant author comment (above) that retroactively admits the requester. + --- ## Open questions - **Reader-only clients can skip the sign-slot.** They can short-circuit AEAD on sign-slot and save some work. Worth implementing? Lead leaning: yes, minor perf win; `read` slot succeeding already proves FoF-admission. -- **Author's own comments.** Author holds `priv_me` directly (generated alongside `V_me` + `pub_me` at `V_me` genesis — OR regenerated per-post as part of the FoF set assembly — see TBD below). `pub_me` is one of the entries in `pub_post_set`. Author comments pass the same accept rule. Strict mode optional. -- **When is `(pub_x, priv_x)` generated?** Two options: - - (A) At `V_x` genesis (Layer 1): generated once per `V_x`, stable across all posts. `pub_x` published via bio-post alongside `V_x` wrappers; `priv_x` held by holders of `V_x`. - - (B) Per-post: author generates fresh `(pub_x, priv_x)` for each slot when assembling a post, wraps `priv_x` in the sign-slot. - - **(B) is cleaner** because per-post rotation (Layer 4) already re-wraps slots; no coordination with Layer 1 genesis. **(A) is cheaper** because keypair generation is once-per-vouch-grant instead of once-per-post-per-vouchee. Lead leaning: **(B) per-post**, matches the anonymous-slot model and keeps Layer 1 untouched. -- **`revocation_list` propagation ordering.** Comments that arrive at a propagation node before the revocation diff arrives will propagate. The revocation applies from its sync-time forward. Acceptable window; not a correctness issue but a propagation-latency consideration. -- **Dummy padding slots.** Real slot = prefilter + read + sign ≈ 154B. Dummy must be byte-identical in size. Random bytes that AEAD-fail on any `V_x`. Confirmed indistinguishable structurally. -- **`pub_post_set` padding.** Currently sized 1:1 with `wrap_slots` (padded together). Confirm alignment is correct — `pub_x_index` indexes into `pub_post_set` only; dummy slots need dummy pubkeys? Or: `pub_post_set` carries only real pubkeys, `wrap_slots` is padded. A comment's `pub_x_index` would always point at real entries; dummies exist only in wrap_slots to hide the vouch count. Needs resolution. - **Rate limiting.** Operational knob. Per-`commenter_id` + per-`pub_x_index` caps in propagation-node config. Out of spec. -- **Non-FoF rendering of comment count.** Do non-FoF readers see "42 FoF comments" or nothing? Privacy: the count leaks engagement. Lead leaning: show count (same as existing Public comment counts; engagement is already a public signal). - **Author's copy of CEK.** Author generated CEK at post creation; does author store it locally keyed by post_id, or unwrap via their own slot like any reader? Lead leaning: local-cache at creation; unwrap-fallback if cache lost. +- **Access-grant dedup.** If the author accidentally publishes the same access-grant twice (or two devices race), propagation nodes must handle it idempotently. Keying by `(post_id, new_pub_x)` — duplicate = no-op. +- **Revocation of a `pub_x` that has no comments yet.** Fine: future comments under that `pub_x` will fail the accept rule, and the deletion step is a no-op. Confirmed harmless. +- **GC of `revocation_list`.** Grows unbounded across a post's lifetime. Realistically capped by vouch-set size. No GC needed in v1. + +## Resolved + +- **`(pub_x, priv_x)` lifecycle**: **per-post** (Scott confirmed). Author regenerates for each post's slot assembly. Matches the anonymous-slot model; no Layer 1 coordination. +- **`pub_post_set` vs `wrap_slots` alignment**: **1:1 with dummy pubkeys** (Scott's direction). Both padded together with `rand(32..=128)` dummies so observers can't infer vouch count, and can't infer a floor across multiple posts either. +- **Author's own entry**: **yes** (Scott confirmed). Author has their own `pub_me/priv_me` entry in `pub_post_set`; own comments pass the same accept rule. +- **Non-FoF comment visibility**: front-end shows "Comments are private" when no key unlocks. Optional "Request access via DM" affordance. Count NOT shown. Author can respond to a request by publishing an access-grant author comment. +- **Revocation semantics**: **retroactive delete + forward** (Scott's correction). File-holders delete comments signed by the revoked `pub_x` on arrival of the diff, then propagate. --- @@ -248,10 +295,13 @@ The reason this is acceptable: the alternative is no propagation-level verificat - `CommentPolicy::FriendsOfFriends` end-to-end (storage, protocol, UI picker). - Dual-derivation wrap slot: read → CEK, sign → priv_x. AEAD with `post_id` AAD. -- `pub_post_set` inline in header; `pub_x_index` on comments. +- `pub_post_set` inline in header, 1:1 with `wrap_slots`, real + dummy entries. +- `pub_x_index` on comments. - CEK_comments derived via HKDF; all comment bodies encrypted. - Propagation nodes enforce four-check accept rule before forwarding. -- Revocation diff format + CDN honor path. -- Per-post ephemeral-keypair generation (option B above, pending decision). +- Revocation diff format + CDN honor path (retroactive delete + forward). +- Access-grant author comment mechanism for post-hoc read widening. +- Per-post ephemeral-keypair generation. +- Random-count dummy padding `rand(32..=128)` on both `wrap_slots` and `pub_post_set`, shuffled together. - Back-compat: old clients can still render existing non-FoF posts; old comments on new FoF posts are rejected at CDN (missing required fields). -- Integration test: 3-node FoF chain (A→B→C). A posts Mode 2 FoF. B comments (accepted, propagates). C comments (accepted via B's chain). D (unrelated) signs junk with fake `pub_x_index` (rejected at first-hop CDN). A revokes B's `pub_x`. C's subsequent comments (which went through B's chain) fail CDN verification and stop propagating. +- Integration test: 3-node FoF chain (A→B→C). A posts Mode 2 FoF. B comments (accepted, propagates). C comments (accepted via B's chain). D (unrelated) signs junk with fake `pub_x_index` pointing at a real entry (rejected at first-hop CDN: `group_sig` fails). D signs junk with `pub_x_index` pointing at a dummy (rejected: `group_sig` fails against a dummy random pubkey). A revokes B's `pub_x`: B's already-propagated comments are deleted on each file-holder as the diff sweeps through; B's subsequent comments rejected at first hop. A vouches for E after the post is live and publishes access-grant author comment; E can now read + comment retroactively. diff --git a/sessions.md b/sessions.md index 94498be..f02fc03 100644 --- a/sessions.md +++ b/sessions.md @@ -65,6 +65,24 @@ See `CONTRIBUTING.md` for the protocol. See `AGENTS.md` for the Claude-specific **Stopping point**: commit `b8b38a6` (Layer 1) + new commit for Layer 2 both on branch; not merged. Awaiting Scott. +### Update 2026-04-24 — Layer 2 round 2 (Scott answers all 5 questions) + +Scott resolved all five open questions: + +1. **Per-post `(pub_x, priv_x)`** — confirmed. +2. **Random-count dummy padding** (`rand(32..=128)`) replaces power-of-2 buckets, with dummy pubkeys in `pub_post_set` so `.len() == wrap_slots.len()`. Across multiple posts from the same author, an observer cannot even establish a reliable floor for the real vouch-set size. +3. **Non-FoF comment UX**: "Comments are private" affordance with optional "Request access via DM" button. No count leak. +4. **Author's own entry in `pub_post_set`** — confirmed. +5. **Revocation is retroactive delete + forward.** File-holders delete locally-stored comments signed by the revoked `pub_x`, then propagate the diff. Stronger than stop-forwarding — prior garbage is cleaned up as the diff sweeps the mesh. + +**New primitive**: **access-grant author comment**. Author can retroactively widen a post's read-set by publishing an author-signed special comment appending a new `WrapSlot` + `pub_post_set` entry. Lets a newly-vouched persona gain read + comment access without republishing the whole post. Answers the "non-FoF requests access via DM, author approves" UX loop. + +**Files touched**: +- `docs/fof-spec/layer-2-mode2-fof-comments.md` — updated Lead decisions, post-header, revocation flow (retroactive), added Access-grant author comment section, updated Privacy tradeoff (size-leak analysis with random padding), Open questions split into unresolved + Resolved, size budget, ship criteria. +- `sessions.md` — this entry. + +Commit pending. + --- ## 2026-04-23 — primary Claude (Lead) — `docs/fof-spec-skeleton` From 3ee20736aad9ffd56ab236e51281039bc7d375d3 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Tue, 12 May 2026 21:43:11 -0400 Subject: [PATCH 04/34] docs: Layer 3 round 1 + unified hybrid padding rule Hybrid padding rule (slots + body, same shape): - <=256 real units: pad to next power of 2 (8, 16, ..., 256) - >256 real units: pad to real + rand(0..=256) / nearest 256KB Replaces Layer 2 round 2's rand(32..=128). Small authors/posts get strong bucket-grouping; large authors/posts get probabilistic noise without 2x bandwidth waste of pure power-of-2 at scale. Layer 3 resolutions: - Custom mode deferred; v1 ships Public / Friends-only / FoF only - Slot dedup at V_x byte level (one slot per unique key) - Body-length padding adopted Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/fof-spec/layer-2-mode2-fof-comments.md | 12 ++++++---- docs/fof-spec/layer-3-mode1-fof-closed.md | 26 ++++++++++++++------- sessions.md | 26 +++++++++++++++++++++ 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/docs/fof-spec/layer-2-mode2-fof-comments.md b/docs/fof-spec/layer-2-mode2-fof-comments.md index b53f84d..3c579d0 100644 --- a/docs/fof-spec/layer-2-mode2-fof-comments.md +++ b/docs/fof-spec/layer-2-mode2-fof-comments.md @@ -35,7 +35,7 @@ Cost: `pub_x_index` is a per-post pseudonym for the voucher-chain — leaks "the - **Comments are encrypted.** `CEK_comments = HKDF(CEK, "comments")`. Non-FoF observers see only ciphertext + signatures on comments. Read-gating is a side effect of the same slot unwrap. - **CDN propagation verifies before forwarding.** Four-check accept rule: valid `pub_x_index`, not in `revocation_list`, `group_sig` validates, `identity_sig` validates. Any failure → drop, do not propagate. - **Revocation is retroactive.** Revocation diff is author-signed, appends a revoked `pub_x` entry. Every file-holder that receives the diff **deletes all comments on this post signed by that `pub_x`** from local storage, then forwards the diff. Comments in flight before the diff arrived get deleted when it catches up. Stronger than "stop forwarding" — prior garbage also goes away. -- **Random-count dummy padding, not fixed buckets.** Append `rand(32..=128)` dummy slots (each real-shape, indistinguishable). Observers know a random amount was added; they cannot infer the floor count across multiple posts from the same author. Dummy `pub_x` entries are included in `pub_post_set` 1:1 so `pub_post_set.len() == wrap_slots.len()`; dummy pubkeys are 32B random bytes (no one holds the matching `priv_x`, so `group_sig` verify against a dummy always fails — benign). +- **Hybrid slot-count padding.** Up to 256 real slots: pad to next power of 2 (smallest authors get strong bucket-grouping; e.g., 5 real → 8 published, 100 real → 128 published, 200 real → 256 published). Above 256 real slots: pad to `real_count + rand(0..=256)` (large authors get probabilistic noise; power-of-2 jumps would waste up to 50%). Dummy `pub_x` entries are included in `pub_post_set` 1:1 so `pub_post_set.len() == wrap_slots.len()`; dummy pubkeys are 32B random bytes (no one holds the matching `priv_x`, so `group_sig` verify against a dummy always fails — benign). - **Post-hoc read-access grant via author comment.** Author can widen the read-set of an already-published post by publishing an author-signed special comment that appends a new `WrapSlot` (and its `pub_post_set` entry) for a newly-vouched persona. No rotation, no republish; retroactive inclusion. See "Access-grant author comment" below. - **`vouch_mac` retained.** Inside ciphertext. Enables author render-time and intra-circle attribution complementary to CDN-level `pub_x_index` attribution. - **Author has their own `pub_me/priv_me`.** Treated as one of the entries in `pub_post_set`. Author signs their own comments through the same path. @@ -246,7 +246,7 @@ struct AccessGrantComment { | `prefilter_tag` | 2 | | **Subtotal per slot** | **~154** | -At 500 real vouchees + `rand(32..=128)` dummies: header payload is 532–628 slots × ~154B = ~82KB to ~97KB. Acceptable for a header carried once per post. +At 500 real vouchees (above the 256 inflection point): pad to `500 + rand(0..=256)` = 500–756 slots × ~154B = ~77KB to ~117KB. At 200 real vouchees: pad to 256 = ~39KB. At 9 real vouchees: pad to 16 = ~2.5KB. Acceptable for a header carried once per post. Per-comment CDN overhead (vs shared-keypair baseline): +64B (`group_sig`) + 4B (`pub_x_index`) + 64B (`identity_sig` — already in baseline) ≈ 68B additional. Negligible. @@ -266,7 +266,11 @@ Decision: **defer Merkle variant to PQ migration**. v1 ships inline. The reason this is acceptable: the alternative is no propagation-level verification, which means any one admitted FoF member can DoS the mesh. Per-post pseudonym is the minimum viable disclosure for CDN-level filtering. -**Vouch-set size:** random `rand(32..=128)` dummy padding means an observer sees `real_count + rand(32..=128)` and cannot infer the true count. Across multiple posts from the same author, the observer also cannot establish a lower bound — each post's padding is independent, so the minimum observed size across many posts only converges to `real_count + 32`, not to `real_count`. +**Vouch-set size:** hybrid padding behaves differently in two regimes. +- **Small authors (≤256 real slots)** publish a power-of-2 count. Observer learns the bucket (e.g., `published == 128` → `real_count ∈ (64, 128]`). Bucket boundaries are 8, 16, 32, 64, 128, 256. Within-bucket count is fully hidden. +- **Large authors (>256 real slots)** publish `real_count + rand(0..=256)`. Each post leaks an upper bound on real_count. Across many posts, an observer's best floor estimate is `min(published_i) - 256`, which doesn't converge tighter without independent side information. + +Small-author bucket boundaries are a known cost; large authors trade off a coarser noise floor against bandwidth. Both regimes hide the true count within meaningful brackets. **Non-FoF reader UX:** non-FoF readers see the public body + "Comments are private" affordance. Comment count is not shown (no engagement leak). Optional: a "Request access via DM" button that sends the author a note; author can respond by publishing an access-grant author comment (above) that retroactively admits the requester. @@ -302,6 +306,6 @@ The reason this is acceptable: the alternative is no propagation-level verificat - Revocation diff format + CDN honor path (retroactive delete + forward). - Access-grant author comment mechanism for post-hoc read widening. - Per-post ephemeral-keypair generation. -- Random-count dummy padding `rand(32..=128)` on both `wrap_slots` and `pub_post_set`, shuffled together. +- Hybrid dummy padding on both `wrap_slots` and `pub_post_set` (power-of-2 up to 256, then `count + rand(0..=256)`), shuffled together. - Back-compat: old clients can still render existing non-FoF posts; old comments on new FoF posts are rejected at CDN (missing required fields). - Integration test: 3-node FoF chain (A→B→C). A posts Mode 2 FoF. B comments (accepted, propagates). C comments (accepted via B's chain). D (unrelated) signs junk with fake `pub_x_index` pointing at a real entry (rejected at first-hop CDN: `group_sig` fails). D signs junk with `pub_x_index` pointing at a dummy (rejected: `group_sig` fails against a dummy random pubkey). A revokes B's `pub_x`: B's already-propagated comments are deleted on each file-holder as the diff sweeps through; B's subsequent comments rejected at first hop. A vouches for E after the post is live and publishes access-grant author comment; E can now read + comment retroactively. diff --git a/docs/fof-spec/layer-3-mode1-fof-closed.md b/docs/fof-spec/layer-3-mode1-fof-closed.md index 795b744..5fd81a1 100644 --- a/docs/fof-spec/layer-3-mode1-fof-closed.md +++ b/docs/fof-spec/layer-3-mode1-fof-closed.md @@ -22,11 +22,12 @@ Builds on Layer 2's `pub_post` / `priv_post` / `wrap_slot` primitives — same s ## Lead decisions - **New variant, not extended Encrypted.** `PostVisibility::FoFClosed` is its own variant. Existing `Encrypted{recipients}` wraps per-recipient NodeIds — visible on the wire. FoF wraps anonymously under symmetric keys — no NodeIds. -- **One wrap slot per `V_x` in the author's keyring.** For a Friends-only post, one slot under `V_me`. For FoF, N+1 slots (one per `V_x` the author holds + own `V_me`). For Custom, subset chosen by author. -- **Slot count padded to power-of-2.** Prevents observers from counting vouchers the author has. TBD — OPUS: confirm padding up to next power of 2 with random dummy slots (non-decryptable ciphertext indistinguishable from real slots). -- **Each slot carries both CEK and priv_post.** Wrapped together as a single plaintext. One successful unwrap gives reader everything they need to read body + sign comments. +- **One wrap slot per unique `V_x`.** Dedup at the `V_x` byte level — if multiple personas hold the same `V_x`, include one slot. Friends-only post: one slot under `V_me`. FoF post: own `V_me` + every distinct `V_x` the author holds. Custom: subset chosen by author (deferred to v2 power-user UI; v1 ships three presets only: Public / Friends-only / Friends-of-Friends). +- **Hybrid slot-count padding.** Up to 256 real slots: pad to next power of 2 (smallest authors get strong bucket-grouping). Above 256: pad to `real_count + rand(0..=256)` (large authors get probabilistic noise; power-of-2 jumps would waste up to 50% bandwidth). Dummy slots are byte-identical to real ones, AEAD-fails on any `V_x`. Dummy entries also added to `pub_post_set` 1:1 (see Layer 2). +- **Hybrid body-size padding.** Same shape: up to 256KB of body ciphertext, pad to next power of 2 (1KB, 2KB, 4KB, …, 256KB). Above 256KB, round up to nearest 256KB. Aligns large posts with the storage chunking-block size; small posts get strong bucket-grouping against length-based classification. +- **Each slot carries both CEK and priv_x (Layer 2 dual-derivation).** Layer 2's `WrapSlot` dual-derivation (read → CEK, sign → priv_x) is the canonical form. Mode 1 simply also uses the CEK to encrypt the body, where Mode 2 leaves the body plaintext. - **Prefilter tag is `HMAC(V_x, post_id)[:2B]`.** Readers precompute a 2-byte tag for each key in their keyring and skip slots that don't match. Cuts trial-decrypt cost by ~2^16 on average. -- **Order of slots is randomized.** No positional leak about which slot corresponds to which voucher. +- **Order of slots is randomized.** No positional leak about which slot corresponds to which voucher. Re-shuffled on every header revision (including access-grant appends from Layer 2 — TBD whether append-only ordering is acceptable, or whether the entire set is re-shuffled at each grant). --- @@ -121,21 +122,28 @@ Ciphertext `FoFClosed` posts ride the same CDN propagation as other encrypted po ## Open questions -- **Slot size uniformity.** Real slots and dummy padding slots must be byte-identical-sized. Confirmed. TBD — OPUS: should we also pad the body length to a bucket to avoid length-based classification? - **Prefilter false-positive cost.** 1/65536 false positive per slot. With 500 slots × reader iterating 500 keys, expected ~3.8 false-positive AEAD attempts per post. Acceptable. - **Prefilter collision on legitimate hits.** Two different `V_x` could produce the same `prefilter_tag` for the same `post_id`. Reader just tries both. No correctness issue. - **Slot-reuse across posts.** If the same `V_x` is used across many posts, an attacker can observe prefilter tags recur. Since `post_id` is in the HMAC input, tags differ per post. No leak. -- **Custom mode slot selection.** Does the author UI let them pick specific vouchers, specific groups of vouchers, or only "all held + own" vs "own only"? Lead leaning: initial UI = only the three preset levels (Friends-only / FoF / Public); custom ships as power-user option later. -- **Deduplication of `V_x` across personas.** If multiple personas hold the same `V_x`, do we include one slot or one-per-persona? Lead leaning: dedup at the `V_x` bytes level; one slot per unique key. +- **Access-grant re-shuffle vs append-only.** When the author publishes a Layer 2 access-grant comment, do we re-shuffle the entire `wrap_slots` + `pub_post_set` (preserves the random-order property for the full set but invalidates `pub_x_index` values in already-propagated comments), or append-only (`pub_x_index` is stable across the post's lifetime, but the newest entries are always at the tail — small positional leak that grants are recent)? Lead leaning: **append-only**; `pub_x_index` stability is load-bearing for revocation and comment verification on already-stored comments. +- **Padding floor for small authors.** Power-of-2 padding on 1 real slot → 1, 2, or 4? Power-of-2-of-1 is 1, but that's no padding. Probably enforce a minimum bucket of 4 or 8 so that a brand-new persona with one vouch doesn't publish a singleton. Lead leaning: minimum 8. + +## Resolved (2026-04-24) + +- **Slot count padding**: hybrid scheme — up to 256, next power of 2; above 256, `real_count + rand(0..=256)`. Body-size padding follows the same shape with 256KB as the inflection point. +- **Custom mode UI**: deferred. v1 ships only the three presets (Public / Friends-only / FoF). Power-user custom-subset UI is v2. +- **Slot deduplication**: dedup at the `V_x` byte level. One slot per unique key. +- **Body length padding**: yes — pad to next power of 2 up to 256KB, then 256KB chunks above. --- ## Ship criteria for Layer 3 - `PostVisibility::FoFClosed` exists end-to-end. -- Author creation path generates ephemeral keypair, wraps CEK+priv_post under each eligible `V_x`, pads to power-of-2. +- Author creation path generates per-post keypairs, wraps CEK+priv_x under each unique `V_x` (deduped), and pads per the hybrid rule: power-of-2 up to 256 real slots, then `real_count + rand(0..=256)` above. +- Body-size padded: power-of-2 up to 256KB, then nearest 256KB above. - Reader decryption path iterates personas × keyring with prefilter tag. - `receive_post` accepts FoFClosed ciphertext without decrypting. -- UI surface: post composer has Public / Friends-only / FoF / Custom picker. +- UI surface: post composer has three presets — Public / Friends-only / Friends-of-Friends. Custom subset is v2. - Integration test: A posts FoFClosed. B (direct vouchee) reads. C (FoF via B) reads. D (unrelated) gets ciphertext, cannot decrypt. - Performance: decryption completes within budget at 500-key keyring × 500-slot posts (see Layer 5 for the optimization work that makes this budget feasible). diff --git a/sessions.md b/sessions.md index f02fc03..d01c3f6 100644 --- a/sessions.md +++ b/sessions.md @@ -65,6 +65,32 @@ See `CONTRIBUTING.md` for the protocol. See `AGENTS.md` for the Claude-specific **Stopping point**: commit `b8b38a6` (Layer 1) + new commit for Layer 2 both on branch; not merged. Awaiting Scott. +### Update 2026-04-24 — Layer 3 round 1 + cross-cutting padding rule + +Scott talked to Opus and resolved Layer 3 open questions + introduced a unified padding rule that supersedes Layer 2 round 2's `rand(32..=128)`. + +**Hybrid padding rule (applies to both slot count and body size)**: +- ≤256 real units → pad to next power of 2 (1, 2, 4, …, 256). Strong bucket-grouping for small authors / small bodies. +- >256 real units → pad to `real + rand(0..=256)` (slots) / round up to nearest 256KB (bodies). Probabilistic noise for large authors / large bodies; avoids 2× bandwidth waste of pure power-of-2 at scale. + +Applies uniformly to slot count and body size. + +**Other resolved Layer 3 questions**: +- Custom mode UI deferred. v1 ships three presets only: Public / Friends-only / FoF. +- Slot dedup at `V_x` byte level. One slot per unique key. +- Body-length padding adopted. + +**Files touched**: +- `docs/fof-spec/layer-3-mode1-fof-closed.md`: Lead decisions updated (hybrid padding, dedup, three-preset UI); open questions split into still-open + Resolved; ship criteria updated. +- `docs/fof-spec/layer-2-mode2-fof-comments.md`: padding rule promoted to hybrid scheme; size budget rewritten with three regime examples; privacy section rewritten for two-regime analysis; Resolved bullet superseded with pointer. +- `sessions.md`: this entry. + +**Still-open Layer 3 questions worth flagging to Scott**: +1. Access-grant ordering — does appending a new slot re-shuffle the full `wrap_slots` / `pub_post_set` (preserves the random-order privacy property but invalidates `pub_x_index` values in already-stored comments), or is it append-only (`pub_x_index` is stable but tail-positional leak says "these are recent grants")? Lead leaning: **append-only**; index stability matters for revocation and stored-comment verification. +2. Minimum slot-count floor for tiny authors. Power-of-2-of-1 = 1, which leaks "this persona has one vouch (probably just themselves)." Lead leaning: minimum bucket of 8. + +**Pending**: Layers 4–6 iterations. Scott to confirm two flagged questions. + ### Update 2026-04-24 — Layer 2 round 2 (Scott answers all 5 questions) Scott resolved all five open questions: From 9040d70bf6b76dea4c8cbfa4e06bcd7a90f8fd3f Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Tue, 12 May 2026 21:47:44 -0400 Subject: [PATCH 05/34] =?UTF-8?q?docs:=20correct=20padding=20rule=20?= =?UTF-8?q?=E2=80=94=20bucketed=20throughout,=20not=20random=20above=20256?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior round misread Opus's recommendation: I wrote "rand(0..=256) above 256" for slots and "round up to nearest 256KB above 256KB" for body. Body was right; slots were wrong. Correct rule: bucketed throughout. Slot buckets: 8, 16, 32, 64, 128, 256 (power-of-2 sub-256), then 384, 512, 640, 768, ... (+128 steps above). Body buckets: 1KB, 2KB, ..., 256KB (power-of-2 sub-256KB), then 512KB, 768KB, 1024KB, ... (+256KB steps above; aligns with future chunk size). Stronger privacy than random: observer learns bucket, never position within it. Stable across posts; no min-over-many-posts floor attack. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/fof-spec/layer-2-mode2-fof-comments.md | 12 ++++-------- docs/fof-spec/layer-3-mode1-fof-closed.md | 10 +++++----- sessions.md | 15 +++++++++++---- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/docs/fof-spec/layer-2-mode2-fof-comments.md b/docs/fof-spec/layer-2-mode2-fof-comments.md index 3c579d0..4bb837d 100644 --- a/docs/fof-spec/layer-2-mode2-fof-comments.md +++ b/docs/fof-spec/layer-2-mode2-fof-comments.md @@ -35,7 +35,7 @@ Cost: `pub_x_index` is a per-post pseudonym for the voucher-chain — leaks "the - **Comments are encrypted.** `CEK_comments = HKDF(CEK, "comments")`. Non-FoF observers see only ciphertext + signatures on comments. Read-gating is a side effect of the same slot unwrap. - **CDN propagation verifies before forwarding.** Four-check accept rule: valid `pub_x_index`, not in `revocation_list`, `group_sig` validates, `identity_sig` validates. Any failure → drop, do not propagate. - **Revocation is retroactive.** Revocation diff is author-signed, appends a revoked `pub_x` entry. Every file-holder that receives the diff **deletes all comments on this post signed by that `pub_x`** from local storage, then forwards the diff. Comments in flight before the diff arrived get deleted when it catches up. Stronger than "stop forwarding" — prior garbage also goes away. -- **Hybrid slot-count padding.** Up to 256 real slots: pad to next power of 2 (smallest authors get strong bucket-grouping; e.g., 5 real → 8 published, 100 real → 128 published, 200 real → 256 published). Above 256 real slots: pad to `real_count + rand(0..=256)` (large authors get probabilistic noise; power-of-2 jumps would waste up to 50%). Dummy `pub_x` entries are included in `pub_post_set` 1:1 so `pub_post_set.len() == wrap_slots.len()`; dummy pubkeys are 32B random bytes (no one holds the matching `priv_x`, so `group_sig` verify against a dummy always fails — benign). +- **Bucketed slot-count padding.** Deterministic buckets throughout. Up to 256 real slots: next power of 2 (8, 16, 32, 64, 128, 256). Above 256: next +128-step bucket (384, 512, 640, 768, …). Author publishes `next_bucket(real_count)` slots; dummy slots fill the gap. Dummy `pub_x` entries are included in `pub_post_set` 1:1 so `pub_post_set.len() == wrap_slots.len()`; dummy pubkeys are 32B random bytes (no one holds the matching `priv_x`, so `group_sig` verify against a dummy always fails — benign). - **Post-hoc read-access grant via author comment.** Author can widen the read-set of an already-published post by publishing an author-signed special comment that appends a new `WrapSlot` (and its `pub_post_set` entry) for a newly-vouched persona. No rotation, no republish; retroactive inclusion. See "Access-grant author comment" below. - **`vouch_mac` retained.** Inside ciphertext. Enables author render-time and intra-circle attribution complementary to CDN-level `pub_x_index` attribution. - **Author has their own `pub_me/priv_me`.** Treated as one of the entries in `pub_post_set`. Author signs their own comments through the same path. @@ -246,7 +246,7 @@ struct AccessGrantComment { | `prefilter_tag` | 2 | | **Subtotal per slot** | **~154** | -At 500 real vouchees (above the 256 inflection point): pad to `500 + rand(0..=256)` = 500–756 slots × ~154B = ~77KB to ~117KB. At 200 real vouchees: pad to 256 = ~39KB. At 9 real vouchees: pad to 16 = ~2.5KB. Acceptable for a header carried once per post. +At 500 real vouchees → next bucket above 256 is 512 → 512 slots × ~154B ≈ 79KB. At 200 real vouchees → bucket 256 ≈ 39KB. At 9 real vouchees → bucket 16 ≈ 2.5KB. Worst-case in-bucket overhead: just-under-boundary case (e.g., 257 real → 384 bucket → 33% padded; 129 real → 256 bucket → 49% padded; well below pure power-of-2 doubling). Acceptable for a header carried once per post. Per-comment CDN overhead (vs shared-keypair baseline): +64B (`group_sig`) + 4B (`pub_x_index`) + 64B (`identity_sig` — already in baseline) ≈ 68B additional. Negligible. @@ -266,11 +266,7 @@ Decision: **defer Merkle variant to PQ migration**. v1 ships inline. The reason this is acceptable: the alternative is no propagation-level verification, which means any one admitted FoF member can DoS the mesh. Per-post pseudonym is the minimum viable disclosure for CDN-level filtering. -**Vouch-set size:** hybrid padding behaves differently in two regimes. -- **Small authors (≤256 real slots)** publish a power-of-2 count. Observer learns the bucket (e.g., `published == 128` → `real_count ∈ (64, 128]`). Bucket boundaries are 8, 16, 32, 64, 128, 256. Within-bucket count is fully hidden. -- **Large authors (>256 real slots)** publish `real_count + rand(0..=256)`. Each post leaks an upper bound on real_count. Across many posts, an observer's best floor estimate is `min(published_i) - 256`, which doesn't converge tighter without independent side information. - -Small-author bucket boundaries are a known cost; large authors trade off a coarser noise floor against bandwidth. Both regimes hide the true count within meaningful brackets. +**Vouch-set size:** bucketed padding throughout. Observer learns the bucket; position within the bucket is fully hidden. Buckets are 8, 16, 32, 64, 128, 256 (power-of-2 sub-256) then 384, 512, 640, 768, … (+128 steps above 256). The same author's bucket is stable across posts unless they cross a boundary — so observers see "this author has between X and Y real vouchees" with no way to converge tighter from multiple posts. **Non-FoF reader UX:** non-FoF readers see the public body + "Comments are private" affordance. Comment count is not shown (no engagement leak). Optional: a "Request access via DM" button that sends the author a note; author can respond by publishing an access-grant author comment (above) that retroactively admits the requester. @@ -306,6 +302,6 @@ Small-author bucket boundaries are a known cost; large authors trade off a coars - Revocation diff format + CDN honor path (retroactive delete + forward). - Access-grant author comment mechanism for post-hoc read widening. - Per-post ephemeral-keypair generation. -- Hybrid dummy padding on both `wrap_slots` and `pub_post_set` (power-of-2 up to 256, then `count + rand(0..=256)`), shuffled together. +- Bucketed dummy padding on both `wrap_slots` and `pub_post_set` (power-of-2 up to 256, then +128 steps above), shuffled together. - Back-compat: old clients can still render existing non-FoF posts; old comments on new FoF posts are rejected at CDN (missing required fields). - Integration test: 3-node FoF chain (A→B→C). A posts Mode 2 FoF. B comments (accepted, propagates). C comments (accepted via B's chain). D (unrelated) signs junk with fake `pub_x_index` pointing at a real entry (rejected at first-hop CDN: `group_sig` fails). D signs junk with `pub_x_index` pointing at a dummy (rejected: `group_sig` fails against a dummy random pubkey). A revokes B's `pub_x`: B's already-propagated comments are deleted on each file-holder as the diff sweeps through; B's subsequent comments rejected at first hop. A vouches for E after the post is live and publishes access-grant author comment; E can now read + comment retroactively. diff --git a/docs/fof-spec/layer-3-mode1-fof-closed.md b/docs/fof-spec/layer-3-mode1-fof-closed.md index 5fd81a1..a06cbbf 100644 --- a/docs/fof-spec/layer-3-mode1-fof-closed.md +++ b/docs/fof-spec/layer-3-mode1-fof-closed.md @@ -23,8 +23,8 @@ Builds on Layer 2's `pub_post` / `priv_post` / `wrap_slot` primitives — same s - **New variant, not extended Encrypted.** `PostVisibility::FoFClosed` is its own variant. Existing `Encrypted{recipients}` wraps per-recipient NodeIds — visible on the wire. FoF wraps anonymously under symmetric keys — no NodeIds. - **One wrap slot per unique `V_x`.** Dedup at the `V_x` byte level — if multiple personas hold the same `V_x`, include one slot. Friends-only post: one slot under `V_me`. FoF post: own `V_me` + every distinct `V_x` the author holds. Custom: subset chosen by author (deferred to v2 power-user UI; v1 ships three presets only: Public / Friends-only / Friends-of-Friends). -- **Hybrid slot-count padding.** Up to 256 real slots: pad to next power of 2 (smallest authors get strong bucket-grouping). Above 256: pad to `real_count + rand(0..=256)` (large authors get probabilistic noise; power-of-2 jumps would waste up to 50% bandwidth). Dummy slots are byte-identical to real ones, AEAD-fails on any `V_x`. Dummy entries also added to `pub_post_set` 1:1 (see Layer 2). -- **Hybrid body-size padding.** Same shape: up to 256KB of body ciphertext, pad to next power of 2 (1KB, 2KB, 4KB, …, 256KB). Above 256KB, round up to nearest 256KB. Aligns large posts with the storage chunking-block size; small posts get strong bucket-grouping against length-based classification. +- **Bucketed slot-count padding.** Deterministic bucket boundaries throughout — observers learn the bucket, not the position within it. Buckets: 8, 16, 32, 64, 128, 256 (power-of-2 up to 256), then 384, 512, 640, 768, … (linear +128 steps above 256). Author publishes `next_bucket(real_count)` slots with random dummies filling the gap. Power-of-2 sub-256 keeps small-author overhead bounded; +128 steps above 256 avoid the 2× waste of pure power-of-2 at scale. Dummy slots are byte-identical to real ones, AEAD-fails on any `V_x`. Dummy entries also added to `pub_post_set` 1:1 (see Layer 2). +- **Bucketed body-size padding.** Same shape applied to body ciphertext bytes: power-of-2 buckets up to 256KB (1KB, 2KB, 4KB, …, 256KB), then 256KB-step buckets above (512KB, 768KB, 1024KB, …). 256KB above is the future storage chunk size — once large enough to chunk, padding aligns to chunk boundaries naturally. - **Each slot carries both CEK and priv_x (Layer 2 dual-derivation).** Layer 2's `WrapSlot` dual-derivation (read → CEK, sign → priv_x) is the canonical form. Mode 1 simply also uses the CEK to encrypt the body, where Mode 2 leaves the body plaintext. - **Prefilter tag is `HMAC(V_x, post_id)[:2B]`.** Readers precompute a 2-byte tag for each key in their keyring and skip slots that don't match. Cuts trial-decrypt cost by ~2^16 on average. - **Order of slots is randomized.** No positional leak about which slot corresponds to which voucher. Re-shuffled on every header revision (including access-grant appends from Layer 2 — TBD whether append-only ordering is acceptable, or whether the entire set is re-shuffled at each grant). @@ -130,7 +130,7 @@ Ciphertext `FoFClosed` posts ride the same CDN propagation as other encrypted po ## Resolved (2026-04-24) -- **Slot count padding**: hybrid scheme — up to 256, next power of 2; above 256, `real_count + rand(0..=256)`. Body-size padding follows the same shape with 256KB as the inflection point. +- **Slot count padding**: bucketed throughout. Power-of-2 buckets to 256 (8, 16, 32, 64, 128, 256), then linear +128 buckets (384, 512, 640, …). Body-size padding follows the same shape with 256KB as the power-of-2 ceiling and 256KB linear steps above. - **Custom mode UI**: deferred. v1 ships only the three presets (Public / Friends-only / FoF). Power-user custom-subset UI is v2. - **Slot deduplication**: dedup at the `V_x` byte level. One slot per unique key. - **Body length padding**: yes — pad to next power of 2 up to 256KB, then 256KB chunks above. @@ -140,8 +140,8 @@ Ciphertext `FoFClosed` posts ride the same CDN propagation as other encrypted po ## Ship criteria for Layer 3 - `PostVisibility::FoFClosed` exists end-to-end. -- Author creation path generates per-post keypairs, wraps CEK+priv_x under each unique `V_x` (deduped), and pads per the hybrid rule: power-of-2 up to 256 real slots, then `real_count + rand(0..=256)` above. -- Body-size padded: power-of-2 up to 256KB, then nearest 256KB above. +- Author creation path generates per-post keypairs, wraps CEK+priv_x under each unique `V_x` (deduped), and pads to the next slot bucket: power-of-2 up to 256, then +128 steps above. +- Body-size padded to next body bucket: power-of-2 up to 256KB, then 256KB steps above. - Reader decryption path iterates personas × keyring with prefilter tag. - `receive_post` accepts FoFClosed ciphertext without decrypting. - UI surface: post composer has three presets — Public / Friends-only / Friends-of-Friends. Custom subset is v2. diff --git a/sessions.md b/sessions.md index d01c3f6..d349278 100644 --- a/sessions.md +++ b/sessions.md @@ -65,13 +65,20 @@ See `CONTRIBUTING.md` for the protocol. See `AGENTS.md` for the Claude-specific **Stopping point**: commit `b8b38a6` (Layer 1) + new commit for Layer 2 both on branch; not merged. Awaiting Scott. -### Update 2026-04-24 — Layer 3 round 1 + cross-cutting padding rule +### Update 2026-04-24 — Layer 3 round 1 + cross-cutting padding rule (corrected) Scott talked to Opus and resolved Layer 3 open questions + introduced a unified padding rule that supersedes Layer 2 round 2's `rand(32..=128)`. -**Hybrid padding rule (applies to both slot count and body size)**: -- ≤256 real units → pad to next power of 2 (1, 2, 4, …, 256). Strong bucket-grouping for small authors / small bodies. -- >256 real units → pad to `real + rand(0..=256)` (slots) / round up to nearest 256KB (bodies). Probabilistic noise for large authors / large bodies; avoids 2× bandwidth waste of pure power-of-2 at scale. +**First-pass misread (corrected by Scott):** I initially wrote the rule as "power-of-2 up to 256, then `real + rand(0..=256)` above." That's wrong — the rule is **bucketed throughout**, not random above the threshold. + +**Bucketed padding rule (applies to both slot count and body size)**: +- ≤256 real units → next power-of-2 bucket (8, 16, 32, 64, 128, 256). +- >256 real units → next linear-step bucket: +128 step for slots (384, 512, 640, …), +256KB step for body bytes (512KB, 768KB, 1024KB, …). +- Deterministic. Author publishes `next_bucket(real)`; dummies fill the gap. + +Why this is stronger than random: observers learn the bucket but never the position within it. Across multiple posts from the same author, the bucket is stable until the author crosses a boundary — so no "min over many posts" attack converges tighter than the bucket bound. Random padding would have leaked `min(observed) - max_noise` as a floor. + +Linear-step above 256 vs pure power-of-2: avoids the 2× waste of jumping 256→512 for an author with 257 vouchees. Above 256, step buckets are 128 (slots) or 256KB (body) so worst-case in-bucket overhead is bounded (~33% at the worst spot). Applies uniformly to slot count and body size. From 4123e032cbc4ba8f956305838ee1c04c6aac7132 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Tue, 12 May 2026 21:58:06 -0400 Subject: [PATCH 06/34] =?UTF-8?q?docs:=20Layer=203=20round=202=20=E2=80=94?= =?UTF-8?q?=20append-at-tail=20grants,=20min=20bucket=208?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the two remaining Layer 3 open questions: - Access-grant ordering: append at tail. pub_x_index values in already-stored comments stay valid; no write amplification. Tail- is-recent positional leak is the accepted cost (vs re-shuffle which would force comments to reference signers by 32B pub_x bytes). - Minimum slot bucket: 8. Singleton posts pad up to 8 so brand-new personas don't publish "no vouchees" headers. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/fof-spec/layer-2-mode2-fof-comments.md | 2 +- docs/fof-spec/layer-3-mode1-fof-closed.md | 7 +++---- sessions.md | 12 ++++++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/fof-spec/layer-2-mode2-fof-comments.md b/docs/fof-spec/layer-2-mode2-fof-comments.md index 4bb837d..00c7cfe 100644 --- a/docs/fof-spec/layer-2-mode2-fof-comments.md +++ b/docs/fof-spec/layer-2-mode2-fof-comments.md @@ -36,7 +36,7 @@ Cost: `pub_x_index` is a per-post pseudonym for the voucher-chain — leaks "the - **CDN propagation verifies before forwarding.** Four-check accept rule: valid `pub_x_index`, not in `revocation_list`, `group_sig` validates, `identity_sig` validates. Any failure → drop, do not propagate. - **Revocation is retroactive.** Revocation diff is author-signed, appends a revoked `pub_x` entry. Every file-holder that receives the diff **deletes all comments on this post signed by that `pub_x`** from local storage, then forwards the diff. Comments in flight before the diff arrived get deleted when it catches up. Stronger than "stop forwarding" — prior garbage also goes away. - **Bucketed slot-count padding.** Deterministic buckets throughout. Up to 256 real slots: next power of 2 (8, 16, 32, 64, 128, 256). Above 256: next +128-step bucket (384, 512, 640, 768, …). Author publishes `next_bucket(real_count)` slots; dummy slots fill the gap. Dummy `pub_x` entries are included in `pub_post_set` 1:1 so `pub_post_set.len() == wrap_slots.len()`; dummy pubkeys are 32B random bytes (no one holds the matching `priv_x`, so `group_sig` verify against a dummy always fails — benign). -- **Post-hoc read-access grant via author comment.** Author can widen the read-set of an already-published post by publishing an author-signed special comment that appends a new `WrapSlot` (and its `pub_post_set` entry) for a newly-vouched persona. No rotation, no republish; retroactive inclusion. See "Access-grant author comment" below. +- **Post-hoc read-access grant via author comment.** Author can widen the read-set of an already-published post by publishing an author-signed special comment that **appends at the tail** a new `WrapSlot` + `pub_post_set` entry for a newly-vouched persona. `pub_x_index` values in already-stored comments stay valid (positions 0..N-1 unchanged). Cost: observers see "tail entries are the most recent grants" — small positional-recency leak; accepted tradeoff for index stability. No rotation, no republish; retroactive inclusion. See "Access-grant author comment" below. - **`vouch_mac` retained.** Inside ciphertext. Enables author render-time and intra-circle attribution complementary to CDN-level `pub_x_index` attribution. - **Author has their own `pub_me/priv_me`.** Treated as one of the entries in `pub_post_set`. Author signs their own comments through the same path. - **`V_me` handed to vouchees already implies `priv_me` handoff.** No — correction: `priv_x` is wrapped in the per-`V_x` sign-slot of every post, not handed out at vouch time. Only people who can unwrap the slot (= people holding `V_x`) receive `priv_x`. This is what makes per-post rotation (Layer 4) a coherent revocation surface. diff --git a/docs/fof-spec/layer-3-mode1-fof-closed.md b/docs/fof-spec/layer-3-mode1-fof-closed.md index a06cbbf..b74ba89 100644 --- a/docs/fof-spec/layer-3-mode1-fof-closed.md +++ b/docs/fof-spec/layer-3-mode1-fof-closed.md @@ -23,7 +23,7 @@ Builds on Layer 2's `pub_post` / `priv_post` / `wrap_slot` primitives — same s - **New variant, not extended Encrypted.** `PostVisibility::FoFClosed` is its own variant. Existing `Encrypted{recipients}` wraps per-recipient NodeIds — visible on the wire. FoF wraps anonymously under symmetric keys — no NodeIds. - **One wrap slot per unique `V_x`.** Dedup at the `V_x` byte level — if multiple personas hold the same `V_x`, include one slot. Friends-only post: one slot under `V_me`. FoF post: own `V_me` + every distinct `V_x` the author holds. Custom: subset chosen by author (deferred to v2 power-user UI; v1 ships three presets only: Public / Friends-only / Friends-of-Friends). -- **Bucketed slot-count padding.** Deterministic bucket boundaries throughout — observers learn the bucket, not the position within it. Buckets: 8, 16, 32, 64, 128, 256 (power-of-2 up to 256), then 384, 512, 640, 768, … (linear +128 steps above 256). Author publishes `next_bucket(real_count)` slots with random dummies filling the gap. Power-of-2 sub-256 keeps small-author overhead bounded; +128 steps above 256 avoid the 2× waste of pure power-of-2 at scale. Dummy slots are byte-identical to real ones, AEAD-fails on any `V_x`. Dummy entries also added to `pub_post_set` 1:1 (see Layer 2). +- **Bucketed slot-count padding.** Deterministic bucket boundaries throughout — observers learn the bucket, not the position within it. Buckets: 8, 16, 32, 64, 128, 256 (power-of-2 from a minimum of 8 up to 256), then 384, 512, 640, 768, … (linear +128 steps above 256). Author publishes `next_bucket(real_count)` slots with random dummies filling the gap. Minimum bucket of 8 means a brand-new persona's first post still publishes 8 slots — no "this persona has no vouchees" signal. Power-of-2 sub-256 keeps small-author overhead bounded; +128 steps above 256 avoid the 2× waste of pure power-of-2 at scale. Dummy slots are byte-identical to real ones, AEAD-fails on any `V_x`. Dummy entries also added to `pub_post_set` 1:1 (see Layer 2). - **Bucketed body-size padding.** Same shape applied to body ciphertext bytes: power-of-2 buckets up to 256KB (1KB, 2KB, 4KB, …, 256KB), then 256KB-step buckets above (512KB, 768KB, 1024KB, …). 256KB above is the future storage chunk size — once large enough to chunk, padding aligns to chunk boundaries naturally. - **Each slot carries both CEK and priv_x (Layer 2 dual-derivation).** Layer 2's `WrapSlot` dual-derivation (read → CEK, sign → priv_x) is the canonical form. Mode 1 simply also uses the CEK to encrypt the body, where Mode 2 leaves the body plaintext. - **Prefilter tag is `HMAC(V_x, post_id)[:2B]`.** Readers precompute a 2-byte tag for each key in their keyring and skip slots that don't match. Cuts trial-decrypt cost by ~2^16 on average. @@ -125,12 +125,11 @@ Ciphertext `FoFClosed` posts ride the same CDN propagation as other encrypted po - **Prefilter false-positive cost.** 1/65536 false positive per slot. With 500 slots × reader iterating 500 keys, expected ~3.8 false-positive AEAD attempts per post. Acceptable. - **Prefilter collision on legitimate hits.** Two different `V_x` could produce the same `prefilter_tag` for the same `post_id`. Reader just tries both. No correctness issue. - **Slot-reuse across posts.** If the same `V_x` is used across many posts, an attacker can observe prefilter tags recur. Since `post_id` is in the HMAC input, tags differ per post. No leak. -- **Access-grant re-shuffle vs append-only.** When the author publishes a Layer 2 access-grant comment, do we re-shuffle the entire `wrap_slots` + `pub_post_set` (preserves the random-order property for the full set but invalidates `pub_x_index` values in already-propagated comments), or append-only (`pub_x_index` is stable across the post's lifetime, but the newest entries are always at the tail — small positional leak that grants are recent)? Lead leaning: **append-only**; `pub_x_index` stability is load-bearing for revocation and comment verification on already-stored comments. -- **Padding floor for small authors.** Power-of-2 padding on 1 real slot → 1, 2, or 4? Power-of-2-of-1 is 1, but that's no padding. Probably enforce a minimum bucket of 4 or 8 so that a brand-new persona with one vouch doesn't publish a singleton. Lead leaning: minimum 8. ## Resolved (2026-04-24) -- **Slot count padding**: bucketed throughout. Power-of-2 buckets to 256 (8, 16, 32, 64, 128, 256), then linear +128 buckets (384, 512, 640, …). Body-size padding follows the same shape with 256KB as the power-of-2 ceiling and 256KB linear steps above. +- **Slot count padding**: bucketed throughout. Power-of-2 buckets from 8 to 256 (minimum bucket is 8 — singleton/tiny-set posts pad up to 8 to avoid leaking "new persona with no vouchees"), then linear +128 buckets (384, 512, 640, …). Body-size padding follows the same shape with 256KB as the power-of-2 ceiling and 256KB linear steps above. +- **Access-grant ordering**: **append at tail** (Scott's call). New entries land at the end of `pub_post_set` + `wrap_slots`. `pub_x_index` values in already-stored comments stay valid. Small positional-recency leak (tail = recent grants) is the accepted cost. - **Custom mode UI**: deferred. v1 ships only the three presets (Public / Friends-only / FoF). Power-user custom-subset UI is v2. - **Slot deduplication**: dedup at the `V_x` byte level. One slot per unique key. - **Body length padding**: yes — pad to next power of 2 up to 256KB, then 256KB chunks above. diff --git a/sessions.md b/sessions.md index d349278..e4d5219 100644 --- a/sessions.md +++ b/sessions.md @@ -65,6 +65,18 @@ See `CONTRIBUTING.md` for the protocol. See `AGENTS.md` for the Claude-specific **Stopping point**: commit `b8b38a6` (Layer 1) + new commit for Layer 2 both on branch; not merged. Awaiting Scott. +### Update 2026-04-24 — Layer 3 round 2 (last two open questions) + +Two follow-up questions resolved: + +- **Access-grant slot ordering**: **append at tail** (not re-shuffle). I'd initially overcorrected to a "switch comments from index to pub_x bytes so shuffles are free" change; Scott reverted that and clarified the choice. Append-at-tail preserves `pub_x_index` stability across the post's lifetime — already-stored comments stay verifiable, no write amplification on grant. Accepted positional-recency leak (tail = newest grants). +- **Minimum slot bucket**: **8**. Singleton/tiny-set posts pad up to 8 slots. Brand-new personas don't publish "I have no vouchees" headers. + +**Files touched**: +- `docs/fof-spec/layer-2-mode2-fof-comments.md`: access-grant lead decision made explicit about append-at-tail and index stability. +- `docs/fof-spec/layer-3-mode1-fof-closed.md`: minimum-8 floor added to padding lead decision; both open questions moved to Resolved. +- `sessions.md`: this entry. + ### Update 2026-04-24 — Layer 3 round 1 + cross-cutting padding rule (corrected) Scott talked to Opus and resolved Layer 3 open questions + introduced a unified padding rule that supersedes Layer 2 round 2's `rand(32..=128)`. From 971766cb3c4a4e3ee4e5408e98f8510c86f21797 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Wed, 13 May 2026 01:07:04 -0400 Subject: [PATCH 07/34] =?UTF-8?q?docs:=20Layer=204=20=E2=80=94=20rotation,?= =?UTF-8?q?=20revocation,=20key=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the decisions from the Layer 4 conversation with Scott: Default narrowing on a single post = Layer 2 revocation (existing). Advanced narrowing of read access = full re-issue with optional supersedes_post_id link (network-heavy, opt-in). V_me rotation = the persona-wide revocation primitive. Generate new V_me, distribute to non-revoked vouchees via next bio-post batch. Receiver-chain model: receivers append new V_me alongside old (not overwrite). Trial-unwrap iterates the chain. Grandfather by default: CDN is V_me-blind, so rotation does NOT auto-cascade comment deletions. Revoked vouchee retains comment authority on old posts unless author opts to cascade per-pub_x revocations. Per-post cascade is opt-in. Local-only own_post_slot_provenance table lets author query "which pub_x's in my posts were sealed under V_me_old?" and publish per-pub_x RevocationEntries. New optional KeyBurnDiff primitive (signed header-diff) swaps a V_me_old wrap_slot for a V_me_new one in-place on a specific post. For the leaked-V_me scenario. Body CEK unchanged. Skeleton's PostKeyRotation record removed entirely. Layer 1 updated: rotation is append-only at receivers; pointer to Layer 4. Multi-epoch bio-post-batch toggle hook added. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/fof-spec/layer-1-vouch-primitive.md | 4 +- docs/fof-spec/layer-4-keypair-rotation.md | 176 ++++++++++++---------- sessions.md | 29 ++++ 3 files changed, 130 insertions(+), 79 deletions(-) diff --git a/docs/fof-spec/layer-1-vouch-primitive.md b/docs/fof-spec/layer-1-vouch-primitive.md index 7556f70..5d87ebe 100644 --- a/docs/fof-spec/layer-1-vouch-primitive.md +++ b/docs/fof-spec/layer-1-vouch-primitive.md @@ -30,7 +30,7 @@ After Layer 1 ships: - **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. +- **`V_me` rotation IS the persona-wide revocation primitive.** To remove a vouchee, generate `V_me_new` and distribute via the next bio-post batch to every current vouchee EXCEPT the revoked one. The revoked person retains `V_me_old`. Old posts sealed under `V_me_old` stay accessible to anyone who still holds `V_me_old` (grandfathered by default). See [Layer 4](layer-4-keypair-rotation.md) for the full lifecycle, optional cascade, and key-burn primitive. --- @@ -207,7 +207,7 @@ 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. +- **Settings**: "Rotate my vouch key" → generates new `V_me` epoch in `vouch_keys_own` (prior epoch retained, marked `is_current = 0`). Optionally offers to issue the new key to existing vouchees minus any marked-revoked. Defaults to single-epoch bio-post batch; advanced multi-epoch toggle available for cases where vouchees on device-wipe need multi-epoch re-bootstrap. See [Layer 4](layer-4-keypair-rotation.md). - **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. diff --git a/docs/fof-spec/layer-4-keypair-rotation.md b/docs/fof-spec/layer-4-keypair-rotation.md index 68f5339..7399487 100644 --- a/docs/fof-spec/layer-4-keypair-rotation.md +++ b/docs/fof-spec/layer-4-keypair-rotation.md @@ -1,122 +1,144 @@ -# Layer 4 — Per-Post Keypair Rotation +# Layer 4 — Rotation, Revocation, and Key Lifecycle -**Scope**: Graceful rotation of `(priv_post, pub_post)` when the author's FoF set changes (new vouches granted, `V_me` rotated, or a vouchee removed). Old comments remain verifiable under the old `pub_post`; new comments require the new `pub_post`. +**Scope**: How an author narrows access on a published post, and how a persona rotates their own `V_me`. Most of the mechanism already exists in Layers 1–2. Layer 4's job is to lock in the policy and add the local-only provenance table that makes selective cascades possible. --- ## Goal -- Author can update the FoF set of an existing post without deleting / recreating it. -- A `PostKeyRotation` record, signed by author identity key, carries a new `(priv_post', pub_post')` wrapped under the current keyring. -- Existing comments under old `pub_post` stay cryptographically valid. -- New comments must sign under `priv_post'`. -- Readers who were admitted under the OLD set but not the NEW one retain read access to the body (CEK didn't change) but can no longer produce accepted comments. +- An author can narrow comment authority on a published post via Layer 2 revocation (default, cheap). +- An author can narrow read access by republishing the post with a new key and narrower wrap_slots (advanced, network-heavy, opt-in per-post). +- A persona rotates `V_me` to remove a vouchee. The new `V_me` is issued to all non-revoked vouchees via the next bio-post wrapper batch. The revoked vouchee retains the old `V_me`. +- Old posts (sealed under any era of `V_me`) stay readable by their original audience — no automatic rewrite. The CDN does not auto-cascade comment deletions on rotation. +- Authors retain *per-post discretion* to cascade comment revocations onto old posts when they want to, by publishing per-pub_x revocation diffs against pub_x's they know were sealed under the old `V_me`. +- An optional **key-burn** primitive lets an author scrub a specific `V_me` from a specific post's wrap_slots in-place, for the narrow case of a leaked `V_me`. --- ## Lead decisions -- **Body CEK is NOT rotated.** Only the signing keypair rotates. Rotation's purpose is to narrow (or widen) the *commenter* set going forward, not revoke read access to the body. Read access of already-distributed content is non-recoverable by design — if an author needs that, they delete the post. -- **Rotation is append-only.** Rotation records accumulate; `pub_post_n` is derived from the latest rotation record. Old `pub_post` values are retained for verifying already-posted comments. -- **Rotation is optional.** Simple case is a post with one immutable `pub_post`. Layer 4 adds the escape hatch; most posts never rotate. -- **Author-signed.** Only the post author (identity key) can rotate. Prevents an admitted commenter from rotating others out. +- **Default = Layer 2 revocation.** Narrowing comment authority on a single published post: author signs a `RevocationEntry` for a specific `pub_x`, propagation nodes delete locally-stored comments by that signer, remove the entry from `pub_post_set`, and forward. No new wire primitive. +- **Advanced = full re-issue.** Author publishes a fresh post with new `(CEK, pub_post_set, wrap_slots)` under a narrower V_x set, optionally with a `supersedes_post_id` link for engagement continuity. Old post stays at its old `post_id` or is locally deleted. Heavy: re-encrypts body, rebuilds engagement context, costs bandwidth. Used only when narrowing READ access matters more than retaining the post in its current form. +- **`V_me` rotation IS the persona-wide revocation primitive.** No separate "kill V_me" wire message. To remove a vouchee, the persona generates a new `V_me_new` and distributes it to every current vouchee except the revoked one via the next bio-post batch (Layer 1 mechanism, unchanged). The revoked vouchee's only key remains `V_me_old`. +- **Receiver keeps the chain (Option A).** When a persona receives `V_B_new` from Party B, they append it to `vouch_keys_received` rather than overwrite. They now hold `{V_B_old, V_B_new}` (and any earlier epochs). On any wrap_slot unwrap attempt, the client trials every epoch in the chain. UX: the "current" key for outgoing operations is the latest received; older epochs are archived but kept for reading historical content. +- **Old posts are grandfathered by default.** `V_me` rotation does NOT automatically trigger CDN comment deletion. CDN's revocation primitive operates on `pub_x`, not `V_me` — and CDN is V_me-blind. After rotation, the revoked vouchee retains the old `V_me`, retains read access to posts with V_me_old slots, and retains comment authority on those posts unless the author explicitly publishes per-pub_x revocations. +- **Per-post cascade is opt-in by the author.** The author can choose to cascade a V_me rotation onto a specific old post by publishing a `RevocationEntry` for the pub_x's that were sealed under the old V_me. The author knows this mapping locally (see `own_post_slot_provenance` below); the CDN does not. Cascade can be all posts (batch action) or a chosen subset. +- **Key burn is optional, narrow scope.** For the case where `V_me_old` has *leaked* (not just rotated for turnover), the author may publish a signed header-diff on a specific post that swaps the V_old wrap_slot for a V_new wrap_slot in-place. Same propagation primitive as revocation and access-grant. Scrubs V_old from the CDN copy of that post. Future observers acquiring leaked V_old can no longer fresh-decrypt the body. Locally-cached plaintext on existing readers' devices is unrecoverable by any wire mechanism (out of scope). +- **`vouch_keys_own` retains multi-epoch rows.** Old `V_me` epochs are never deleted automatically. May be deleted by explicit user action with a prominent warning ("this prevents future re-keys / cascades on any post sealed under this epoch"). --- -## Data model +## What goes away from the original skeleton -### `PostKeyRotation` record +The skeleton's `PostKeyRotation` record (with `rotation_index`, `pub_post_index` on comments, time-bucketed signature verification) is removed entirely. Its job is done by: -```rust -struct PostKeyRotation { - post_id: PostId, - rotation_index: u32, // monotonic; 0 = original keypair (implicit), 1 = first rotation, etc. - new_pub_post: [u8; 32], - new_wrap_slots: Vec, // wraps new priv_post under current keyring (same as post creation) - superseded_at: u64, // ms; rotation timestamp - sig: [u8; 64], // author identity-key signature over the above -} +- Layer 2's `RevocationEntry` for the comment-narrowing case. +- Standard post-publish + optional `supersedes_post_id` for the re-issue case. +- In-place wrap_slot swap as a new, optional key-burn diff (below). + +What goes away: +- `PostKeyRotation` record type. +- `rotation_index` / `pub_post_index` field on comments. +- Time-bucketed cross-rotation signature verification. + +What stays: +- `RevocationEntry` from Layer 2. +- Standard post-publish path. +- `vouch_keys_own` and `vouch_keys_received` from Layer 1, both with multi-epoch retention. + +--- + +## Data model additions + +### `own_post_slot_provenance` (local only, never on wire) + +The author needs to know which slot in each of their posts was sealed under which V_x, so they can selectively cascade a V_me rotation onto old posts. This is author-local state, not transmitted. + +``` +own_post_slot_provenance( + author_persona_id BLOB, + post_id BLOB, + slot_index INTEGER, -- index into pub_post_set / wrap_slots + sealed_under_v_x_owner BLOB, -- which persona issued the V_x used (== author_persona_id for the author's own V_me slots) + sealed_under_v_x_epoch INTEGER, + pub_x BLOB(32), -- the pub_x in the post's pub_post_set for this slot + PRIMARY KEY (author_persona_id, post_id, slot_index) +) ``` -Persisted as a sidecar to the post. TBD — OPUS: whether this lives in its own table (`post_key_rotations`) or as a serialized column on the post. +Populated at post-publish time. Used at cascade time: `SELECT pub_x FROM own_post_slot_provenance WHERE author_persona_id = ? AND sealed_under_v_x_owner = ? AND sealed_under_v_x_epoch = ?` returns the pub_x list to revoke. -### Comment verification after rotation - -Each comment's `group_sig` is verified against a specific `pub_post`. Determination rule: - -- If `comment.created_at < rotation_1.superseded_at` → verify against `pub_post_0` (original). -- If `rotation_n.superseded_at ≤ comment.created_at < rotation_{n+1}.superseded_at` → verify against `rotation_n.new_pub_post`. -- If `comment.created_at ≥ latest_rotation.superseded_at` → verify against latest `new_pub_post`. - -TBD — OPUS: time-based bucketing requires trusting `comment.created_at`. Alternative: comment carries an explicit `pub_post_index` field pointing at which keypair generation it's signed under. Lead leaning: **explicit index field in comment**, avoids clock-skew ambiguity. - -### Extend `InlineComment` (from Layer 2) +### Optional `supersedes_post_id` field on `PostHeader` ```rust -struct InlineComment { +struct PostHeader { // ... existing fields ... - #[serde(default)] - pub_post_index: u32, // 0 for original keypair, n for rotation n - group_sig: ..., - vouch_mac: ..., + + #[serde(default, skip_serializing_if = "Option::is_none")] + supersedes_post_id: Option, } ``` ---- +Used by the advanced re-issue path. Author identity sig covers it. Readers may display "this is a re-issued version of an earlier post." -## Rotation flow (author side) +### `KeyBurnDiff` (header-diff type) -1. Author changes FoF-relevant state (new vouch granted, someone un-vouched, `V_me` rotated). -2. Author decides to re-gate a specific post's comments: UI action "rotate comment keys for this post." -3. Generate new `(priv_post', pub_post')`. -4. Re-wrap `priv_post'` under the author's CURRENT keyring (the same algorithm as initial post creation, Layer 3). -5. Build `PostKeyRotation` record, sign, publish. -6. Rotation record propagates via normal CDN (it's a diff on the post, same mechanism as engagement diffs). +```rust +struct KeyBurnDiff { + post_id: PostId, + slot_index: u32, // which slot to swap + new_wrap_slot: WrapSlot, // sealed under V_x_new (typically author's V_me_new) + new_pub_x: [u8; 32], // corresponding pub_x for pub_post_set replacement + sealed_at_ms: u64, + author_sig: [u8; 64], // author identity-key sig over the above + parent_post_id +} +``` + +Propagation: same path as revocation diffs. File-holders apply by replacing `wrap_slots[slot_index]` and `pub_post_set[slot_index]` in their local copy of the post header. Forward to neighbors. Idempotent (re-applying with the same slot_index + same new pub_x is a no-op). --- -## Reader/commenter side +## Author UX surfaces -- On receiving a `PostKeyRotation` record, readers store it keyed by `(post_id, rotation_index)`. -- At comment-creation time: look up the **latest** rotation record for the parent post; trial-decrypt the new slots; if success, use `priv_post'` to sign, set `pub_post_index` to latest index. -- At comment-verification time: look up the rotation referenced by `comment.pub_post_index`; verify against that `pub_post`. - -If a reader can unwrap `rotation_n.wrap_slots` but NOT `rotation_{n+1}.wrap_slots`, they've been rotated out between n and n+1. They can still READ the body (CEK unchanged) and verify historical comments; they cannot author new comments after rotation n+1. +- **"Remove this commenter from this post"** → `RevocationEntry` for that specific `pub_x`. Standard Layer 2 path. +- **"Rotate my vouch key"** (Settings) → generates a new `V_me` epoch in `vouch_keys_own`, sets current. Old epoch retained. Then offers: "Issue the new key to your existing vouchees?" → if yes, queues a fresh bio-post batch wrapping V_me_new for all current targets except those marked revoked. Standard Layer 1 mechanism. +- **"Cascade this rotation onto my old posts"** (offered after a rotation if it was triggered by a revoke action) → batch action that queries `own_post_slot_provenance` for the rotated-out epoch and publishes per-pub_x revocations on each affected post. Costs N RevocationEntries; can be done in background. Optional. Per-post selection allowed. +- **"Re-issue this post with narrower access"** (advanced) → opens compose with body pre-filled and `supersedes_post_id` set. New audience pickable. Old post optionally deleted on publish. +- **"Burn this leaked key from a post"** (rare) → publishes a `KeyBurnDiff` swapping the V_me_old slot for a V_me_new slot on a specific post. Offered when the user marks `V_me_old` as leaked. Can be batched across all the author's posts via the provenance table. --- -## Propagation +## Cascade decision tree (for the author) -Rotation records are signed post-deltas. They reuse the existing `BlobHeaderDiff` propagation mechanism — the post header gains the rotation's `pub_post` and `wrap_slots`; readers pull updated posts through the normal CDN. - -TBD — OPUS: whether rotation records are part of the post's BlobHeader diff (natural fit) or a separate post-referencing record (cleaner separation but more protocol surface). - ---- - -## Edge cases - -- **Multiple rotations in short succession.** Monotonic index + explicit `pub_post_index` on comments disambiguates. No clock dependency. -- **Comment authored against stale rotation.** If a commenter creates a comment under rotation n, but by the time it propagates, rotation n+1 exists — the comment is still valid against rotation n. Readers verify against the rotation the comment declares. -- **Attacker forges rotation.** Rotation is signed by author identity key. Forgery == identity key compromise, which is outside FoF scope. -- **Reader never sees rotation record but sees new comments.** Until the rotation record arrives, new comments appear "unverified." Filter-out at render leaves them as pending until rotation arrives. Standard eventually-consistent behavior. +| Scenario | Default action | Optional escalation | +|---|---|---| +| Vouchee unfollowed, casual cleanup | Rotate V_me. Old posts grandfathered. | None. | +| Vouchee misbehaving on one specific post | Layer 2 revocation on that post's `pub_x`. | None. | +| Vouchee misbehaving across many posts | Rotate V_me. Cascade revocations onto every post they ever had access to. | Optional follow-up: key-burn if their continued read access is unacceptable. | +| V_me_old leaked | Rotate V_me. Cascade revocations onto all affected posts. | Key-burn V_me_old slots on every affected post. | --- ## Open questions -- **Rotation on `V_me` rotation.** When a persona rotates their own `V_me`, do we auto-rotate every one of their open FoFClosed posts? Cost is O(posts × eligible_keys). Lead leaning: **no auto-rotation**; user opts in per-post. Posts without rotation continue to accept comments from the old keyring (which still holds the old `V_me`). -- **Garbage-collecting old rotation records.** They're needed to verify historical comments. Never GC'd? Or keep only most recent N? Lead leaning: keep all; historical comments don't re-verify often and the data is cheap. -- **UI for rotation.** "Update who can comment" button on post. Simple. No scheduling / batch rotation UI in v1. -- **Rotation without keyring change.** Could be used to kick out a specific commenter by rotating and manually excluding their winning `V_x`. But winning `V_x` isn't known to the author (wrap slots are anonymous). Practical effect: authors can widen or narrow the whole set, not surgically exclude one person, without additional support. +- **Bio-post batch contents during rotation.** Default: wrap only the new V_me epoch in the next batch. Advanced UI option: wrap multiple epochs for vouchees who lost their device and need to re-bootstrap. Lead leaning: default to new-only; advanced toggle for multi-epoch. +- **`supersedes_post_id` binding strength.** The post-header `author_sig` already covers the field. Sufficient, or do we want a reciprocal "this post has been superseded" diff on the old post for symmetric discoverability? Lead leaning: one-way link is sufficient; old post being deleted or not is independent. +- **Key-burn vs. body re-encryption.** Key-burn swaps wrap_slots but keeps the body ciphertext (still encrypted under the same CEK). A reader who unwrapped via V_me_old still has CEK cached locally; their local plaintext copy is unaffected. Key-burn only prevents *fresh* decryption of the wire ciphertext. Is that sufficient, or should key-burn also imply CEK rotation? CEK rotation = re-encrypting the body = essentially full re-issue. Lead leaning: key-burn does NOT rotate CEK; it's specifically the "scrub V_old from headers" primitive. Full re-issue remains available if CEK rotation is wanted. +- **`own_post_slot_provenance` export.** Lost on device wipe means the author can't cascade-revoke after a fresh install. Lead leaning: include in identity export bundle. +- **Garbage-collecting `vouch_keys_own` ancient epochs.** Never auto-GC. User-explicit only, with warning. --- ## Ship criteria for Layer 4 -- `PostKeyRotation` record type exists end-to-end. -- Author UI action: "Update who can comment on this post." -- Rotation records propagate via CDN. -- Comment signing uses latest rotation's `priv_post`; `pub_post_index` attached. -- Comment verification routes to the correct `pub_post` via index. -- Back-compat: posts without rotation records are handled as `pub_post_index = 0` uniformly. -- Integration test: A posts FoFClosed. B comments (admitted). A vouches for C, rotates. C comments (admitted). Old B-comment still verifies; new B-comment still verifies (B still in keyring); new D-comment (never admitted) rejected. +- `vouch_keys_own` retains multi-epoch rows without auto-deletion on rotation. +- `vouch_keys_received` retains multi-epoch rows; trial-unwrap iterates the chain per voucher. +- `own_post_slot_provenance` table populated at every post-publish. +- Author UI: "Rotate my vouch key" with optional follow-up "Issue to existing vouchees." +- Author UI: "Cascade revocations onto my old posts" as a post-rotation action. +- Author UI: "Re-issue this post with narrower access" (advanced). +- Author UI: "Burn leaked key" as a rare/explicit action. +- `KeyBurnDiff` propagation: same path as revocation diffs; idempotent application. +- `supersedes_post_id` field on `PostHeader` is wire-defined and back-compat (default None). +- No `PostKeyRotation` record exists. +- Integration test: A vouches for B and C. A posts FoF post P sealed under V_a (and other V_x's). B and C read + comment. A rotates V_a → V_a' to remove C; issues V_a' to B only. C still holds V_a; A's new posts (sealed only under V_a') invisible to C, visible to B. C can still read P (V_a-sealed slot still in P's wrap_slots); A optionally cascades a revocation on P that removes C's pub_x and deletes C's comments on P. A optionally key-burns V_a from P, swapping the V_a slot for a V_a' slot — C can no longer fresh-decrypt P from the wire (already-cached plaintext on C's device unaffected, out of scope). diff --git a/sessions.md b/sessions.md index e4d5219..5e86a7b 100644 --- a/sessions.md +++ b/sessions.md @@ -65,6 +65,35 @@ See `CONTRIBUTING.md` for the protocol. See `AGENTS.md` for the Claude-specific **Stopping point**: commit `b8b38a6` (Layer 1) + new commit for Layer 2 both on branch; not merged. Awaiting Scott. +### Update 2026-05-13 — Layer 4 written (rotation + revocation + key lifecycle) + +Iterative session with Scott. Recap of where the model landed: + +**Rotation/revocation model (now in spec)**: +- Default narrowing of comment authority on a post = Layer 2 revocation (existing mechanism). No new wire primitive. +- Advanced narrowing of read access = full re-issue with `supersedes_post_id` link. Discouraged due to network overhead. +- `V_me` rotation = the persona-wide revocation primitive. Generate new V_me, distribute via next bio-post batch to non-revoked vouchees only. Revoked person retains old V_me. +- Receiver-chain model: receiver appends new V_me to `vouch_keys_received` (does NOT overwrite). Trial-unwrap iterates the chain. UX-wise the "current" key is the newest; older epochs are archived but kept for historical decrypts. +- **Grandfather-by-default**: CDN is V_me-blind, so rotation does NOT auto-cascade comment deletion. Revoked vouchee keeps comment authority on old posts unless the author opts to cascade per-pub_x revocations. +- **Per-post cascade is opt-in**: author can query a local `own_post_slot_provenance` table to find pub_x's sealed under V_me_old in any of their posts, then publish per-pub_x RevocationEntries to cascade. +- **Key-burn primitive (new, optional)**: signed `KeyBurnDiff` swaps an old wrap_slot for a new one in-place on a specific post. Used when V_me leaked and the author wants to scrub it from the CDN copy of old posts. Body CEK unchanged; affects future fresh-decrypts only. + +**Cryptographic stack confirmed (Scott reconfirmed)**: +- Body encryption: symmetric ChaCha20-Poly1305 under CEK. PQ-safe. +- Wrap_slots: AEAD under V_x. PQ-safe. +- Comment signing: **asymmetric Ed25519** (per-V_x per-post `(pub_x, priv_x)`). NOT PQ-safe; ML-DSA-65 migration deferred. Scott confirmed the asymmetric-for-signing tradeoff is intentional — it's what makes CDN-level bandwidth-DoS filtering work. + +**Files touched in this round**: +- `docs/fof-spec/layer-4-keypair-rotation.md`: full rewrite from skeleton. +- `docs/fof-spec/layer-1-vouch-primitive.md`: rotation language updated to point at Layer 4's append-only model; multi-epoch UI hook added. + +**Branch state**: `docs/fof-spec-layer1-bio-grants` (despite the name, holds all Layer 1–4 spec work). Commit pending. Not merged per Scott's standing instruction. + +**Pending**: +- Layer 5 (unlock cache + prefilter): existing skeleton text still reflects single per-post keypair model. Needs reconciliation with per-V_x model from Layer 2. +- Layer 3 (Mode 1): partially-superseded banner still present. Needs Scott/Opus reconciliation pass. +- Layer 6 (revocation): stub still. Largely obviated by Layer 4 work. + ### Update 2026-04-24 — Layer 3 round 2 (last two open questions) Two follow-up questions resolved: From 73b1e24f9a4cb5de1a10292531849b3fe20df289 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Wed, 13 May 2026 01:10:43 -0400 Subject: [PATCH 08/34] =?UTF-8?q?docs:=20spec=20cleanup=20=E2=80=94=20Laye?= =?UTF-8?q?r=205=20wording,=20Layer=203=20banner,=20Layer=206=20superseded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 5: replace two priv_post references in author-direct fast path with the correct per-V_x CEK + priv_x lookup. Cache/prefilter logic unchanged. Layer 3: replace the "partially superseded" warning banner with a plain scope note explaining the Mode 1/Mode 2 distinction reduces to "body encrypted vs body plaintext"; wrap-slot canonical form lives in Layer 2. Layer 6: mark as superseded by Layer 4. README updated to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/fof-spec/README.md | 2 +- docs/fof-spec/layer-3-mode1-fof-closed.md | 6 +++--- docs/fof-spec/layer-5-prefilter-and-cache.md | 4 ++-- docs/fof-spec/layer-6-revocation.md | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/fof-spec/README.md b/docs/fof-spec/README.md index 34f04c8..3343a0c 100644 --- a/docs/fof-spec/README.md +++ b/docs/fof-spec/README.md @@ -47,7 +47,7 @@ Build and ship bottom-up. Each layer is independently shippable and exercised be 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. 5. **[Layer 5](layer-5-prefilter-and-cache.md) — Unlock cache + prefilter optimization.** Author-direct fast path, winning-V_x-per-author cache, unreadable-posts retry table, re-try-on-new-V_x trigger. Performance-critical at realistic keyring sizes (400–500 keys × 400–500 slots). -6. **[Layer 6](layer-6-revocation.md) — Revocation & rotation cascades.** Deferred; may not be in v1. Drafted as a stub for design review. +6. **[Layer 6](layer-6-revocation.md) — Revocation & rotation cascades.** Superseded by Layer 4. File retained as a record of alternatives considered. --- diff --git a/docs/fof-spec/layer-3-mode1-fof-closed.md b/docs/fof-spec/layer-3-mode1-fof-closed.md index b74ba89..944692f 100644 --- a/docs/fof-spec/layer-3-mode1-fof-closed.md +++ b/docs/fof-spec/layer-3-mode1-fof-closed.md @@ -1,10 +1,10 @@ # Layer 3 — Mode 1: `FOF_CLOSED` Posts -> **⚠️ Partially superseded by Layer 2 rewrite (2026-04-24).** Layer 2 now defines the canonical wrap-slot structure (dual read/sign derivation), `pub_post_set`, per-`V_x` signing keypair, and CDN-level verification. Layer 3 inherits all of that unchanged — the Mode 1 vs Mode 2 distinction reduces to "body encrypted under CEK (Mode 1) vs body plaintext (Mode 2)." The sections below still reflect the earlier single-keypair design and will be reconciled when Scott + Opus review Layer 3. +**Scope**: New `PostVisibility::FoFClosed` variant. Both post body AND comments are gated to the FoF graph. Body is encrypted under CEK; readership emerges from keyring intersection with `wrap_slots`. -**Scope**: New `PostVisibility::FoFClosed` variant. Both post body AND comments are gated to the FoF graph. Body is encrypted; readership emerges from keyring intersection with `wrap_slots`. +The wrap-slot structure, `pub_post_set`, per-`V_x` signing keypair, and CDN-level verification are all defined in [Layer 2](layer-2-mode2-fof-comments.md) (the canonical form). Layer 3 inherits Layer 2's structures unchanged — the Mode 1 vs Mode 2 distinction reduces to: **body encrypted under CEK (Mode 1) vs body plaintext (Mode 2)**. Wrap-slot read-part still carries the CEK in both modes; in Mode 2 the CEK is used only to derive `CEK_comments`, in Mode 1 it is used for both body and comments. -Builds on Layer 2's `pub_post` / `priv_post` / `wrap_slot` primitives — same structures, just that the CEK encrypting the body is *also* in the wrap slots (alongside `priv_post`). +The legacy `pub_post`/`priv_post` field names that appear in some sections below are retained for readability; semantically read them as the canonical per-V_x `(pub_x, priv_x)` from Layer 2. --- diff --git a/docs/fof-spec/layer-5-prefilter-and-cache.md b/docs/fof-spec/layer-5-prefilter-and-cache.md index 6194a2d..947fe09 100644 --- a/docs/fof-spec/layer-5-prefilter-and-cache.md +++ b/docs/fof-spec/layer-5-prefilter-and-cache.md @@ -18,7 +18,7 @@ This layer is load-bearing, not optional. Without it, a modest keyring × slot m - **Cache the winning `(persona, V_x)` per author.** First time persona `P` decrypts an FoFClosed post from author `A` using `V_x`, remember the tuple. Next post from `A`: try that `(P, V_x)` first. Author almost always re-wraps under the same set. - **Track unreadable posts.** If no key currently held unwraps a post, insert into `vouch_unreadable_posts` for later retry. Clearing this set is cheap and necessary — a newly-received `V_x` potentially unlocks an arbitrary number of old posts. -- **Author-direct fast path.** If `post.author` is one of the reader's persona IDs, the reader is the author and holds `priv_post` implicitly (author-local cache of per-post keypairs). No wrap-slot iteration needed. +- **Author-direct fast path.** If `post.author` is one of the reader's persona IDs, the reader is the author and holds the post's CEK + every `priv_x` directly from creation (author-local cache, keyed by `post_id`). No wrap-slot iteration needed. - **Prefilter tag precompute per-post, not per-feed-fetch.** At ingest time (once per post received), compute the reader's full set of `HMAC(V_x, post_id)[:2B]` tags and note which slots have matching prefilter values. Cache that index. Avoids recomputing HMACs on every re-render. --- @@ -76,7 +76,7 @@ TBD — OPUS: whether this table is worth it. Alternative: keep `wrap_slots` as On incoming FoFClosed post from author `A`: -1. **Author-direct check**: Is `A` in reader's list of personas? If yes → reader authored it; pull `priv_post` from local author cache. Done. +1. **Author-direct check**: Is `A` in reader's list of personas? If yes → reader authored it; pull CEK (and any needed `priv_x`) from local author cache keyed by `post_id`. Done. 2. **Cache lookup**: Query `vouch_unlock_cache` for `(any_persona, A)`. For each cached winning `V_x`: - Compute `prefilter_tag = HMAC(V_x, post_id)[:2B]`. - Find matching slot(s) in post's `wrap_slots`; attempt AEAD-open. diff --git a/docs/fof-spec/layer-6-revocation.md b/docs/fof-spec/layer-6-revocation.md index cea5e37..8104294 100644 --- a/docs/fof-spec/layer-6-revocation.md +++ b/docs/fof-spec/layer-6-revocation.md @@ -1,8 +1,8 @@ # Layer 6 — Revocation & Rotation Cascades -**Status**: Stub. May not be in v1. Drafted for design review only. +**Status**: **Superseded by [Layer 4](layer-4-keypair-rotation.md) (2026-05-13).** Layer 4 settled the revocation and cascade design: V_me rotation as the persona-wide revocation primitive, receiver-chain storage, grandfather-by-default with author-opt-in per-post cascades, and the optional `KeyBurnDiff` primitive for leaked-V_me scenarios. Nothing in Layer 6 below is load-bearing; this file is retained as a record of the alternatives that were considered before Layer 4 was written. -**Scope**: Mechanism for a persona to un-vouch a specific vouchee without rotating `V_me` (which affects everyone), and for cascading rotations when a down-chain vouchee is un-vouched. +**Original scope (now resolved by Layer 4)**: Mechanism for a persona to un-vouch a specific vouchee without rotating `V_me` (which affects everyone), and for cascading rotations when a down-chain vouchee is un-vouched. --- From d7ce2f734c059132b10bbf44356c211a3d4fa530 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Wed, 13 May 2026 01:20:43 -0400 Subject: [PATCH 09/34] docs(design.html): add section 20a Friend-of-Friend Visibility Public-facing architecture description of the FoF post-gating system specified in docs/fof-spec/. Sits between section 20 (Encryption) and section 21 (Delete Propagation). All subsections marked badge-planned. Covers the user-facing 4-level visibility model, V_me primitive, bio-post HPKE distribution, dual-mode operation (public-body+FoF- comments vs FoFClosed), CDN-level comment verification, bucketed padding scheme, revocation/rotation/key-burn lifecycle, PQ-readiness, and the five ship-able layers. Visibility variants table updated with the new FoFClosed row. Disambiguation note added at top of section 20a noting this "vouch" is the cryptographic V_me primitive, distinct from the directory vouches in section 27. Reciprocal disambiguation note added at top of section 27 pointing the other direction. TOC entry added. Co-Authored-By: Claude Opus 4.7 (1M context) --- sessions.md | 28 ++++++++++++ website/design.html | 105 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/sessions.md b/sessions.md index 5e86a7b..52fe532 100644 --- a/sessions.md +++ b/sessions.md @@ -65,6 +65,34 @@ See `CONTRIBUTING.md` for the protocol. See `AGENTS.md` for the Claude-specific **Stopping point**: commit `b8b38a6` (Layer 1) + new commit for Layer 2 both on branch; not merged. Awaiting Scott. +### Update 2026-05-13 — design.html FoF section (20a) added + +Added a new section `20a. Friend-of-Friend Visibility` to `website/design.html`, sitting between Encryption (20) and Delete Propagation (21). Marked all subsections `badge-planned`. Layer table at the bottom shows ship status per layer. + +**Key writing decisions**: +- Up-front disambiguation note: this section's "vouch" is the cryptographic V_me primitive, distinct from the directory-vouch system in section 27. Symmetric disambiguation note added to section 27 pointing the other direction. +- User-facing 4-level model (Public / Friends-only / FoF / Custom-v2) leads. Crypto primitives follow. +- Mode 1 vs Mode 2 split called out via `card` divs. +- CDN-level verification highlighted as the propagation-DoS resistance story. +- Revocation lifecycle: three `card` blocks — per-post default, V_me rotation, opt-in cascade + key-burn. +- PQ-readiness explicitly addressed (symmetric primitives PQ-safe; Ed25519 → ML-DSA-65 swap path noted). +- Cross-ref to `docs/fof-spec/` for implementation detail. + +**Tables updated**: +- Visibility variants table (in section 20) now has a `FoFClosed` row with overhead + bucketed-audience note. +- New layer-status table at the bottom of section 20a shows the five ship-able layers. + +**Other touched**: +- TOC entry added (`20a. Friend-of-Friend Visibility`). +- `reference_design_index.md` auto-updated by the design-index hook on save. +- Section 27 (Directory) got a reciprocal disambiguation note pointing at section 20a. + +**Files in this commit**: +- `website/design.html` +- `sessions.md` (this entry) + +Branch state: still `docs/fof-spec-layer1-bio-grants`, still unmerged. Implementation can now begin from a coherent public design + internal spec. + ### Update 2026-05-13 — Layer 4 written (rotation + revocation + key lifecycle) Iterative session with Scott. Recap of where the model landed: diff --git a/website/design.html b/website/design.html index 4dccad2..032cda5 100644 --- a/website/design.html +++ b/website/design.html @@ -68,6 +68,7 @@ 18b. Erasure-Coded CDN Replication 19. Sync Protocol 20. Encryption + 20a. Friend-of-Friend Visibility 21. Delete Propagation 22. Social Graph Privacy 23. Multi-Device Identity @@ -1304,6 +1305,7 @@ END PublicNoneUnlimited Encrypted { recipients }~60 bytes per recipient~500 (256KB cap) GroupEncrypted { group_id, epoch, wrapped_cek }~100 bytes totalUnlimited (one CEK wrap for the group) + FoFClosed { pub_post_set, wrap_slots } Planned~154 bytes per admitted V_x, paddedBucketed (8/16/32/64/128/256, then +128 steps)

PostId integrity

@@ -1350,6 +1352,105 @@ END

Different profile versions per circle, encrypted with the circle/group key. A peer sees the profile version for the most-privileged circle they belong to. CircleProfileUpdate (0xB4) wire message. Public profiles can be hidden (public_visible=false strips display_name/bio).

+ +
+

20a. Friend-of-Friend Visibility Planned

+ +
+ Distinct from directory vouches. The "FoF vouch" described here is a cryptographic primitive for post readership and comment gating (per-persona symmetric key V_me). It is unrelated to the directory vouch system in section 27, which governs discovery-layer trust and bot-ring resistance. The two share vocabulary but operate at different layers. +
+ +

The problem

+

Existing visibility variants gate by explicit recipient lists (Encrypted{recipients}) or named-circle membership (GroupEncrypted). Neither expresses "people who are reachable through my social graph" without leaking the graph itself. FoF visibility fills that gap: posts whose readership emerges from cryptographic reachability through a unilateral vouch graph, with no recipient IDs on the wire and no centrally-computed membership lists.

+ +

User-facing model

+

Authors pick one of four visibility levels at compose time:

+ + + + + + +
LevelReaches
PublicAll readers (unchanged)
Friends-onlyPersonas you have vouched for
Friends-of-FriendsYour vouchees + every vouchee of anyone who vouched for you (emergent FoF)
Custom v2Author-selected subset of held vouch keys
+ +

Core primitives

+
    +
  • V_me: a 32-byte symmetric key owned by each persona. Distributed to everyone the persona vouches for. Anyone holding V_alice can decrypt wrap slots Alice sealed under it.
  • +
  • Keyring: per-persona, holds the persona's own V_me plus every V_x received from vouchers. The union of these is what makes FoF reach emergent: an author wraps a post slot under every V_x they hold, and any reader whose keyring intersects with that set can decrypt.
  • +
  • Wrap slot: an anonymous AEAD ciphertext in the post header, sealed under one V_x. Carries the post's CEK plus a per-slot signing key. No recipient ID visible on the wire.
  • +
  • Prefilter tag: a 2-byte HMAC(V_x, post_id)[:2B] on each slot. Readers precompute tags for keys in their keyring and skip non-matching slots, cutting trial-decrypt cost ~65,000× per post.
  • +
  • pub_post_set: list of all admitted signing pubkeys for a post's FoF set. Inline in the post header, randomly ordered. Allows CDN-level verification of comment signatures without revealing membership identities.
  • +
+ +

Distribution: vouches ride bio posts

+

Vouches are NOT delivered via DM. Instead, the voucher publishes anonymous HPKE-sealed wrappers (one per recipient) inside their bio post. HPKE (RFC 9180) provides recipient anonymity — wrapper ciphertext reveals nothing about the recipient's pubkey. Each wrapper is 48 bytes (32-byte sealed V_me + 16-byte AEAD tag); one shared ephemeral pubkey per batch.

+

Readers auto-scan bio posts of accounts they follow, trial-decrypting each wrapper against each of their personas. Cost is ~60µs per wrapper per persona on mobile — a 200-wrapper bio scanned against 3 personas is ~36 ms. The bio's VouchGrantBatch is padded with dummy wrappers and shuffled on every publish so observers see neither vouch-set size nor change-targets.

+ +

Two modes

+
+

Mode 2: Public body, FoF-gated comments

+

Body is plaintext in the CDN (indexable, cacheable, shardable — unchanged from existing public-post path). Comments are encrypted under a CEK derived from the wrap-slot CEK, so only FoF members can read them. Non-FoF observers see only ciphertext + signature fields. The compose UI exposes this via a new CommentPolicy::FriendsOfFriends variant.

+
+ +
+

Mode 1: FoFClosed (body + comments encrypted)

+

New PostVisibility::FoFClosed variant. Body is encrypted under CEK (same wrap-slot mechanism as comments). Both readership and comment authority emerge from keyring intersection with the post's wrap_slots.

+
+ +

CDN-level comment verification

+

Each wrap slot is dual-derived: one half yields the shared CEK (read capability), the other yields a per-V_x Ed25519 signing keypair (priv_x sealed inside the slot; the corresponding pub_x published in the post's pub_post_set). Comments declare which pub_x signed them and carry the signature.

+

Propagation nodes verify three things before forwarding a comment:

+
    +
  1. pub_x_index points to a valid entry in pub_post_set.
  2. +
  3. That entry is not in the post's revocation_list.
  4. +
  5. The comment's group_sig validates against that pub_x.
  6. +
+

Any failure → drop, do not forward. This kills the bandwidth-amplification attack that a single admitted-but-malicious FoF member could otherwise mount: their forgeries cannot pass the propagation gate.

+ +

Privacy properties

+
    +
  • Unilateral: vouching is a one-way act, no handshake. The FoF graph forms without bilateral negotiation.
  • +
  • Graph-private: wrap slots carry no recipient IDs. Observers cannot enumerate who can read a post.
  • +
  • Bucketed padding: slot count and body size are deterministically padded to fixed buckets (power-of-2 up to 256, then +128 steps for slots; same shape up to 256 KB then +256 KB steps for bodies). Observers learn the bucket, not the position within it.
  • +
  • Recipient anonymity on vouch distribution: HPKE key privacy ensures bio-post wrappers do not reveal recipients.
  • +
  • Per-post chain pseudonym (accepted tradeoff): the pub_x_index in a comment lets observers correlate "these N comments came through the same chain" within a single post. Cross-post correlation is broken because keys regenerate per post.
  • +
+ +

Revocation & key lifecycle

+

Three complementary mechanisms:

+
+

Per-post comment revocation (default)

+

The author signs a RevocationEntry for a specific pub_x on a specific post. Propagation nodes delete locally-stored comments by that signer, remove the entry from pub_post_set, append to revocation_list, and forward the diff. Retroactive: the mesh self-cleans as the diff sweeps through.

+
+
+

Persona-wide V_me rotation

+

To remove a vouchee, the persona generates V_me_new and issues it to every non-revoked vouchee via the next bio-post batch. Revoked vouchees retain V_me_old. Old posts (sealed under V_me_old) stay readable by anyone who holds the old key — grandfathered by default. The CDN does not auto-cascade revocations.

+

Receivers append new V_me values to their keyring (chain), so newly-issued keys do not invalidate prior ones — the receiver keeps holding the old key for reading historical content.

+
+
+

Opt-in cascade + key burn (advanced)

+

If the author wants to cut off comment authority on old posts after a rotation, they cascade by publishing per-pub_x revocations on each affected post. Local-only own_post_slot_provenance table maps "which pub_x was sealed under which V_me" so the author can target precisely.

+

For the rare case of a leaked V_me, an optional KeyBurnDiff primitive swaps the V_old wrap slot for a V_new wrap slot in-place on a specific post. Scrubs the leaked key from the CDN copy of old posts. Body CEK unchanged.

+
+ +

Performance budget

+

At realistic scale (~500 vouchees, ~500 wrap slots per post), reader-side decryption uses an unlock cache: the first time persona P decrypts a post from author A via key V_x, the (P, V_x) tuple is cached. Subsequent posts from A try that key first — one HMAC + one AEAD attempt in the hot path. Full scan only on cache miss; newly-received V_x triggers a retry sweep over the unreadable-posts table.

+ +

Post-quantum readiness

+

Body encryption, wrap slots, and HKDF/HMAC are all symmetric — PQ-safe. Comment signing uses Ed25519 today; the spec shape is algorithm-agnostic so ML-DSA-65 (~2 KB pubkey, ~3.3 KB signature) can substitute, optionally with a Merkle-commit variant on pub_post_set to keep header size bounded.

+ +

Implementation

+

Full crypto-level byte layouts, data models, wire-format additions, ship criteria, and integration tests are specified in docs/fof-spec/. The implementation is layered for bottom-up shipping:

+ + + + + + + +
LayerScopeStatus
1Vouch primitive (V_x keys, keyring, bio-post HPKE wrappers, scan policy)Planned
2Mode 2: public posts with FoF-gated comments, CDN-level verificationPlanned
3Mode 1: FoFClosed body + wrap slots + anonymous prefilterPlanned
4Rotation, revocation, key lifecycle (grandfather + cascade + key-burn)Planned
5Unlock cache + prefilter optimization (perf-critical at scale)Planned
+
+

21. Delete Propagation

@@ -1578,6 +1679,10 @@ END

The directory is an opt-in convenience layer for discovery and creator protection. It is not node access — losing directory presence does not disconnect anyone from the network or from their existing connections. This asymmetry is load-bearing: humans with mature relationships shrug off directory loss; bots and content thieves depend on it entirely.

+
+ Distinct from FoF cryptographic vouches. The "vouch" described in this section is a directory-layer trust signal governing discovery and bot-ring resistance. It is unrelated to the cryptographic vouch (V_me) in section 20a, which gates post readership and commenting via per-persona symmetric keys. The two share vocabulary but operate at different layers. +
+

Scope

  • Whitelist track — discoverability, vouch-based entry, graph-scoped visibility.
  • From 8a53d83306bad67258f236284675dc2628c8f0f0 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Wed, 13 May 2026 01:29:43 -0400 Subject: [PATCH 10/34] feat(fof-layer1): schema + storage API + vouch-grant crypto primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the foundational pieces for FoF Layer 1 (vouch primitive) per docs/fof-spec/layer-1-vouch-primitive.md: Schema (init_tables, CREATE TABLE IF NOT EXISTS — safe for upgrade and fresh installs): - vouch_keys_own: per-persona V_me history, append-only on rotation - vouch_keys_received: per-persona inbound keyring, multi-epoch - vouch_bio_scan_cache: short-circuits unchanged-bio re-scans - own_vouch_targets: author-local, never on wire, drives batch assembly Storage API: insert/list/lookup for all four tables, including current_own_vouch_key, list_received_vouch_keys, list_vouchers_for, record_bio_scan_result, upsert/revoke_vouch_target. Crypto: HPKE-style seal_vouch_grant / open_vouch_grant using existing ed25519 → X25519 derivation. Per-batch ephemeral X25519 keypair via generate_vouch_batch_ephemeral. Wrapper is 48B (32B sealed V_me + 16B AEAD tag). Recipient-free derivation context per spec — info string is "itsgoin/vouch-grant/v1/{key|nonce}/". 3 unit tests cover roundtrip + wrong-post-id + random-bytes-as-dummy. No behavior change yet; nothing wired in. Layer 1 wire types, persona auto-gen, publish/scan paths follow in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/crypto.rs | 175 ++++++++++++++++++ crates/core/src/storage.rs | 359 +++++++++++++++++++++++++++++++++++++ 2 files changed, 534 insertions(+) diff --git a/crates/core/src/crypto.rs b/crates/core/src/crypto.rs index 6b9d24f..f7264c1 100644 --- a/crates/core/src/crypto.rs +++ b/crates/core/src/crypto.rs @@ -12,6 +12,12 @@ use crate::types::{GroupEpoch, GroupId, GroupMemberKey, NodeId, PostId, WrappedK const CEK_WRAP_CONTEXT: &str = "itsgoin/cek-wrap/v1"; +/// FoF Layer 1: vouch-grant HPKE-style wrapper construction. +/// HKDF/derive_key info MUST be recipient-free (key privacy). +/// `bio_post_id` ties the wrapper to the publishing bio post. +const VOUCH_GRANT_KEY_CONTEXT: &str = "itsgoin/vouch-grant/v1/key"; +const VOUCH_GRANT_NONCE_CONTEXT: &str = "itsgoin/vouch-grant/v1/nonce"; + /// Convert an ed25519 seed (32 bytes from identity.key) to X25519 private scalar bytes. pub fn ed25519_seed_to_x25519_private(seed: &[u8; 32]) -> [u8; 32] { let signing_key = SigningKey::from_bytes(seed); @@ -193,6 +199,108 @@ pub fn unwrap_group_cek( Ok(cek) } +// --- FoF Layer 1: vouch-grant HPKE-style seal/open --- +// +// Per the FoF spec (docs/fof-spec/layer-1-vouch-primitive.md), a voucher +// publishes anonymous per-recipient wrappers inside their bio post. Each +// wrapper carries `V_me` (the voucher's symmetric key) sealed under a +// shared secret derived from ECDH between a per-batch ephemeral X25519 +// keypair and the recipient's persona X25519 key. +// +// Recipient anonymity ("key privacy") is preserved because: +// 1. Wrappers carry no recipient identifier. +// 2. The KDF info string is recipient-free (only the post_id appears). +// 3. All wrappers in a batch share the same ephemeral pubkey. +// +// Wire shape: 48 bytes per wrapper (32B sealed V_me + 16B AEAD tag). +// One 32B ephemeral pubkey shared across all wrappers in the batch. + +/// Generate a fresh ephemeral X25519 keypair for a vouch-grant batch. +/// Returns `(eph_priv_scalar, eph_pub)` in X25519 byte form. Reuses the +/// ed25519 → X25519 derivation path that the rest of the codebase uses +/// so all X25519 endpoints are produced identically. +pub fn generate_vouch_batch_ephemeral() -> ([u8; 32], [u8; 32]) { + let mut seed = [0u8; 32]; + rand::rng().fill_bytes(&mut seed); + let eph_priv = ed25519_seed_to_x25519_private(&seed); + let signing_key = SigningKey::from_bytes(&seed); + let eph_pub = signing_key.verifying_key().to_montgomery().to_bytes(); + (eph_priv, eph_pub) +} + +/// Derive the (wrapping_key, nonce) pair for a vouch-grant wrapper from +/// the ECDH shared secret and the publishing bio post's ID. +fn derive_vouch_grant_key_nonce( + shared_secret: &[u8; 32], + bio_post_id: &PostId, +) -> ([u8; 32], [u8; 12]) { + // Bake bio_post_id into the derivation context. Recipient-free. + let key_ctx = format!("{}/{}", VOUCH_GRANT_KEY_CONTEXT, hex_lower(bio_post_id)); + let nonce_ctx = format!("{}/{}", VOUCH_GRANT_NONCE_CONTEXT, hex_lower(bio_post_id)); + let wrapping_key = blake3::derive_key(&key_ctx, shared_secret); + let nonce_full = blake3::derive_key(&nonce_ctx, shared_secret); + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(&nonce_full[..12]); + (wrapping_key, nonce) +} + +fn hex_lower(bytes: &[u8; 32]) -> String { + let mut s = String::with_capacity(64); + for b in bytes { + s.push_str(&format!("{:02x}", b)); + } + s +} + +/// Seal `V_me` (32B) under the recipient's X25519 pubkey using the +/// batch's ephemeral X25519 private key. Returns the 48-byte wrapper +/// `ciphertext(32) || tag(16)`. +pub fn seal_vouch_grant( + eph_priv: &[u8; 32], + recipient_x25519_pub: &[u8; 32], + bio_post_id: &PostId, + v_me: &[u8; 32], +) -> Result> { + let shared_secret = x25519_dh(eph_priv, recipient_x25519_pub); + let (wrapping_key, nonce) = derive_vouch_grant_key_nonce(&shared_secret, bio_post_id); + let cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key) + .map_err(|e| anyhow::anyhow!("vouch-grant cipher init: {}", e))?; + let ciphertext = cipher + .encrypt(Nonce::from_slice(&nonce), v_me.as_slice()) + .map_err(|e| anyhow::anyhow!("vouch-grant seal: {}", e))?; + // ChaCha20-Poly1305 output is 32B plaintext + 16B tag = 48B. + if ciphertext.len() != 48 { + bail!("unexpected vouch-grant wrapper length: {}", ciphertext.len()); + } + Ok(ciphertext) +} + +/// Try to open a vouch-grant wrapper using the recipient's X25519 private +/// scalar. Returns `Some(V_me)` on success, `None` on AEAD failure (i.e., +/// this wrapper was not addressed to this recipient). +pub fn open_vouch_grant( + recipient_x25519_priv: &[u8; 32], + batch_eph_pub: &[u8; 32], + bio_post_id: &PostId, + wrapper_ciphertext: &[u8], +) -> Option<[u8; 32]> { + if wrapper_ciphertext.len() != 48 { + return None; + } + let shared_secret = x25519_dh(recipient_x25519_priv, batch_eph_pub); + let (wrapping_key, nonce) = derive_vouch_grant_key_nonce(&shared_secret, bio_post_id); + let cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key).ok()?; + let plaintext = cipher + .decrypt(Nonce::from_slice(&nonce), wrapper_ciphertext) + .ok()?; + if plaintext.len() != 32 { + return None; + } + let mut v_me = [0u8; 32]; + v_me.copy_from_slice(&plaintext); + Some(v_me) +} + /// Encrypt a post with a provided CEK, wrapping for recipients. /// Returns `(base64_ciphertext, Vec)`. pub fn encrypt_post_with_cek( @@ -1347,4 +1455,71 @@ mod tests { // Different calls produce different noise (with very high probability) assert_ne!(random_slot_noise(64), random_slot_noise(64)); } + + // --- FoF Layer 1: vouch-grant seal/open --- + + fn make_persona_x25519(seed_byte: u8) -> ([u8; 32], [u8; 32]) { + // Derive (x25519_priv, x25519_pub) from an ed25519 seed, mirroring + // the production path personas use. + let mut seed = [0u8; 32]; + seed[0] = seed_byte; + let priv_x = ed25519_seed_to_x25519_private(&seed); + let signing_key = SigningKey::from_bytes(&seed); + let pub_x = signing_key.verifying_key().to_montgomery().to_bytes(); + (priv_x, pub_x) + } + + #[test] + fn vouch_grant_roundtrip() { + let (alice_priv, _alice_pub) = make_persona_x25519(11); + let (bob_priv, bob_pub) = make_persona_x25519(22); + let bio_post_id: PostId = [7u8; 32]; + let v_me: [u8; 32] = [42u8; 32]; + + let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral(); + + // Seal for Bob + let wrapper = seal_vouch_grant(&eph_priv, &bob_pub, &bio_post_id, &v_me).unwrap(); + assert_eq!(wrapper.len(), 48, "wrapper must be 48 bytes (32 sealed + 16 tag)"); + + // Bob opens it + let opened = open_vouch_grant(&bob_priv, &eph_pub, &bio_post_id, &wrapper); + assert_eq!(opened, Some(v_me)); + + // Alice (not the recipient) cannot open it + let alice_attempt = open_vouch_grant(&alice_priv, &eph_pub, &bio_post_id, &wrapper); + assert_eq!(alice_attempt, None, "non-recipient must not decrypt"); + } + + #[test] + fn vouch_grant_wrong_bio_post_id_fails() { + let (_, bob_pub) = make_persona_x25519(22); + let (bob_priv, _) = make_persona_x25519(22); + let real_bio_id: PostId = [1u8; 32]; + let wrong_bio_id: PostId = [2u8; 32]; + let v_me: [u8; 32] = [99u8; 32]; + + let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral(); + let wrapper = seal_vouch_grant(&eph_priv, &bob_pub, &real_bio_id, &v_me).unwrap(); + + // Wrong bio_post_id derives a different key+nonce → AEAD fails. + let attempt = open_vouch_grant(&bob_priv, &eph_pub, &wrong_bio_id, &wrapper); + assert_eq!(attempt, None); + + // Right bio_post_id succeeds. + let ok = open_vouch_grant(&bob_priv, &eph_pub, &real_bio_id, &wrapper); + assert_eq!(ok, Some(v_me)); + } + + #[test] + fn vouch_grant_random_bytes_fail() { + let (bob_priv, _) = make_persona_x25519(22); + let bio_post_id: PostId = [5u8; 32]; + let (_, eph_pub) = generate_vouch_batch_ephemeral(); + + let mut junk = [0u8; 48]; + rand::rng().fill_bytes(&mut junk); + let attempt = open_vouch_grant(&bob_priv, &eph_pub, &bio_post_id, &junk); + assert_eq!(attempt, None, "random bytes must AEAD-fail (dummy wrapper indistinguishable)"); + } } diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index e5507ab..a8f8b6b 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -415,6 +415,60 @@ impl Storage { secret_seed BLOB NOT NULL, display_name TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL + ); + -- FoF Layer 1: per-persona V_me history. Rows are append-only + -- on rotation (Layer 4); old epochs retained for unwrapping + -- historical wrap_slots. is_current marks the active outgoing + -- key. key_material is the 32B symmetric V_me bytes. + CREATE TABLE IF NOT EXISTS vouch_keys_own ( + persona_id BLOB NOT NULL, + epoch INTEGER NOT NULL, + key_material BLOB NOT NULL, + created_at_ms INTEGER NOT NULL, + is_current INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (persona_id, epoch) + ); + CREATE INDEX IF NOT EXISTS idx_vouch_keys_own_current + ON vouch_keys_own(persona_id, is_current); + -- FoF Layer 1: per-persona keyring of received vouch keys from + -- others. holder_persona_id is whose keyring this row belongs + -- to; owner_id is the persona who issued the V_x; epoch is the + -- issuer's V_x epoch. Multi-epoch retention per Layer 4. + CREATE TABLE IF NOT EXISTS vouch_keys_received ( + holder_persona_id BLOB NOT NULL, + owner_id BLOB NOT NULL, + epoch INTEGER NOT NULL, + key_material BLOB NOT NULL, + received_at_ms INTEGER NOT NULL, + source_bio_post_id BLOB, + PRIMARY KEY (holder_persona_id, owner_id, epoch) + ); + CREATE INDEX IF NOT EXISTS idx_vouch_keys_received_owner + ON vouch_keys_received(holder_persona_id, owner_id); + -- FoF Layer 1: short-circuit cache for re-scanning bio posts + -- that haven't changed. bio_epoch is the issuer's bio-post + -- revision counter. result=1 means a wrapper unlocked; 0 means + -- nothing for this persona. + CREATE TABLE IF NOT EXISTS vouch_bio_scan_cache ( + scanner_persona_id BLOB NOT NULL, + bio_author_id BLOB NOT NULL, + bio_epoch INTEGER NOT NULL, + result INTEGER NOT NULL, + unlocked_v_x_epoch INTEGER, + scanned_at_ms INTEGER NOT NULL, + PRIMARY KEY (scanner_persona_id, bio_author_id, bio_epoch) + ); + -- FoF Layer 1: author-local record of who this persona has + -- vouched for. Never on the wire. Drives bio-post wrapper + -- batch assembly. current=1 means the target is in the latest + -- batch; current=0 means they were removed (revoked). + CREATE TABLE IF NOT EXISTS own_vouch_targets ( + voucher_persona_id BLOB NOT NULL, + target_persona_id BLOB NOT NULL, + target_x25519_pub BLOB NOT NULL, + granted_at_ms INTEGER NOT NULL, + current INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (voucher_persona_id, target_persona_id) );", )?; Ok(()) @@ -4586,6 +4640,311 @@ impl Storage { Ok(n as u64) } + // --- FoF Layer 1: Vouch keys (own + received) --- + + /// Insert a new V_me epoch for a persona. Marks it current; older + /// epochs are flipped to non-current. Append-only — old epochs are + /// never deleted by rotation (see Layer 4). + pub fn insert_own_vouch_key( + &self, + persona_id: &NodeId, + epoch: u32, + key_material: &[u8; 32], + created_at_ms: u64, + ) -> anyhow::Result<()> { + let tx = self.conn.unchecked_transaction()?; + tx.execute( + "UPDATE vouch_keys_own SET is_current = 0 WHERE persona_id = ?1", + params![persona_id.as_slice()], + )?; + tx.execute( + "INSERT OR REPLACE INTO vouch_keys_own + (persona_id, epoch, key_material, created_at_ms, is_current) + VALUES (?1, ?2, ?3, ?4, 1)", + params![ + persona_id.as_slice(), + epoch as i64, + key_material.as_slice(), + created_at_ms as i64, + ], + )?; + tx.commit()?; + Ok(()) + } + + /// Return the persona's current V_me as `(epoch, key)`, or None if not set. + pub fn current_own_vouch_key( + &self, + persona_id: &NodeId, + ) -> anyhow::Result> { + let result = self.conn.query_row( + "SELECT epoch, key_material FROM vouch_keys_own + WHERE persona_id = ?1 AND is_current = 1", + params![persona_id.as_slice()], + |row| { + let epoch: i64 = row.get(0)?; + let key: Vec = row.get(1)?; + Ok((epoch, key)) + }, + ); + match result { + Ok((epoch, key_bytes)) => { + let key: [u8; 32] = key_bytes.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid vouch key length"))?; + Ok(Some((epoch as u32, key))) + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// Return all V_me epochs for a persona (current + retained past). + /// Sorted newest-first. Used at unwrap time (try newest first) and + /// when a sender needs to publish multi-epoch grants. + pub fn list_own_vouch_keys( + &self, + persona_id: &NodeId, + ) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT epoch, key_material FROM vouch_keys_own + WHERE persona_id = ?1 ORDER BY epoch DESC", + )?; + let rows = stmt.query_map(params![persona_id.as_slice()], |row| { + let epoch: i64 = row.get(0)?; + let key: Vec = row.get(1)?; + Ok((epoch, key)) + })?; + let mut out = Vec::new(); + for r in rows { + let (epoch, key_bytes) = r?; + let key: [u8; 32] = key_bytes.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid vouch key length"))?; + out.push((epoch as u32, key)); + } + Ok(out) + } + + /// Insert a received vouch key into a persona's keyring. Idempotent + /// on `(holder, owner, epoch)`. + pub fn insert_received_vouch_key( + &self, + holder_persona_id: &NodeId, + owner_id: &NodeId, + epoch: u32, + key_material: &[u8; 32], + received_at_ms: u64, + source_bio_post_id: Option<&[u8; 32]>, + ) -> anyhow::Result<()> { + self.conn.execute( + "INSERT OR IGNORE INTO vouch_keys_received + (holder_persona_id, owner_id, epoch, key_material, received_at_ms, source_bio_post_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + holder_persona_id.as_slice(), + owner_id.as_slice(), + epoch as i64, + key_material.as_slice(), + received_at_ms as i64, + source_bio_post_id.map(|b| b.as_slice()), + ], + )?; + Ok(()) + } + + /// Return the full received-vouch keyring for a persona. Each row is + /// `(owner_id, epoch, key_material)`. Trial-unwrap iterates the result. + pub fn list_received_vouch_keys( + &self, + holder_persona_id: &NodeId, + ) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT owner_id, epoch, key_material FROM vouch_keys_received + WHERE holder_persona_id = ?1 + ORDER BY owner_id, epoch DESC", + )?; + let rows = stmt.query_map(params![holder_persona_id.as_slice()], |row| { + let owner: Vec = row.get(0)?; + let epoch: i64 = row.get(1)?; + let key: Vec = row.get(2)?; + Ok((owner, epoch, key)) + })?; + let mut out = Vec::new(); + for r in rows { + let (owner_bytes, epoch, key_bytes) = r?; + let owner: NodeId = owner_bytes.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid owner_id in vouch_keys_received"))?; + let key: [u8; 32] = key_bytes.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid key in vouch_keys_received"))?; + out.push((owner, epoch as u32, key)); + } + Ok(out) + } + + /// List distinct owners that have vouched for a persona (for UI + /// "Who has vouched for me"). Latest epoch per owner. + pub fn list_vouchers_for( + &self, + holder_persona_id: &NodeId, + ) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT owner_id, MAX(epoch), MAX(received_at_ms) + FROM vouch_keys_received + WHERE holder_persona_id = ?1 + GROUP BY owner_id", + )?; + let rows = stmt.query_map(params![holder_persona_id.as_slice()], |row| { + let owner: Vec = row.get(0)?; + let epoch: i64 = row.get(1)?; + let at: i64 = row.get(2)?; + Ok((owner, epoch, at)) + })?; + let mut out = Vec::new(); + for r in rows { + let (owner_bytes, epoch, at) = r?; + let owner: NodeId = owner_bytes.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid owner_id"))?; + out.push((owner, epoch as u32, at as u64)); + } + Ok(out) + } + + /// Lookup a scan-cache entry. Returns Some(unlocked_epoch) if the + /// cached result was a hit (Some(None) means the row exists as a miss). + /// Returns None if no cache row exists (scan needed). + pub fn lookup_bio_scan_cache( + &self, + scanner_persona_id: &NodeId, + bio_author_id: &NodeId, + bio_epoch: u32, + ) -> anyhow::Result>> { + let result = self.conn.query_row( + "SELECT result, unlocked_v_x_epoch FROM vouch_bio_scan_cache + WHERE scanner_persona_id = ?1 AND bio_author_id = ?2 AND bio_epoch = ?3", + params![ + scanner_persona_id.as_slice(), + bio_author_id.as_slice(), + bio_epoch as i64, + ], + |row| { + let result: i64 = row.get(0)?; + let unlocked: Option = row.get(1)?; + Ok((result, unlocked)) + }, + ); + match result { + Ok((res, unlocked)) => { + if res == 1 { + Ok(Some(unlocked.map(|e| e as u32))) + } else { + Ok(Some(None)) + } + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// Record a scan-cache hit (result=1) or miss (result=0). + pub fn record_bio_scan_result( + &self, + scanner_persona_id: &NodeId, + bio_author_id: &NodeId, + bio_epoch: u32, + unlocked_v_x_epoch: Option, + scanned_at_ms: u64, + ) -> anyhow::Result<()> { + let result_flag: i64 = if unlocked_v_x_epoch.is_some() { 1 } else { 0 }; + self.conn.execute( + "INSERT OR REPLACE INTO vouch_bio_scan_cache + (scanner_persona_id, bio_author_id, bio_epoch, result, unlocked_v_x_epoch, scanned_at_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + scanner_persona_id.as_slice(), + bio_author_id.as_slice(), + bio_epoch as i64, + result_flag, + unlocked_v_x_epoch.map(|e| e as i64), + scanned_at_ms as i64, + ], + )?; + Ok(()) + } + + /// Upsert an outbound vouch target for a persona. `current=1` means + /// it'll be wrapped into the next bio-post batch. + pub fn upsert_vouch_target( + &self, + voucher_persona_id: &NodeId, + target_persona_id: &NodeId, + target_x25519_pub: &[u8; 32], + granted_at_ms: u64, + current: bool, + ) -> anyhow::Result<()> { + self.conn.execute( + "INSERT INTO own_vouch_targets + (voucher_persona_id, target_persona_id, target_x25519_pub, granted_at_ms, current) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(voucher_persona_id, target_persona_id) DO UPDATE SET + target_x25519_pub = excluded.target_x25519_pub, + current = excluded.current", + params![ + voucher_persona_id.as_slice(), + target_persona_id.as_slice(), + target_x25519_pub.as_slice(), + granted_at_ms as i64, + current as i64, + ], + )?; + Ok(()) + } + + /// List current outbound vouch targets for a persona. + pub fn list_current_vouch_targets( + &self, + voucher_persona_id: &NodeId, + ) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT target_persona_id, target_x25519_pub, granted_at_ms + FROM own_vouch_targets + WHERE voucher_persona_id = ?1 AND current = 1 + ORDER BY granted_at_ms ASC", + )?; + let rows = stmt.query_map(params![voucher_persona_id.as_slice()], |row| { + let tid: Vec = row.get(0)?; + let xpub: Vec = row.get(1)?; + let at: i64 = row.get(2)?; + Ok((tid, xpub, at)) + })?; + let mut out = Vec::new(); + for r in rows { + let (tid_bytes, xpub_bytes, at) = r?; + let tid: NodeId = tid_bytes.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid target_persona_id"))?; + let xpub: [u8; 32] = xpub_bytes.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid target_x25519_pub"))?; + out.push((tid, xpub, at as u64)); + } + Ok(out) + } + + /// Mark a vouch target as no longer current (soft revoke; row retained + /// so the audit trail / cascade-pickup is preserved). + pub fn revoke_vouch_target( + &self, + voucher_persona_id: &NodeId, + target_persona_id: &NodeId, + ) -> anyhow::Result<()> { + self.conn.execute( + "UPDATE own_vouch_targets SET current = 0 + WHERE voucher_persona_id = ?1 AND target_persona_id = ?2", + params![ + voucher_persona_id.as_slice(), + target_persona_id.as_slice(), + ], + )?; + Ok(()) + } + // --- File holders (flat, per-file, LRU-capped at 5) --- // // A single table for PostId-keyed engagement propagation and CID-keyed From bc008c50495752021d9b3214639b519de25a195d Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Wed, 13 May 2026 01:35:19 -0400 Subject: [PATCH 11/34] feat(fof-layer1): wire types + V_me auto-gen on persona creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds VouchGrantBatch type to types.rs and extends ProfilePostContent with optional vouch_grants + bio_epoch fields (back-compat via #[serde(default)]). VouchGrantBatch carries one shared batch_eph_pub + a Vec> of 48-byte wrappers; receivers identify their wrapper by AEAD success, not position. Wires V_me auto-generation into both persona-creation paths: - Node::open first-run auto-persona block now seeds vouch_keys_own epoch=1 alongside upsert_posting_identity. - create_posting_identity calls ensure_initial_v_me after the new persona is stored. Helpers live as private free functions at module scope so both the sync (Node::open holds a Storage guard) and async (create_posting holds a StoragePool) sites can share them. Idempotent — re-running on a persona that already has a current key is a no-op. Two existing ProfilePostContent construction sites updated to set the new fields to defaults (None / 0); they'll get real values when the publish path is wired up in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/node.rs | 39 ++++++++++++++++++++++++++++++++++++++ crates/core/src/profile.rs | 2 ++ crates/core/src/types.rs | 38 +++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 3a1614d..cc0db25 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -63,6 +63,35 @@ pub struct Node { budget_last_reset_ms: Arc, } +/// FoF Layer 1: generate a fresh 32B `V_me` and insert it as the +/// persona's current epoch (epoch=1). Idempotent if the persona already +/// has a current key — does nothing in that case. +fn generate_and_store_initial_v_me( + storage: &crate::storage::Storage, + persona_id: &NodeId, + now_ms: u64, +) -> anyhow::Result<()> { + use rand::RngCore; + if storage.current_own_vouch_key(persona_id)?.is_some() { + return Ok(()); + } + let mut key = [0u8; 32]; + rand::rng().fill_bytes(&mut key); + storage.insert_own_vouch_key(persona_id, 1, &key, now_ms)?; + Ok(()) +} + +/// Async wrapper used by `Node::create_posting_identity`. Acquires the +/// storage handle and delegates to the sync helper. +async fn ensure_initial_v_me( + storage: &StoragePool, + persona_id: &NodeId, + now_ms: u64, +) -> anyhow::Result<()> { + let s = storage.get().await; + generate_and_store_initial_v_me(&s, persona_id, now_ms) +} + impl Node { /// Create or open a node in the given data directory (Desktop profile) pub async fn open(data_dir: impl AsRef) -> anyhow::Result { @@ -133,6 +162,8 @@ impl Node { created_at: now, })?; s.set_default_posting_id(&nid)?; + // FoF Layer 1: auto-gen V_me epoch 1 for this fresh persona. + generate_and_store_initial_v_me(&s, &nid, now)?; // Mark this as the disposable auto-gen persona from the // fresh-install flow. If the user subsequently imports, we // prune this id iff it's still pristine (no name, no posts, @@ -684,6 +715,12 @@ impl Node { } } + // FoF Layer 1: every persona owns its own V_me (symmetric 32B key). + // Auto-generate epoch 1 at creation. Stored in vouch_keys_own with + // is_current=1. Per Layer 4, rotations append new epochs; this row + // is never deleted automatically. + ensure_initial_v_me(&self.storage, &node_id, now).await?; + Ok(identity) } @@ -772,6 +809,8 @@ impl Node { avatar_cid: None, timestamp_ms: pi.created_at, signature, + vouch_grants: None, + bio_epoch: 0, }; let post = Post { author: pi.node_id, diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs index 7d1b66b..3d2a297 100644 --- a/crates/core/src/profile.rs +++ b/crates/core/src/profile.rs @@ -88,6 +88,8 @@ pub fn build_profile_post( avatar_cid, timestamp_ms, signature, + vouch_grants: None, + bio_epoch: 0, }; Post { author: *author, diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 9102c6b..d5c591d 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -274,6 +274,44 @@ pub struct ProfilePostContent { /// 64-byte ed25519 signature. See `crypto::sign_profile` for the byte /// layout signed by the posting identity. pub signature: Vec, + /// FoF Layer 1: HPKE-style anonymous wrapper batch carrying the + /// voucher's V_me to each non-revoked recipient. `None` on bio posts + /// that don't issue vouches (e.g., the inaugural empty-state profile + /// post for a brand-new persona). Bound to this post via the + /// `bio_post_id` baked into each wrapper's HKDF info. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vouch_grants: Option, + /// FoF Layer 1: monotonic bio-post revision counter for this persona. + /// Used by receivers to short-circuit the scan via `vouch_bio_scan_cache`. + /// Increments on every bio publish; 0 for pre-Layer-1 posts (back-compat). + #[serde(default)] + pub bio_epoch: u32, +} + +/// FoF Layer 1: a batch of per-recipient HPKE-style wrappers carrying +/// the voucher's `V_me` to each currently-vouched persona. Sits inside +/// a `VisibilityIntent::Profile` post. The post's author is the voucher. +/// +/// Wire shape: one shared 32B ephemeral X25519 pubkey + N 48-byte +/// wrappers (32B sealed `V_me` + 16B AEAD tag). Wrappers carry no +/// recipient identifier; recipient anonymity is preserved by HPKE key +/// privacy. The HKDF info string includes the bio post id but NEVER +/// a recipient identifier — including one would break key privacy. +/// +/// Dummy wrappers are random 48-byte sequences indistinguishable from +/// real ones; they AEAD-fail on every persona. Bucketed padding is +/// applied by the publisher (Layer 3 specifies the bucket boundaries). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VouchGrantBatch { + /// Shared ephemeral X25519 pubkey for this batch. + pub batch_eph_pub: [u8; 32], + /// Epoch of the voucher's `V_me` being distributed in this batch. + /// All wrappers in the batch carry the same epoch's key. + pub v_x_epoch: u32, + /// Real + dummy wrappers, shuffled. Each entry is exactly 48 bytes + /// (32B sealed `V_me` + 16B AEAD tag); receivers identify "their" + /// wrapper by successful AEAD decryption, not by position. + pub wrappers: Vec>, } /// Content payload of a `VisibilityIntent::Announcement` post. From 3ee5c30ad2500f8bb4882a8b474f7af7d24080d2 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Wed, 13 May 2026 01:39:09 -0400 Subject: [PATCH 12/34] feat(fof-layer1): publish path embeds VouchGrantBatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the publish side of FoF Layer 1 vouch distribution: - VouchGrantBatch gains bio_pub_nonce (32B random per batch). Replaces the spec's circular "bio_post_id in HKDF info" — BLAKE3(post) depends on vouch_grants, so we need a content-independent binder. Recipient-free per HPKE key-privacy; serves the same anti-replay purpose as bio_post_id would have. - profile::build_vouch_grant_batch reads current_own_vouch_key + list_current_vouch_targets, generates eph keypair + bio_pub_nonce, seals V_me for each target, bucket-pads with random 48B dummies, and shuffles. Returns None when there are no targets. - next_vouch_batch_bucket implements the FoF Layer 3 padding rule: minimum bucket 8, power-of-2 up to 256, then linear +128 steps. Bucket-padding-tests verifies all boundaries. - Storage gains next_bio_epoch_for(persona_id): monotonic counter per persona, used by receivers' scan cache. Stored in settings. - build_profile_post signature extended to take Option + bio_epoch: u32. Both publish_profile_post_as (initial post) and set_profile (subsequent edits) build the batch and bump the epoch on every publish. - Test sites updated to pass None/0 for the new args. Receive-side scan (next commit) reads VouchGrantBatch + bio_pub_nonce to trial-decrypt wrappers and populate vouch_keys_received. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/node.rs | 20 ++++++ crates/core/src/profile.rs | 123 +++++++++++++++++++++++++++++++++++-- crates/core/src/storage.rs | 13 ++++ crates/core/src/types.rs | 6 ++ 4 files changed, 156 insertions(+), 6 deletions(-) diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index cc0db25..12f646b 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -735,12 +735,22 @@ impl Node { bio: &str, avatar_cid: Option<[u8; 32]>, ) -> anyhow::Result<()> { + // FoF Layer 1: build the vouch-grant batch (if this persona has + // any current vouch targets) + bump the bio_epoch. + let (vouch_grants, bio_epoch) = { + let storage = self.storage.get().await; + let batch = crate::profile::build_vouch_grant_batch(&*storage, posting_id)?; + let epoch = storage.next_bio_epoch_for(posting_id)?; + (batch, epoch) + }; let profile_post = crate::profile::build_profile_post( posting_id, posting_secret, display_name, bio, avatar_cid, + vouch_grants, + bio_epoch, ); let profile_post_id = crate::content::compute_post_id(&profile_post); let timestamp_ms = profile_post.timestamp_ms; @@ -1542,12 +1552,22 @@ impl Node { storage.get_profile(&posting_id).ok().flatten().and_then(|p| p.avatar_cid) }; + // FoF Layer 1: build the vouch-grant batch (if this persona has + // any current vouch targets) + bump bio_epoch. + let (vouch_grants, bio_epoch) = { + let storage = self.storage.get().await; + let batch = crate::profile::build_vouch_grant_batch(&*storage, &posting_id)?; + let epoch = storage.next_bio_epoch_for(&posting_id)?; + (batch, epoch) + }; let profile_post = crate::profile::build_profile_post( &posting_id, &posting_secret, &display_name, &bio, avatar_cid, + vouch_grants, + bio_epoch, ); let profile_post_id = crate::content::compute_post_id(&profile_post); let timestamp_ms = profile_post.timestamp_ms; diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs index 3d2a297..29bcd12 100644 --- a/crates/core/src/profile.rs +++ b/crates/core/src/profile.rs @@ -70,12 +70,19 @@ pub fn apply_profile_post_if_applicable( /// Build a Profile post signed by the posting identity. Caller is /// responsible for storing and propagating it. +/// +/// Optional `vouch_grants` carries the FoF Layer 1 anonymous-wrapper +/// batch distributing the persona's current `V_me` to vouched personas. +/// `bio_epoch` is a monotonic per-persona counter that lets receivers +/// short-circuit re-scanning unchanged bios. pub fn build_profile_post( author: &NodeId, author_secret: &[u8; 32], display_name: &str, bio: &str, avatar_cid: Option<[u8; 32]>, + vouch_grants: Option, + bio_epoch: u32, ) -> Post { let timestamp_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -88,8 +95,8 @@ pub fn build_profile_post( avatar_cid, timestamp_ms, signature, - vouch_grants: None, - bio_epoch: 0, + vouch_grants, + bio_epoch, }; Post { author: *author, @@ -105,6 +112,110 @@ pub fn profile_post_visibility() -> PostVisibility { PostVisibility::Public } +/// FoF Layer 1: build the `VouchGrantBatch` for a persona's next bio +/// publish, drawing the current `V_me` from `vouch_keys_own` and the +/// recipient list from `own_vouch_targets` (current=1 only). +/// +/// Returns `None` when the persona has no current vouch targets — the +/// bio post can be published without a vouch-grant batch in that case. +/// +/// Padding: per FoF Layer 3, the wrapper count is bucketed: power-of-2 +/// up to 256 (minimum bucket 8), then linear +128 steps. Real wrappers +/// + random-bytes dummies are shuffled together. Dummies are 48B random +/// sequences — AEAD-indistinguishable from real wrappers to outsiders. +pub fn build_vouch_grant_batch( + storage: &crate::storage::Storage, + persona_id: &NodeId, +) -> anyhow::Result> { + use rand::RngCore; + use rand::seq::SliceRandom; + + let Some((v_x_epoch, v_me)) = storage.current_own_vouch_key(persona_id)? else { + return Ok(None); + }; + let targets = storage.list_current_vouch_targets(persona_id)?; + if targets.is_empty() { + return Ok(None); + } + + let mut bio_pub_nonce = [0u8; 32]; + rand::rng().fill_bytes(&mut bio_pub_nonce); + let (eph_priv, batch_eph_pub) = crypto::generate_vouch_batch_ephemeral(); + + // Real wrappers. + let mut wrappers: Vec> = Vec::with_capacity(targets.len()); + for (_tid, x25519_pub, _at) in &targets { + let w = crypto::seal_vouch_grant(&eph_priv, x25519_pub, &bio_pub_nonce, &v_me)?; + wrappers.push(w); + } + + // Dummy padding to the next bucket. Min 8; power-of-2 to 256; then + // +128 linear steps. See FoF Layer 3 lead decisions. + let target_count = next_vouch_batch_bucket(wrappers.len()); + let mut rng = rand::rng(); + while wrappers.len() < target_count { + let mut dummy = vec![0u8; 48]; + rng.fill_bytes(&mut dummy); + wrappers.push(dummy); + } + + // Shuffle so real and dummy positions are indistinguishable. + wrappers.shuffle(&mut rng); + + Ok(Some(crate::types::VouchGrantBatch { + batch_eph_pub, + v_x_epoch, + bio_pub_nonce, + wrappers, + })) +} + +/// Bucket-pad a real wrapper count to the next allowed bucket. +/// Minimum bucket is 8 (so a single-target post still publishes 8 +/// wrappers, hiding "this persona has no vouchees" entirely). +/// Power-of-2 up to 256; linear +128 steps above 256. +fn next_vouch_batch_bucket(real: usize) -> usize { + if real <= 8 { return 8; } + if real <= 256 { + // smallest power of 2 >= real + let mut b = 8usize; + while b < real { b *= 2; } + return b; + } + // 384, 512, 640, ... + let above = real - 256; + let steps = (above + 127) / 128; + 256 + steps * 128 +} + +#[cfg(test)] +mod batch_padding_tests { + use super::next_vouch_batch_bucket; + + #[test] + fn buckets_match_spec() { + // Minimum floor. + assert_eq!(next_vouch_batch_bucket(0), 8); + assert_eq!(next_vouch_batch_bucket(1), 8); + assert_eq!(next_vouch_batch_bucket(7), 8); + assert_eq!(next_vouch_batch_bucket(8), 8); + + // Power-of-2 progression. + assert_eq!(next_vouch_batch_bucket(9), 16); + assert_eq!(next_vouch_batch_bucket(16), 16); + assert_eq!(next_vouch_batch_bucket(17), 32); + assert_eq!(next_vouch_batch_bucket(129), 256); + assert_eq!(next_vouch_batch_bucket(256), 256); + + // Linear +128 above 256. + assert_eq!(next_vouch_batch_bucket(257), 384); + assert_eq!(next_vouch_batch_bucket(384), 384); + assert_eq!(next_vouch_batch_bucket(385), 512); + assert_eq!(next_vouch_batch_bucket(500), 512); + assert_eq!(next_vouch_batch_bucket(513), 640); + } +} + /// Compute the `PostId` for a freshly-built profile post. pub fn profile_post_id(post: &Post) -> PostId { crate::content::compute_post_id(post) @@ -132,7 +243,7 @@ mod tests { let s = temp_storage(); let (sec, pub_id) = make_keypair(11); - let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None); + let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None, None, 0); apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap(); let stored = s.get_profile(&pub_id).unwrap().expect("profile stored"); @@ -147,7 +258,7 @@ mod tests { let (sec_b, _pub_b) = make_keypair(2); // Build a post claiming `pub_a` but signing with `sec_b`. - let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None); + let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None, None, 0); let res = apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)); assert!(res.is_err()); assert!(s.get_profile(&pub_a).unwrap().is_none()); @@ -159,7 +270,7 @@ mod tests { let (sec, pub_id) = make_keypair(3); // Seed with a newer profile. - let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None); + let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None, None, 0); // Hack the timestamp to make it clearly newer. let mut content: ProfilePostContent = serde_json::from_str(&newer.content).unwrap(); content.timestamp_ms = 10_000; @@ -169,7 +280,7 @@ mod tests { apply_profile_post_if_applicable(&s, &newer, Some(&VisibilityIntent::Profile)).unwrap(); // Apply an older profile — should be ignored. - let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None); + let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None, None, 0); let mut content_o: ProfilePostContent = serde_json::from_str(&older.content).unwrap(); content_o.timestamp_ms = 5_000; content_o.signature = crypto::sign_profile(&sec, &content_o.display_name, &content_o.bio, &content_o.avatar_cid, content_o.timestamp_ms); diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index a8f8b6b..7958a40 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -4945,6 +4945,19 @@ impl Storage { Ok(()) } + /// Increment and return the next bio-publish epoch for a persona. + /// Counter is monotonic; used by receivers' scan cache to short-circuit + /// re-scanning unchanged bios. Stored in `settings` keyed by persona. + pub fn next_bio_epoch_for(&self, persona_id: &NodeId) -> anyhow::Result { + let key = format!("bio_epoch_{}", hex::encode(persona_id)); + let current: u32 = self.get_setting(&key)? + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + let next = current + 1; + self.set_setting(&key, &next.to_string())?; + Ok(next) + } + // --- File holders (flat, per-file, LRU-capped at 5) --- // // A single table for PostId-keyed engagement propagation and CID-keyed diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index d5c591d..7e3a54a 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -308,6 +308,12 @@ pub struct VouchGrantBatch { /// Epoch of the voucher's `V_me` being distributed in this batch. /// All wrappers in the batch carry the same epoch's key. pub v_x_epoch: u32, + /// Random 32B nonce binding this batch's wrappers to this specific + /// bio publish. Plays the role the spec calls "bio_post_id" in the + /// HKDF info string — distinct from the actual `PostId` (which is + /// `BLAKE3(post)` and would be circular here). Recipient-free per + /// HPKE key-privacy requirements. + pub bio_pub_nonce: [u8; 32], /// Real + dummy wrappers, shuffled. Each entry is exactly 48 bytes /// (32B sealed `V_me` + 16B AEAD tag); receivers identify "their" /// wrapper by successful AEAD decryption, not by position. From d1afcec26ab13f747476a6eee3e6cb479dbd3073 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Wed, 13 May 2026 01:44:54 -0400 Subject: [PATCH 13/34] feat(fof-layer1): receive-path scan populates vouch_keys_received MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the receive side of Layer 1 vouch distribution: - profile::scan_vouch_grants_for_all_personas reads the VouchGrantBatch from an incoming profile post, trial-decrypts each wrapper against every persona's X25519 private scalar, and inserts successful unlocks into vouch_keys_received. Idempotent via the bio_scan_cache. - apply_profile_post_if_applicable now calls the scan before the timestamp-based last-writer-wins short-circuit on display_name/bio. A profile post that arrives "older" than what we've stored can still carry vouch grants we haven't seen — bio_epoch is the actual freshness signal for the wrapper batch. - Follow-gated per the spec: skipped if the bio author isn't in follows. Self-authored posts skipped (we already have our own V_me). - storage::is_follow helper added (cheap COUNT membership check). Two new integration tests cover the wire: - vouch_grant_end_to_end_via_bio_post: Bob's signed profile post carries a real wrapper for Alice + 7 dummies; Alice's keyring picks up V_bob and the scan cache records the hit. - vouch_grant_skipped_for_non_followed_author: same post, but Alice doesn't follow Bob → no scan, no keyring entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/profile.rs | 246 +++++++++++++++++++++++++++++++++++++ crates/core/src/storage.rs | 8 ++ 2 files changed, 254 insertions(+) diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs index 29bcd12..0ae0183 100644 --- a/crates/core/src/profile.rs +++ b/crates/core/src/profile.rs @@ -46,6 +46,14 @@ pub fn apply_profile_post_if_applicable( } let content = verify_profile_post(post)?; + // FoF Layer 1: scan any embedded vouch-grant batch BEFORE the + // timestamp short-circuit below. A profile post that arrives older + // than what we've stored (last-writer-wins on display_name/bio) can + // still carry vouch grants we haven't seen — bio_epoch is the actual + // freshness signal for the wrapper batch, distinct from the + // post's display timestamp. + scan_vouch_grants_for_all_personas(s, &post.author, &content)?; + // Only apply if newer than the stored row (last-writer-wins by timestamp). if let Some(existing) = s.get_profile(&post.author)? { if existing.updated_at >= content.timestamp_ms { @@ -68,6 +76,90 @@ pub fn apply_profile_post_if_applicable( Ok(()) } +/// FoF Layer 1: trial-decrypt every wrapper in the post's +/// `vouch_grants` batch against every persona on this device, recording +/// successful unlocks into `vouch_keys_received`. Idempotent via the +/// `(scanner_persona, bio_author, bio_epoch)` scan cache. +/// +/// Follow-gated per the spec: skipped if the bio author is not in +/// `follows`. The manual "check this bio for a vouch for me" gesture +/// (post-Layer-1) will call a separate force-scan entrypoint. +/// +/// Self-authored posts are skipped (we already have our own V_me). +pub fn scan_vouch_grants_for_all_personas( + s: &Storage, + author: &NodeId, + content: &ProfilePostContent, +) -> anyhow::Result<()> { + let Some(batch) = &content.vouch_grants else { return Ok(()); }; + + // Skip if we authored this post. + if s.get_posting_identity(author)?.is_some() { + return Ok(()); + } + + // Follow-gate: only auto-scan bios of accounts we follow. + if !s.is_follow(author)? { + return Ok(()); + } + + let personas = s.list_posting_identities()?; + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + for persona in &personas { + // Per-persona scan cache: skip if we already trialed this + // (scanner_persona, bio_author, bio_epoch) tuple. + if s.lookup_bio_scan_cache(&persona.node_id, author, content.bio_epoch)?.is_some() { + continue; + } + + // Derive persona's X25519 private scalar to trial-decrypt + // wrappers under the batch ephemeral pubkey. + let persona_x25519_priv = crypto::ed25519_seed_to_x25519_private(&persona.secret_seed); + + let mut unlocked: Option = None; + for wrapper_bytes in &batch.wrappers { + if let Some(v_me) = crypto::open_vouch_grant( + &persona_x25519_priv, + &batch.batch_eph_pub, + &batch.bio_pub_nonce, + wrapper_bytes, + ) { + // This wrapper was addressed to this persona. + // Use the post's author as the source post-id field + // (informational only — the cryptographic binder is + // bio_pub_nonce inside the batch). + s.insert_received_vouch_key( + &persona.node_id, + author, + batch.v_x_epoch, + &v_me, + now_ms, + None, + )?; + unlocked = Some(batch.v_x_epoch); + // Continue iterating — a future multi-epoch batch may + // address this persona twice (different epoch wrappers + // for the same persona). Today only one epoch ships per + // batch, but the loop is correct either way. + } + } + + s.record_bio_scan_result( + &persona.node_id, + author, + content.bio_epoch, + unlocked, + now_ms, + )?; + } + + Ok(()) +} + /// Build a Profile post signed by the posting identity. Caller is /// responsible for storing and propagating it. /// @@ -291,4 +383,158 @@ mod tests { let stored = s.get_profile(&pub_id).unwrap().unwrap(); assert_eq!(stored.display_name, "NewName"); } + + /// End-to-end Layer 1: voucher's bio post carries a VouchGrantBatch + /// addressed to the receiver's persona; receiver auto-scans on + /// apply_profile_post_if_applicable and populates vouch_keys_received. + #[test] + fn vouch_grant_end_to_end_via_bio_post() { + use crate::types::{PostingIdentity, VouchGrantBatch}; + use rand::RngCore; + + let s = temp_storage(); + + // Two personas on this device (the "receiver" device). Alice is + // the only one we're acting as; "bob" is the voucher whose bio + // post arrives. + let (alice_seed, alice_id) = make_keypair(50); + let (bob_seed, bob_id) = make_keypair(60); + + s.upsert_posting_identity(&PostingIdentity { + node_id: alice_id, + secret_seed: alice_seed, + display_name: "Alice".into(), + created_at: 1000, + }).unwrap(); + + // Receiver-device follows the voucher; otherwise auto-scan is + // follow-gated off and would skip. + s.add_follow(&bob_id).unwrap(); + + // Build bob's V_me + the wrapper batch addressed to alice's + // persona X25519 pubkey. + let mut v_me_bob = [0u8; 32]; + rand::rng().fill_bytes(&mut v_me_bob); + + let alice_x25519_pub = crypto::ed25519_pubkey_to_x25519_public(&alice_id).unwrap(); + let mut bio_pub_nonce = [0u8; 32]; + rand::rng().fill_bytes(&mut bio_pub_nonce); + let (eph_priv, batch_eph_pub) = crypto::generate_vouch_batch_ephemeral(); + + let real_wrapper = crypto::seal_vouch_grant( + &eph_priv, + &alice_x25519_pub, + &bio_pub_nonce, + &v_me_bob, + ).unwrap(); + + // Mix in some dummy wrappers to confirm the scan finds the real + // one even when most positions fail AEAD. + let mut wrappers = vec![real_wrapper]; + for _ in 0..7 { + let mut dummy = vec![0u8; 48]; + rand::rng().fill_bytes(&mut dummy); + wrappers.push(dummy); + } + + let batch = VouchGrantBatch { + batch_eph_pub, + v_x_epoch: 1, + bio_pub_nonce, + wrappers, + }; + + // Construct bob's bio post with the batch. + let timestamp_ms = 2000; + let display_name = "Bob"; + let bio = "hi"; + let signature = crypto::sign_profile(&bob_seed, display_name, bio, &None, timestamp_ms); + let content = ProfilePostContent { + display_name: display_name.to_string(), + bio: bio.to_string(), + avatar_cid: None, + timestamp_ms, + signature, + vouch_grants: Some(batch), + bio_epoch: 1, + }; + let post = Post { + author: bob_id, + content: serde_json::to_string(&content).unwrap(), + attachments: vec![], + timestamp_ms, + }; + + // Apply. Auto-scan should fire and store the unwrapped V_me. + apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap(); + + // Alice's keyring should now hold V_bob at epoch 1. + let received = s.list_received_vouch_keys(&alice_id).unwrap(); + assert_eq!(received.len(), 1, "expected one received vouch"); + let (owner, epoch, key) = &received[0]; + assert_eq!(*owner, bob_id); + assert_eq!(*epoch, 1); + assert_eq!(*key, v_me_bob); + + // Scan cache should record the hit so a re-apply is a no-op + // (idempotent + cheap). + let cache = s.lookup_bio_scan_cache(&alice_id, &bob_id, 1).unwrap(); + assert_eq!(cache, Some(Some(1))); + } + + /// Same setup, but receiver-device does NOT follow the voucher. + /// Auto-scan must skip; no vouch keys recorded. + #[test] + fn vouch_grant_skipped_for_non_followed_author() { + use crate::types::{PostingIdentity, VouchGrantBatch}; + use rand::RngCore; + + let s = temp_storage(); + let (alice_seed, alice_id) = make_keypair(70); + let (bob_seed, bob_id) = make_keypair(80); + s.upsert_posting_identity(&PostingIdentity { + node_id: alice_id, + secret_seed: alice_seed, + display_name: "Alice".into(), + created_at: 1000, + }).unwrap(); + // NOT following bob — scan must skip. + + let mut v_me_bob = [0u8; 32]; + rand::rng().fill_bytes(&mut v_me_bob); + let alice_x25519_pub = crypto::ed25519_pubkey_to_x25519_public(&alice_id).unwrap(); + let mut bio_pub_nonce = [0u8; 32]; + rand::rng().fill_bytes(&mut bio_pub_nonce); + let (eph_priv, batch_eph_pub) = crypto::generate_vouch_batch_ephemeral(); + let wrapper = crypto::seal_vouch_grant( + &eph_priv, &alice_x25519_pub, &bio_pub_nonce, &v_me_bob, + ).unwrap(); + let batch = VouchGrantBatch { + batch_eph_pub, + v_x_epoch: 1, + bio_pub_nonce, + wrappers: vec![wrapper], + }; + let timestamp_ms = 2000; + let signature = crypto::sign_profile(&bob_seed, "Bob", "", &None, timestamp_ms); + let content = ProfilePostContent { + display_name: "Bob".into(), + bio: String::new(), + avatar_cid: None, + timestamp_ms, + signature, + vouch_grants: Some(batch), + bio_epoch: 1, + }; + let post = Post { + author: bob_id, + content: serde_json::to_string(&content).unwrap(), + attachments: vec![], + timestamp_ms, + }; + apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap(); + + let received = s.list_received_vouch_keys(&alice_id).unwrap(); + assert!(received.is_empty(), "non-followed author must not auto-scan"); + } } diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index 7958a40..7912b9f 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -1280,6 +1280,14 @@ impl Storage { Ok(()) } + /// Cheap membership check against the `follows` table. + pub fn is_follow(&self, node_id: &NodeId) -> anyhow::Result { + let n: i64 = self.conn.prepare( + "SELECT COUNT(*) FROM follows WHERE node_id = ?1", + )?.query_row(params![node_id.as_slice()], |row| row.get(0))?; + Ok(n > 0) + } + pub fn remove_follow(&self, node_id: &NodeId) -> anyhow::Result<()> { self.conn.execute( "DELETE FROM follows WHERE node_id = ?1", From 34c5b60686dc3e1f6304c8ee9d3194d586c7d3df Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Wed, 13 May 2026 06:47:18 -0400 Subject: [PATCH 14/34] feat(fof-layer1): Tauri commands + frontend UI for vouches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node helpers (crates/core/src/node.rs): - vouch_for_peer(target): derives target X25519 pub from NodeId, inserts into own_vouch_targets, republishes bio post so the new VouchGrantBatch propagates to the receiver via CDN. - revoke_vouch_and_rotate(target): per Scott's design, revocation IS the rotation primitive. Marks target current=0, generates new V_me epoch in vouch_keys_own (prior retained, Layer 4 receiver-chain), republishes bio. Revoked persona retains old V_me → grandfathered access to old content; locked out of new content sealed under V_new. - list_vouches_given / list_vouches_received: enriched with display names via resolve_display_name. Tauri commands (crates/tauri-app/src/lib.rs): - vouch_for_peer, revoke_vouch_for_peer (single-action commands) - list_vouches_given, list_vouches_received (DTOs with camelCase) - All registered in the generate_handler! list. Frontend (frontend/index.html + app.js): - New "Vouches" section in Settings tab with side-by-side Given / Received lists. Per-row Revoke button on Given entries with confirm prompt explaining the rotation semantics. - Bio modal gains Vouch / Revoke Vouch button next to Follow/Unfollow. State derived from list_vouches_given on modal open. - loadVouches wired into the settings-tab activation handler. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/node.rs | 136 ++++++++++++++++++++++++++++++++++++ crates/tauri-app/src/lib.rs | 60 ++++++++++++++++ frontend/app.js | 81 ++++++++++++++++++++- frontend/index.html | 15 ++++ 4 files changed, 291 insertions(+), 1 deletion(-) diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 12f646b..1e5efb7 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -1696,6 +1696,142 @@ impl Node { storage.get_display_name(node_id) } + // ---- FoF Layer 1: Vouches ---- + + /// Vouch for a persona from the current default posting identity. + /// Inserts into `own_vouch_targets` and republishes the bio post so + /// the recipient sees the vouch on their next scan. + pub async fn vouch_for_peer(&self, target: &NodeId) -> anyhow::Result<()> { + let (default_id, display_name, bio, avatar_cid, posting_secret) = { + let storage = self.storage.get().await; + let Some(default_id) = storage.get_default_posting_id()? else { + anyhow::bail!("no default posting identity"); + }; + let pi = storage.get_posting_identity(&default_id)? + .ok_or_else(|| anyhow::anyhow!("default posting identity not in storage"))?; + let profile = storage.get_profile(&default_id)?; + let (name, bio, avatar) = match profile { + Some(p) => (p.display_name, p.bio, p.avatar_cid), + None => (pi.display_name.clone(), String::new(), None), + }; + (default_id, name, bio, avatar, pi.secret_seed) + }; + + // Convert the target's ed25519 NodeId to its X25519 pubkey via + // the same Montgomery derivation receivers use. + let target_x25519_pub = crate::crypto::ed25519_pubkey_to_x25519_public(target)?; + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + + { + let storage = self.storage.get().await; + storage.upsert_vouch_target(&default_id, target, &target_x25519_pub, now_ms, true)?; + } + + // Republish bio post so the new vouch_grants batch propagates. + self.publish_profile_post_as( + &default_id, &posting_secret, &display_name, &bio, avatar_cid, + ).await?; + Ok(()) + } + + /// Revoke a vouch + rotate V_me. Per Scott's design: revocation IS + /// the rotation primitive. The new V_me_epoch is generated and the + /// bio post is republished with wrappers for every remaining target + /// (current=1); the revoked persona only ever held the old V_me, so + /// they're frozen out of future content but retain access to old + /// content (grandfathered) per Layer 4. + pub async fn revoke_vouch_and_rotate(&self, target: &NodeId) -> anyhow::Result<()> { + use rand::RngCore; + let (default_id, display_name, bio, avatar_cid, posting_secret) = { + let storage = self.storage.get().await; + let Some(default_id) = storage.get_default_posting_id()? else { + anyhow::bail!("no default posting identity"); + }; + let pi = storage.get_posting_identity(&default_id)? + .ok_or_else(|| anyhow::anyhow!("default posting identity not in storage"))?; + let profile = storage.get_profile(&default_id)?; + let (name, bio, avatar) = match profile { + Some(p) => (p.display_name, p.bio, p.avatar_cid), + None => (pi.display_name.clone(), String::new(), None), + }; + (default_id, name, bio, avatar, pi.secret_seed) + }; + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + + { + let storage = self.storage.get().await; + // Soft-revoke: drop from current set (row retained for the + // audit trail + cascade-pickup later if needed). + storage.revoke_vouch_target(&default_id, target)?; + + // Rotate V_me: pick the next epoch, insert as current. Prior + // epoch retained (Layer 4 receiver-chain model). + let next_epoch = storage.current_own_vouch_key(&default_id)? + .map(|(e, _)| e + 1) + .unwrap_or(1); + let mut new_key = [0u8; 32]; + rand::rng().fill_bytes(&mut new_key); + storage.insert_own_vouch_key(&default_id, next_epoch, &new_key, now_ms)?; + } + + // Republish bio post — new V_me wrapped to every still-current target. + self.publish_profile_post_as( + &default_id, &posting_secret, &display_name, &bio, avatar_cid, + ).await?; + Ok(()) + } + + /// List vouches the default persona has issued. Returns + /// `(target_node_id, display_name, granted_at_ms)` tuples. + pub async fn list_vouches_given(&self) -> anyhow::Result> { + let (default_id, targets) = { + let storage = self.storage.get().await; + let Some(default_id) = storage.get_default_posting_id()? else { + return Ok(Vec::new()); + }; + let targets = storage.list_current_vouch_targets(&default_id)?; + (default_id, targets) + }; + let _ = default_id; + let mut out = Vec::with_capacity(targets.len()); + for (tid, _xpub, at) in targets { + let display = match self.resolve_display_name(&tid).await { + Ok((name, _, _)) if !name.is_empty() => name, + _ => String::new(), + }; + out.push((tid, display, at)); + } + Ok(out) + } + + /// List vouches received by the default persona. Returns + /// `(voucher_node_id, display_name, latest_epoch, latest_received_at_ms)`. + pub async fn list_vouches_received(&self) -> anyhow::Result> { + let (default_id, vouchers) = { + let storage = self.storage.get().await; + let Some(default_id) = storage.get_default_posting_id()? else { + return Ok(Vec::new()); + }; + let vouchers = storage.list_vouchers_for(&default_id)?; + (default_id, vouchers) + }; + let _ = default_id; + let mut out = Vec::with_capacity(vouchers.len()); + for (owner, epoch, at) in vouchers { + let display = match self.resolve_display_name(&owner).await { + Ok((name, _, _)) if !name.is_empty() => name, + _ => String::new(), + }; + out.push((owner, display, epoch, at)); + } + Ok(out) + } + // ---- Blobs ---- /// Get a blob by CID from local store. diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 44e3c93..2b99819 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -1089,6 +1089,62 @@ async fn list_ignored_peers(state: State<'_, AppNode>) -> Result, node_id_hex: String) -> Result<(), String> { + let node = get_node(&state).await; + let nid = parse_node_id(&node_id_hex)?; + node.vouch_for_peer(&nid).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn revoke_vouch_for_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> { + let node = get_node(&state).await; + let nid = parse_node_id(&node_id_hex)?; + node.revoke_vouch_and_rotate(&nid).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn list_vouches_given(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; + let rows = node.list_vouches_given().await.map_err(|e| e.to_string())?; + Ok(rows.into_iter().map(|(nid, name, at)| VouchGivenDto { + node_id: hex::encode(nid), + display_name: name, + granted_at_ms: at, + }).collect()) +} + +#[tauri::command] +async fn list_vouches_received(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; + let rows = node.list_vouches_received().await.map_err(|e| e.to_string())?; + Ok(rows.into_iter().map(|(nid, name, epoch, at)| VouchReceivedDto { + node_id: hex::encode(nid), + display_name: name, + epoch, + received_at_ms: at, + }).collect()) +} + #[tauri::command] async fn list_follows(state: State<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; @@ -3103,6 +3159,10 @@ pub fn run() { ignore_peer, unignore_peer, list_ignored_peers, + vouch_for_peer, + revoke_vouch_for_peer, + list_vouches_given, + list_vouches_received, list_circles, create_circle, delete_circle, diff --git a/frontend/app.js b/frontend/app.js index e01fe3b..2050913 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1633,8 +1633,10 @@ async function openBioModal(nodeId, preloadedName) { const resolved = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null); const follows = await invoke('list_follows').catch(() => []); const ignored = await invoke('list_ignored_peers').catch(() => []); + const vouches = await invoke('list_vouches_given').catch(() => []); const following = follows.some(f => f.nodeId === nodeId); const isIgnored = ignored.some(i => i.nodeId === nodeId); + const isVouched = vouches.some(v => v.nodeId === nodeId); const name = (resolved && resolved.name) || preloadedName || nodeId.slice(0, 12); const bio = (resolved && resolved.bio) || ''; const icon = generateIdenticon(nodeId, 48); @@ -1654,6 +1656,9 @@ async function openBioModal(nodeId, preloadedName) { ${following ? `` : ``} + ${isVouched + ? `` + : ``} ${isIgnored ? `` @@ -1700,6 +1705,27 @@ async function openBioModal(nodeId, preloadedName) { try { await invoke('unignore_peer', { nodeIdHex: nodeId }); toast('Unignored'); close(); loadFeed(true); } catch (e) { toast('Error: ' + e); } }; + const vouch = document.getElementById('bio-vouch'); + if (vouch) vouch.onclick = async () => { + vouch.disabled = true; + try { + await invoke('vouch_for_peer', { nodeIdHex: nodeId }); + toast(`Vouched for ${name}`); + close(); + } catch (e) { toast('Error: ' + e); } + finally { vouch.disabled = false; } + }; + const revokeVouch = document.getElementById('bio-revoke-vouch'); + if (revokeVouch) revokeVouch.onclick = async () => { + if (!confirm(`Revoke vouch for ${name}? This rotates your vouch key — they keep access to existing posts but not future ones.`)) return; + revokeVouch.disabled = true; + try { + await invoke('revoke_vouch_for_peer', { nodeIdHex: nodeId }); + toast('Revoked and rotated'); + close(); + } catch (e) { toast('Error: ' + e); } + finally { revokeVouch.disabled = false; } + }; } catch (e) { bodyEl.innerHTML = `

    Error: ${e}

    `; } @@ -3062,7 +3088,7 @@ document.querySelectorAll('.tab').forEach(tab => { loadMessages(true); loadDmRecipientOptions(); clearNotifications('msg-'); } - if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); loadUpdateSettings(); loadIgnored(); } + if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); loadUpdateSettings(); loadIgnored(); loadVouches(); } }); }); }); @@ -3618,6 +3644,59 @@ async function loadIgnored() { } } +async function loadVouches() { + const givenEl = document.getElementById('vouches-given-list'); + const recvEl = document.getElementById('vouches-received-list'); + if (!givenEl || !recvEl) return; + try { + const [given, received] = await Promise.all([ + invoke('list_vouches_given').catch(() => []), + invoke('list_vouches_received').catch(() => []), + ]); + if (!given || given.length === 0) { + givenEl.innerHTML = '

    No vouches given.

    '; + } else { + givenEl.innerHTML = given.map(v => { + const label = escapeHtml(v.displayName || v.nodeId.slice(0, 12)); + const icon = generateIdenticon(v.nodeId, 18); + return `
    +
    ${icon} ${label}
    +
    + +
    +
    `; + }).join(''); + givenEl.querySelectorAll('.revoke-vouch-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const name = btn.dataset.name; + if (!confirm(`Revoke vouch for ${name}? This rotates your vouch key — they keep access to existing posts but not future ones.`)) return; + btn.disabled = true; + try { + await invoke('revoke_vouch_for_peer', { nodeIdHex: btn.dataset.nodeId }); + toast('Revoked and rotated'); + loadVouches(); + } catch (e) { toast('Error: ' + e); } + finally { btn.disabled = false; } + }); + }); + } + if (!received || received.length === 0) { + recvEl.innerHTML = '

    No vouches received.

    '; + } else { + recvEl.innerHTML = received.map(v => { + const label = escapeHtml(v.displayName || v.nodeId.slice(0, 12)); + const icon = generateIdenticon(v.nodeId, 18); + return `
    +
    ${icon} ${label}
    +
    epoch ${v.epoch}
    +
    `; + }).join(''); + } + } catch (e) { + givenEl.innerHTML = `

    Error: ${e}

    `; + } +} + // --- Release announcement / upgrade banner --- async function loadUpgradeBanner() { const banner = document.getElementById('upgrade-banner'); diff --git a/frontend/index.html b/frontend/index.html index 87d9c83..577fe08 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -198,6 +198,21 @@
    +
    +

    Vouches

    +

    Vouches you've given let those friends read your Friend-of-Friend posts. Vouches you've received unlock the posts of those who vouched for you. Revoking rotates your vouch key — the revoked friend keeps access to your existing posts, but not future ones.

    +
    +
    +

    Given

    +
    +
    +
    +

    Received

    +
    +
    +
    +
    +

    Updates

    Network-wide release announcements are signed by the bootstrap anchor and arrive via the CDN. Choose which channel to follow.

    From 74fec3b1fb6311c7c0d99d964ad1284e1d492828 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Wed, 13 May 2026 07:16:42 -0400 Subject: [PATCH 15/34] feat(fof-layer2): wrap-slot dual-derivation seal/open primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundational crypto for FoF Mode 2 (public body + FoF-gated comments) and Mode 1 (FoFClosed; later). Implements the dual-derivation wrap slot from docs/fof-spec/layer-2-mode2-fof-comments.md: - Each slot is sealed under one V_x and dual-derived: read part → 32B CEK (read capability for the post) sign part → 32B priv_x (per-V_x signing capability) - Both halves use ChaCha20-Poly1305 with deterministic key+nonce derived from (V_x, slot_binder_nonce) via blake3::derive_key with distinct sub-contexts. Receiver trial-decrypts: success on both halves yields OpenedWrapSlot{cek, priv_x_seed}. - 2-byte prefilter tag = blake3-derive("...prefilter", nonce||V_x)[..2]. Receivers precompute one per held V_x per post; skip non-matching slots entirely. Cuts trial-decrypt cost by ~2^16. slot_binder_nonce (32B random per-post) replaces the spec's literal "post_id in HKDF info" — PostId = BLAKE3(post) would be circular here. Same anti-replay property: unique per publish, recipient-free, in the post header in plaintext. Also adds derive_cek_comments(cek, slot_binder_nonce) for the comment-body encryption key (distinct from the post body CEK; lets Mode 2 keep body public but comments private). 4 unit tests: slot roundtrip, wrong-binder-fails, prefilter tag stability + keying, cek_comments distinct-per-post. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/crypto.rs | 242 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) diff --git a/crates/core/src/crypto.rs b/crates/core/src/crypto.rs index f7264c1..9130d6b 100644 --- a/crates/core/src/crypto.rs +++ b/crates/core/src/crypto.rs @@ -18,6 +18,17 @@ const CEK_WRAP_CONTEXT: &str = "itsgoin/cek-wrap/v1"; const VOUCH_GRANT_KEY_CONTEXT: &str = "itsgoin/vouch-grant/v1/key"; const VOUCH_GRANT_NONCE_CONTEXT: &str = "itsgoin/vouch-grant/v1/nonce"; +/// FoF Layer 2: per-V_x wrap-slot derivation contexts. Each slot is +/// dual-derived under a different sub-context: `read` yields the CEK +/// (read capability), `sign` yields the per-V_x signing seed. Bound to +/// the post via `slot_binder_nonce` (a random 32B nonce in the post +/// header — not the PostId, which would be circular). +const WRAP_SLOT_READ_KEY_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/read/key"; +const WRAP_SLOT_READ_NONCE_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/read/nonce"; +const WRAP_SLOT_SIGN_KEY_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/sign/key"; +const WRAP_SLOT_SIGN_NONCE_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/sign/nonce"; +const WRAP_SLOT_PREFILTER_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/prefilter"; + /// Convert an ed25519 seed (32 bytes from identity.key) to X25519 private scalar bytes. pub fn ed25519_seed_to_x25519_private(seed: &[u8; 32]) -> [u8; 32] { let signing_key = SigningKey::from_bytes(seed); @@ -301,6 +312,158 @@ pub fn open_vouch_grant( Some(v_me) } +// --- FoF Layer 2: wrap-slot seal/open (dual-derived read + sign) --- +// +// Each post under FoF comment-gating carries one wrap slot per +// admitted V_x. The slot is dual-derived: one half yields the post's +// shared CEK (read capability), the other yields the per-V_x signing +// seed priv_x (comment-authorship capability for that voucher-chain). +// +// Receivers trial-decrypt slots whose prefilter tag matches one of +// their held V_x's. Successful AEAD-open on the `read` part gives +// them CEK; the `sign` part gives them priv_x. They derive the +// matching pub_x = ed25519_pub(priv_x_seed); the CDN verifies their +// comment signatures against pub_x via the post's pub_post_set. +// +// All AEAD derivation is bound to a per-post `slot_binder_nonce` +// (random 32B in the post header). This plays the same role as the +// spec's "post_id in HKDF info" but isn't circular (PostId = +// BLAKE3(post) depends on wrap_slots → circular). +// +// Wire shape: +// prefilter_tag: 2 bytes (HMAC(V_x, slot_binder_nonce)[:2]) +// read_part: 48 bytes (32B sealed CEK + 16B tag) +// sign_part: 48 bytes (32B sealed priv_x seed + 16B tag) +// Total: 98 bytes per slot. + +/// Output of [`seal_wrap_slot`]. All fields are wire-stable. See module +/// doc above for derivation details. +#[derive(Debug, Clone)] +pub struct SealedWrapSlot { + pub prefilter_tag: [u8; 2], + pub read_ciphertext: Vec, // 48 bytes + pub sign_ciphertext: Vec, // 48 bytes +} + +/// Compute the 2-byte prefilter tag for a (V_x, slot_binder_nonce) +/// pair. Cheap; receivers precompute one per held V_x per post and +/// skip non-matching slots entirely. +pub fn wrap_slot_prefilter_tag(v_x: &[u8; 32], slot_binder_nonce: &[u8; 32]) -> [u8; 2] { + let mut input = [0u8; 64]; + input[..32].copy_from_slice(slot_binder_nonce); + input[32..].copy_from_slice(v_x); + let tag = blake3::derive_key(WRAP_SLOT_PREFILTER_CONTEXT, &input); + [tag[0], tag[1]] +} + +fn derive_wrap_slot_part( + v_x: &[u8; 32], + slot_binder_nonce: &[u8; 32], + key_ctx: &str, + nonce_ctx: &str, +) -> ([u8; 32], [u8; 12]) { + let mut input = [0u8; 64]; + input[..32].copy_from_slice(slot_binder_nonce); + input[32..].copy_from_slice(v_x); + let key = blake3::derive_key(key_ctx, &input); + let nonce_full = blake3::derive_key(nonce_ctx, &input); + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(&nonce_full[..12]); + (key, nonce) +} + +/// Seal one wrap slot for a specific V_x. Pair (CEK, priv_x_seed) is +/// the slot plaintext: the read part carries CEK, the sign part carries +/// priv_x_seed. Both halves are bound to `slot_binder_nonce` via HKDF. +pub fn seal_wrap_slot( + v_x: &[u8; 32], + slot_binder_nonce: &[u8; 32], + cek: &[u8; 32], + priv_x_seed: &[u8; 32], +) -> Result { + let (read_key, read_nonce) = derive_wrap_slot_part( + v_x, slot_binder_nonce, + WRAP_SLOT_READ_KEY_CONTEXT, WRAP_SLOT_READ_NONCE_CONTEXT, + ); + let (sign_key, sign_nonce) = derive_wrap_slot_part( + v_x, slot_binder_nonce, + WRAP_SLOT_SIGN_KEY_CONTEXT, WRAP_SLOT_SIGN_NONCE_CONTEXT, + ); + let read_cipher = ChaCha20Poly1305::new_from_slice(&read_key) + .map_err(|e| anyhow::anyhow!("read cipher init: {}", e))?; + let sign_cipher = ChaCha20Poly1305::new_from_slice(&sign_key) + .map_err(|e| anyhow::anyhow!("sign cipher init: {}", e))?; + let read_ct = read_cipher + .encrypt(Nonce::from_slice(&read_nonce), cek.as_slice()) + .map_err(|e| anyhow::anyhow!("read seal: {}", e))?; + let sign_ct = sign_cipher + .encrypt(Nonce::from_slice(&sign_nonce), priv_x_seed.as_slice()) + .map_err(|e| anyhow::anyhow!("sign seal: {}", e))?; + if read_ct.len() != 48 || sign_ct.len() != 48 { + bail!("unexpected wrap-slot ciphertext length"); + } + Ok(SealedWrapSlot { + prefilter_tag: wrap_slot_prefilter_tag(v_x, slot_binder_nonce), + read_ciphertext: read_ct, + sign_ciphertext: sign_ct, + }) +} + +/// Output of a successful [`open_wrap_slot`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpenedWrapSlot { + pub cek: [u8; 32], + pub priv_x_seed: [u8; 32], +} + +/// Try to open a wrap slot using one of the receiver's V_x's. Returns +/// `None` if either AEAD fails (this slot isn't sealed under this V_x). +pub fn open_wrap_slot( + v_x: &[u8; 32], + slot_binder_nonce: &[u8; 32], + read_ciphertext: &[u8], + sign_ciphertext: &[u8], +) -> Option { + if read_ciphertext.len() != 48 || sign_ciphertext.len() != 48 { + return None; + } + let (read_key, read_nonce) = derive_wrap_slot_part( + v_x, slot_binder_nonce, + WRAP_SLOT_READ_KEY_CONTEXT, WRAP_SLOT_READ_NONCE_CONTEXT, + ); + let (sign_key, sign_nonce) = derive_wrap_slot_part( + v_x, slot_binder_nonce, + WRAP_SLOT_SIGN_KEY_CONTEXT, WRAP_SLOT_SIGN_NONCE_CONTEXT, + ); + let read_cipher = ChaCha20Poly1305::new_from_slice(&read_key).ok()?; + let sign_cipher = ChaCha20Poly1305::new_from_slice(&sign_key).ok()?; + let cek_bytes = read_cipher + .decrypt(Nonce::from_slice(&read_nonce), read_ciphertext) + .ok()?; + let seed_bytes = sign_cipher + .decrypt(Nonce::from_slice(&sign_nonce), sign_ciphertext) + .ok()?; + if cek_bytes.len() != 32 || seed_bytes.len() != 32 { + return None; + } + let mut cek = [0u8; 32]; + cek.copy_from_slice(&cek_bytes); + let mut priv_x_seed = [0u8; 32]; + priv_x_seed.copy_from_slice(&seed_bytes); + Some(OpenedWrapSlot { cek, priv_x_seed }) +} + +/// Derive the per-post comments CEK from the wrap-slot CEK. The +/// comments-CEK is used to encrypt comment bodies separately from the +/// post body — preserves the option of body-public + comments-private +/// (Mode 2) without leaking the body CEK relationship. +pub fn derive_cek_comments(cek: &[u8; 32], slot_binder_nonce: &[u8; 32]) -> [u8; 32] { + let mut input = [0u8; 64]; + input[..32].copy_from_slice(cek); + input[32..].copy_from_slice(slot_binder_nonce); + blake3::derive_key("itsgoin/fof-cek-comments/v1", &input) +} + /// Encrypt a post with a provided CEK, wrapping for recipients. /// Returns `(base64_ciphertext, Vec)`. pub fn encrypt_post_with_cek( @@ -1522,4 +1685,83 @@ mod tests { let attempt = open_vouch_grant(&bob_priv, &eph_pub, &bio_post_id, &junk); assert_eq!(attempt, None, "random bytes must AEAD-fail (dummy wrapper indistinguishable)"); } + + // --- FoF Layer 2: wrap-slot seal/open --- + + #[test] + fn wrap_slot_roundtrip() { + let v_x: [u8; 32] = [0x42; 32]; + let slot_binder_nonce: [u8; 32] = [0xAB; 32]; + let cek: [u8; 32] = [0x01; 32]; + let priv_x_seed: [u8; 32] = [0x02; 32]; + + let sealed = seal_wrap_slot(&v_x, &slot_binder_nonce, &cek, &priv_x_seed).unwrap(); + assert_eq!(sealed.read_ciphertext.len(), 48); + assert_eq!(sealed.sign_ciphertext.len(), 48); + + // Same V_x opens it. + let opened = open_wrap_slot( + &v_x, &slot_binder_nonce, + &sealed.read_ciphertext, &sealed.sign_ciphertext, + ).unwrap(); + assert_eq!(opened.cek, cek); + assert_eq!(opened.priv_x_seed, priv_x_seed); + + // Different V_x must not. + let wrong_v_x: [u8; 32] = [0x99; 32]; + let attempt = open_wrap_slot( + &wrong_v_x, &slot_binder_nonce, + &sealed.read_ciphertext, &sealed.sign_ciphertext, + ); + assert_eq!(attempt, None); + } + + #[test] + fn wrap_slot_wrong_binder_fails() { + let v_x: [u8; 32] = [0x42; 32]; + let real_nonce: [u8; 32] = [0xAB; 32]; + let wrong_nonce: [u8; 32] = [0xCD; 32]; + let cek: [u8; 32] = [0x01; 32]; + let priv_x_seed: [u8; 32] = [0x02; 32]; + + let sealed = seal_wrap_slot(&v_x, &real_nonce, &cek, &priv_x_seed).unwrap(); + // Same V_x but wrong slot_binder_nonce → AEAD-fail. + let attempt = open_wrap_slot( + &v_x, &wrong_nonce, + &sealed.read_ciphertext, &sealed.sign_ciphertext, + ); + assert_eq!(attempt, None); + } + + #[test] + fn wrap_slot_prefilter_tag_is_stable_and_keyed() { + let v_a: [u8; 32] = [0x11; 32]; + let v_b: [u8; 32] = [0x22; 32]; + let nonce_x: [u8; 32] = [0xAA; 32]; + let nonce_y: [u8; 32] = [0xBB; 32]; + + let t1 = wrap_slot_prefilter_tag(&v_a, &nonce_x); + let t2 = wrap_slot_prefilter_tag(&v_a, &nonce_x); + assert_eq!(t1, t2, "deterministic for same inputs"); + + // Different V_x or different nonce → different tag (overwhelmingly). + let t3 = wrap_slot_prefilter_tag(&v_b, &nonce_x); + assert_ne!(t1, t3); + let t4 = wrap_slot_prefilter_tag(&v_a, &nonce_y); + assert_ne!(t1, t4); + } + + #[test] + fn cek_comments_is_distinct_per_post() { + let cek: [u8; 32] = [0x01; 32]; + let nonce_a: [u8; 32] = [0xAA; 32]; + let nonce_b: [u8; 32] = [0xBB; 32]; + let a = derive_cek_comments(&cek, &nonce_a); + let b = derive_cek_comments(&cek, &nonce_b); + assert_ne!(a, b); + // Stable. + assert_eq!(derive_cek_comments(&cek, &nonce_a), a); + // Different from the base CEK. + assert_ne!(a, cek); + } } From 0f5147a31cb1d875d6ed73023376fa8c2e34a920 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 13:39:46 -0400 Subject: [PATCH 16/34] =?UTF-8?q?feat(fof-layer2):=20wire=20types=20?= =?UTF-8?q?=E2=80=94=20WrapSlot,=20FoFCommentGating,=20CommentPermission::?= =?UTF-8?q?FriendsOfFriends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the on-wire shapes for FoF Mode 2 comment-gating per docs/fof-spec/layer-2-mode2-fof-comments.md: - WrapSlot: per-V_x slot with 2B prefilter_tag + 48B read_ciphertext + 48B sign_ciphertext (sealed CEK + sealed priv_x_seed). 98 bytes total per slot. Receiver trial-decrypts via prefilter match. - FoFCommentGating: author-published gating block embedded in Post.fof_gating. Carries slot_binder_nonce (32B random; replaces spec's circular "post_id in HKDF info"), pub_post_set (1:1 with wrap_slots, includes dummy pubkeys), wrap_slots, and revocation_list (initially empty; revocation diffs accumulate on the BlobHeader copy). - RevocationEntry: author-signed entry triggering retroactive comment delete + pub_post_set removal on every file-holder that receives it. - CommentPermission gains FriendsOfFriends variant. Existing match arm in connection.rs handle-incoming-diff path is extended with a "drop pending CDN four-check verification" stub (full verify in a later slice). - InlineComment extended with three optional fields: pub_x_index: index into parent post's pub_post_set group_sig: 64B ed25519 sig under priv_x encrypted_payload: ChaCha20-Poly1305 ciphertext under CEK_comments All #[serde(default)] for back-compat. Old comments deserialize cleanly with None. - Post gains optional fof_gating field for the author-signed snapshot at publish time. PostId = BLAKE3(Post) covers it, so any tampering is detectable. Mutations (revocation, access-grant) arrive later as diffs against the local BlobHeader copy. All 21 existing Post construction sites + 4 existing InlineComment sites updated via perl -0pe sweeps to pass None for the new fields. Full test suite: 134/134 pass (4 new slot crypto + 130 existing). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/announcement.rs | 1 + crates/core/src/connection.rs | 6 ++ crates/core/src/content.rs | 4 + crates/core/src/control.rs | 4 + crates/core/src/group_key_distribution.rs | 2 + crates/core/src/import.rs | 3 + crates/core/src/network.rs | 1 + crates/core/src/node.rs | 6 ++ crates/core/src/profile.rs | 3 + crates/core/src/storage.rs | 23 ++++++ crates/core/src/types.rs | 95 +++++++++++++++++++++++ 11 files changed, 148 insertions(+) diff --git a/crates/core/src/announcement.rs b/crates/core/src/announcement.rs index a3a6c89..098677e 100644 --- a/crates/core/src/announcement.rs +++ b/crates/core/src/announcement.rs @@ -143,6 +143,7 @@ pub fn build_announcement_post( content: serde_json::to_string(&content).unwrap_or_default(), attachments: vec![], timestamp_ms, + fof_gating: None, } } diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 1d4917b..e7b1fb5 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -6171,6 +6171,12 @@ impl ConnectionManager { } } crate::types::CommentPermission::Public => {} + crate::types::CommentPermission::FriendsOfFriends => { + // FoF four-check verification gate lives + // in a future slice; for now treat as + // "drop until verified" (safest default). + continue; + } } if !crate::crypto::verify_comment_signature( &comment.author, diff --git a/crates/core/src/content.rs b/crates/core/src/content.rs index ecb3664..622a016 100644 --- a/crates/core/src/content.rs +++ b/crates/core/src/content.rs @@ -23,6 +23,7 @@ mod tests { content: "hello world".to_string(), attachments: vec![], timestamp_ms: 1000, + fof_gating: None, }; let id1 = compute_post_id(&post); let id2 = compute_post_id(&post); @@ -36,12 +37,14 @@ mod tests { content: "hello".to_string(), attachments: vec![], timestamp_ms: 1000, + fof_gating: None, }; let post2 = Post { author: [1u8; 32], content: "world".to_string(), attachments: vec![], timestamp_ms: 1000, + fof_gating: None, }; assert_ne!(compute_post_id(&post1), compute_post_id(&post2)); } @@ -53,6 +56,7 @@ mod tests { content: "test".to_string(), attachments: vec![], timestamp_ms: 1000, + fof_gating: None, }; let id = compute_post_id(&post); assert!(verify_post_id(&id, &post)); diff --git a/crates/core/src/control.rs b/crates/core/src/control.rs index 9d75d98..57dd53d 100644 --- a/crates/core/src/control.rs +++ b/crates/core/src/control.rs @@ -155,6 +155,7 @@ pub fn build_delete_control_post( content: serde_json::to_string(&op).unwrap_or_default(), attachments: vec![], timestamp_ms, + fof_gating: None, } } @@ -182,6 +183,7 @@ pub fn build_visibility_control_post( content: serde_json::to_string(&op).unwrap_or_default(), attachments: vec![], timestamp_ms, + fof_gating: None, } } @@ -212,6 +214,7 @@ mod tests { content: "hello".to_string(), attachments: vec![], timestamp_ms: 1000, + fof_gating: None, }; let post_id = crate::content::compute_post_id(&post); s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap(); @@ -240,6 +243,7 @@ mod tests { content: "hello".to_string(), attachments: vec![], timestamp_ms: 1000, + fof_gating: None, }; let post_id = crate::content::compute_post_id(&post); s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap(); diff --git a/crates/core/src/group_key_distribution.rs b/crates/core/src/group_key_distribution.rs index f23335e..d229dab 100644 --- a/crates/core/src/group_key_distribution.rs +++ b/crates/core/src/group_key_distribution.rs @@ -61,6 +61,7 @@ pub fn build_distribution_post( content: ciphertext_b64, attachments: vec![], timestamp_ms, + fof_gating: None, }; let post_id = compute_post_id(&post); let visibility = PostVisibility::Encrypted { recipients: wrapped_keys }; @@ -241,6 +242,7 @@ mod tests { content: ciphertext, attachments: vec![], timestamp_ms: 200, + fof_gating: None, }; let forged_vis = PostVisibility::Encrypted { recipients: wrapped }; diff --git a/crates/core/src/import.rs b/crates/core/src/import.rs index ed12d2a..1992868 100644 --- a/crates/core/src/import.rs +++ b/crates/core/src/import.rs @@ -286,6 +286,7 @@ pub async fn import_as_personas( content: ep.content.clone(), attachments: attachments.clone(), timestamp_ms: ep.timestamp_ms, + fof_gating: None, }; // Preserve the original visibility intent from the export. @@ -459,6 +460,7 @@ pub async fn import_public_posts( content: ep.content.clone(), attachments: attachments.clone(), timestamp_ms: ep.timestamp_ms, + fof_gating: None, }; // Read blob data from archive @@ -685,6 +687,7 @@ pub async fn merge_with_key( content: plaintext, attachments: attachments.clone(), timestamp_ms: ep.timestamp_ms, + fof_gating: None, }; // Read blob data from archive (may need decryption for encrypted posts) diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index 114ab41..bc7fd21 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -2253,6 +2253,7 @@ mod tests { content: "test".to_string(), attachments: vec![], timestamp_ms: 1000, + fof_gating: None, } } diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 1e5efb7..44fb070 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -827,6 +827,7 @@ impl Node { content: serde_json::to_string(&content).unwrap_or_default(), attachments: vec![], timestamp_ms: pi.created_at, + fof_gating: None, }; let post_id = crate::content::compute_post_id(&post); { @@ -1162,6 +1163,7 @@ impl Node { content: final_content, attachments, timestamp_ms: now, + fof_gating: None, }; let post_id = compute_post_id(&post); @@ -3081,6 +3083,7 @@ impl Node { content: new_content, attachments: post.attachments.clone(), timestamp_ms: post.timestamp_ms, + fof_gating: None, }; let new_post_id = compute_post_id(&new_post); @@ -4586,6 +4589,9 @@ impl Node { signature, deleted_at: None, ref_post_id, + pub_x_index: None, + group_sig: None, + encrypted_payload: None, }; let storage = self.storage.get().await; diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs index 0ae0183..55970a6 100644 --- a/crates/core/src/profile.rs +++ b/crates/core/src/profile.rs @@ -195,6 +195,7 @@ pub fn build_profile_post( content: serde_json::to_string(&content).unwrap_or_default(), attachments: vec![], timestamp_ms, + fof_gating: None, } } @@ -463,6 +464,7 @@ mod tests { content: serde_json::to_string(&content).unwrap(), attachments: vec![], timestamp_ms, + fof_gating: None, }; // Apply. Auto-scan should fire and store the unwrapped V_me. @@ -531,6 +533,7 @@ mod tests { content: serde_json::to_string(&content).unwrap(), attachments: vec![], timestamp_ms, + fof_gating: None, }; apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap(); diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index 7912b9f..b9452ea 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -893,6 +893,7 @@ impl Storage { content: row.get(1)?, attachments, timestamp_ms: row.get::<_, i64>(3)? as u64, + fof_gating: None, })) } else { Ok(None) @@ -919,6 +920,7 @@ impl Storage { content: row.get(1)?, attachments, timestamp_ms: row.get::<_, i64>(3)? as u64, + fof_gating: None, }, visibility, ))) @@ -1012,6 +1014,7 @@ impl Storage { content, attachments, timestamp_ms: timestamp_ms as u64, + fof_gating: None, }, visibility, )); @@ -1050,6 +1053,7 @@ impl Storage { content, attachments, timestamp_ms: timestamp_ms as u64, + fof_gating: None, }, visibility, )); @@ -1212,6 +1216,7 @@ impl Storage { content, attachments, timestamp_ms: timestamp_ms as u64, + fof_gating: None, }, visibility, )); @@ -1247,6 +1252,7 @@ impl Storage { content, attachments, timestamp_ms: timestamp_ms as u64, + fof_gating: None, }, visibility, )); @@ -2926,6 +2932,7 @@ impl Storage { content: row.get(2)?, attachments, timestamp_ms: row.get::<_, i64>(4)? as u64, + fof_gating: None, }, visibility, )); @@ -5302,6 +5309,9 @@ impl Storage { signature: sig, deleted_at: None, ref_post_id, + pub_x_index: None, + group_sig: None, + encrypted_payload: None, }); } Ok(result) @@ -5341,6 +5351,9 @@ impl Storage { signature: sig, deleted_at: del.map(|v| v as u64), ref_post_id, + pub_x_index: None, + group_sig: None, + encrypted_payload: None, }); } Ok(result) @@ -6100,6 +6113,7 @@ mod tests { content: format!("post at {}", ts), attachments: vec![], timestamp_ms: ts, + fof_gating: None, }; let id = blake3::hash(&serde_json::to_vec(&post).unwrap()); s.store_post(id.as_bytes(), &post).unwrap(); @@ -6797,6 +6811,9 @@ mod tests { signature: vec![0u8; 64], deleted_at: None, ref_post_id: None, + pub_x_index: None, + group_sig: None, + encrypted_payload: None, }).unwrap(); s.store_comment(&InlineComment { @@ -6807,6 +6824,9 @@ mod tests { signature: vec![1u8; 64], deleted_at: None, ref_post_id: None, + pub_x_index: None, + group_sig: None, + encrypted_payload: None, }).unwrap(); let comments = s.get_comments(&post_id).unwrap(); @@ -6832,6 +6852,9 @@ mod tests { signature: vec![9u8; 64], deleted_at: None, ref_post_id: Some(ref_post), + pub_x_index: None, + group_sig: None, + encrypted_payload: None, }).unwrap(); let live = s.get_comments(&post_id).unwrap(); diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 7e3a54a..ec73145 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -39,6 +39,15 @@ pub struct Post { pub attachments: Vec, /// Unix timestamp in milliseconds pub timestamp_ms: u64, + /// FoF Layer 2: author-signed snapshot of the comment-gating + /// state at publish time. Carries wrap_slots, pub_post_set, and the + /// slot_binder_nonce. `None` on posts without FoF comment gating. + /// Covered by `PostId = BLAKE3(Post)` so any forgery is detectable. + /// Revocations and access-grants arrive later as engagement diffs + /// against the local BlobHeader copy; this field is the snapshot at + /// t=0, not the live mutable state. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fof_gating: Option, } /// A reference to a media blob attached to a post @@ -918,6 +927,9 @@ pub struct InlineComment { pub post_id: PostId, /// Either the full comment text (short comments) or a short preview of /// the referenced post (when `ref_post_id` is set). + /// + /// On FoF-policy posts this field is empty — the body lives encrypted + /// in `encrypted_payload`. Non-FoF readers see no text at all. pub content: String, /// When the comment was created (ms) pub timestamp_ms: u64, @@ -932,6 +944,23 @@ pub struct InlineComment { /// for the expanded view. #[serde(default)] pub ref_post_id: Option, + /// FoF Layer 2: index into the parent post's `pub_post_set` + /// identifying which voucher-chain signed this comment. `None` on + /// non-FoF comments. CDN propagation nodes verify `group_sig` + /// against `pub_post_set[pub_x_index]` before forwarding. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pub_x_index: Option, + /// FoF Layer 2: 64-byte ed25519 signature under priv_x over + /// `(encrypted_payload || parent_post_id || pub_x_index)`. Verified + /// at CDN-level against `pub_post_set[pub_x_index]`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_sig: Option>, + /// FoF Layer 2: ChaCha20-Poly1305 ciphertext under CEK_comments + /// (derived from CEK via HKDF). Plaintext is the JSON-encoded + /// comment body + optional vouch_mac + optional parent_comment_id. + /// Non-FoF observers see only ciphertext + sigs — body is opaque. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encrypted_payload: Option>, } /// Permission level for comments on a post @@ -944,6 +973,13 @@ pub enum CommentPermission { FollowersOnly, /// Comments disabled None, + /// FoF Layer 2: commenter must hold one of the V_x keys in the + /// author's keyring (own V_me + every V_x they received). The author + /// publishes pub_post_set + wrap_slots in the post; commenters trial- + /// decrypt to unlock priv_x for signing. CDN nodes verify the + /// comment's group_sig + pub_x_index before forwarding — kills the + /// bandwidth-DoS attack a single admitted FoF member could mount. + FriendsOfFriends, } impl Default for CommentPermission { @@ -987,6 +1023,65 @@ impl Default for ModerationMode { } } +/// FoF Layer 2: per-V_x wrap slot in a post header. Dual-derived so +/// one successful AEAD-open yields both the read CEK and the per-V_x +/// signing seed. Real slots and dummy padding slots are byte-identical +/// (98 bytes each); receivers identify "their" slot by successful +/// AEAD decryption, not by position. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WrapSlot { + /// 2-byte HMAC prefix. Receivers precompute one per held V_x; the + /// scan iterates only slots whose prefilter matches. + pub prefilter_tag: [u8; 2], + /// AEAD ciphertext under KDF(V_x, slot_binder_nonce, "read"); 48B + /// (32B sealed CEK + 16B tag). + pub read_ciphertext: Vec, + /// AEAD ciphertext under KDF(V_x, slot_binder_nonce, "sign"); 48B + /// (32B sealed priv_x ed25519 seed + 16B tag). + pub sign_ciphertext: Vec, +} + +/// FoF Layer 2: author-signed revocation entry. When a post-holder +/// receives a valid revocation diff, it deletes all locally-stored +/// comments signed by `revoked_pub_x` AND removes the entry from its +/// local pub_post_set, then forwards the diff. Retroactive cleanup. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RevocationEntry { + /// The pub_x being revoked. Must be in the post's pub_post_set + /// at the time the diff is processed. + pub revoked_pub_x: [u8; 32], + /// ms since epoch. + pub revoked_at_ms: u64, + /// Opaque to CDN; used by author UI to display the reason. + pub reason_code: u8, + /// 64-byte ed25519 signature by the post author over + /// (post_id || revoked_pub_x || revoked_at_ms || reason_code). + pub author_sig: Vec, +} + +/// FoF Layer 2: the author-published gating block embedded in a +/// FoF-comment-policy post. Carries the wrap slots + the matching +/// pub_post_set + the slot_binder_nonce. The `revocation_list` is +/// initially empty; revocation diffs append over the post's lifetime. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FoFCommentGating { + /// Random 32B nonce. Plays the spec's "post_id in HKDF info" role + /// without circularity (PostId = BLAKE3(post) depends on this field). + pub slot_binder_nonce: [u8; 32], + /// All admitted pub_x's, 1:1 with `wrap_slots` (including dummies). + /// Order is randomized at publish; access-grants append at the tail + /// (Layer 3 resolved decision — pub_x_index stability matters more + /// than the small tail-positional-recency leak). + pub pub_post_set: Vec<[u8; 32]>, + /// Real wrap slots + dummy slots, shuffled at publish. 1:1 with + /// `pub_post_set`. + pub wrap_slots: Vec, + /// Initially empty. Receivers accumulate revocations as diffs + /// arrive; the on-wire t=0 snapshot is empty. + #[serde(default)] + pub revocation_list: Vec, +} + /// Author-controlled engagement policy for a post #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct CommentPolicy { From bdcd2142cd7ee7e2635192f2485316f51d867f95 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 13:50:56 -0400 Subject: [PATCH 17/34] feat(fof-layer2): author publish-side build_fof_comment_gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New crates/core/src/fof.rs module owns the author-side FoF Layer 2 publish path: - build_fof_comment_gating(storage, author_persona_id): gathers the author's keyring (own current V_me + every distinct received V_x), generates a fresh CEK + slot_binder_nonce, generates a fresh per-V_x (priv_x, pub_x) Ed25519 keypair per real slot, seals each slot, pads with random-bytes dummies to the next bucket (min 8, power-of-2 to 256, +128 above per Layer 3), shuffles real + dummy together, and returns the FoFCommentGating wire block + author-local CEK + the slot_binder_nonce. - Dedup at V_x byte level: a key held under multiple owners produces exactly one slot. - next_vouch_batch_bucket promoted to pub(crate) in profile.rs so the Layer 2 fof module can reuse the bucket rule from Layer 3. Three unit tests cover: - Real-count + padding + roundtrip (Alice's own V_me unlocks her slot; Bob's V_x unlocks his slot; both yield same CEK). - No V_me → returns None (graceful). - Duplicate V_x bytes across owners are deduped (single slot). 134 → 138 tests pass (no regressions). Subsequent slices wire this into the post-create path, add the reader/commenter side, the CDN four-check verification, and the revocation/access-grant diff handlers. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/fof.rs | 285 +++++++++++++++++++++++++++++++++++++ crates/core/src/lib.rs | 1 + crates/core/src/profile.rs | 2 +- 3 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 crates/core/src/fof.rs diff --git a/crates/core/src/fof.rs b/crates/core/src/fof.rs new file mode 100644 index 0000000..f806b75 --- /dev/null +++ b/crates/core/src/fof.rs @@ -0,0 +1,285 @@ +//! FoF Layer 2: post-side construction + verification for FoF-gated +//! comments. See `docs/fof-spec/layer-2-mode2-fof-comments.md` for the +//! wire shape and threat model. +//! +//! This module owns the author-side **publish path**: +//! - Generate the post's CEK + per-V_x signing keypairs. +//! - Seal one wrap slot per unique V_x in the author's keyring. +//! - Pad with dummy slots + dummy pub_x entries (bucketed per Layer 3). +//! - Shuffle real + dummy together so positions don't leak ordering. +//! +//! The reader/commenter unwrap path and CDN verification path live in +//! sibling modules (added in subsequent slices). + +use anyhow::Result; +use ed25519_dalek::SigningKey; +use rand::seq::SliceRandom; +use rand::RngCore; + +use crate::crypto::{seal_wrap_slot, SealedWrapSlot}; +use crate::storage::Storage; +use crate::types::{FoFCommentGating, NodeId, WrapSlot}; + +/// Build the `FoFCommentGating` block for a post about to be published +/// under `CommentPermission::FriendsOfFriends`. The author's keyring +/// (own current `V_me` + every distinct received `V_x`) drives the +/// real-slot set; dummies pad the count to the next bucket. +/// +/// Returns `None` if the author has no current `V_me` set — every +/// persona is supposed to have one (auto-generated on creation in +/// Layer 1), but this gracefully no-ops if not. +/// +/// Side effect: this function is pure; no storage writes. The caller +/// owns persisting the resulting Post. +pub fn build_fof_comment_gating( + storage: &Storage, + author_persona_id: &NodeId, +) -> Result> { + // Gather the author's keyring: own current V_me + all unique + // received V_x's (deduped at the byte level per Layer 3). + let Some((_own_epoch, own_v_me)) = storage.current_own_vouch_key(author_persona_id)? else { + return Ok(None); + }; + let received = storage.list_received_vouch_keys(author_persona_id)?; + + // Dedup at the V_x byte level. Keep the highest epoch per (owner, key). + let mut unique_keys: Vec<[u8; 32]> = Vec::with_capacity(1 + received.len()); + unique_keys.push(own_v_me); + for (_owner, _epoch, key) in &received { + if !unique_keys.iter().any(|existing| existing == key) { + unique_keys.push(*key); + } + } + + // Generate the per-post CEK + slot_binder_nonce. + let mut cek = [0u8; 32]; + rand::rng().fill_bytes(&mut cek); + let mut slot_binder_nonce = [0u8; 32]; + rand::rng().fill_bytes(&mut slot_binder_nonce); + + // Per real V_x: generate (priv_x, pub_x) freshly per spec (Layer 2 + // resolved decision — per-post keypair generation). Then seal the + // slot. Build pub_post_set in lockstep with wrap_slots so the + // .len() invariant holds and indices map cleanly. + let mut entries: Vec<([u8; 32], WrapSlot)> = Vec::with_capacity(unique_keys.len()); + for v_x in &unique_keys { + let mut seed = [0u8; 32]; + rand::rng().fill_bytes(&mut seed); + let signing_key = SigningKey::from_bytes(&seed); + let pub_x = *signing_key.verifying_key().as_bytes(); + + let sealed: SealedWrapSlot = seal_wrap_slot(v_x, &slot_binder_nonce, &cek, &seed)?; + let slot = WrapSlot { + prefilter_tag: sealed.prefilter_tag, + read_ciphertext: sealed.read_ciphertext, + sign_ciphertext: sealed.sign_ciphertext, + }; + entries.push((pub_x, slot)); + } + + // Pad to bucket with dummies. Dummy pub_x = 32B random bytes (no + // priv_x exists; group_sig verification against it will always + // fail — benign). Dummy slot = 98B random; AEAD-fails on every V_x. + let bucket = crate::profile::next_vouch_batch_bucket(entries.len()); + let mut rng = rand::rng(); + while entries.len() < bucket { + let mut dummy_pub_x = [0u8; 32]; + rng.fill_bytes(&mut dummy_pub_x); + let mut dummy_prefilter = [0u8; 2]; + rng.fill_bytes(&mut dummy_prefilter); + let mut dummy_read = vec![0u8; 48]; + rng.fill_bytes(&mut dummy_read); + let mut dummy_sign = vec![0u8; 48]; + rng.fill_bytes(&mut dummy_sign); + entries.push(( + dummy_pub_x, + WrapSlot { + prefilter_tag: dummy_prefilter, + read_ciphertext: dummy_read, + sign_ciphertext: dummy_sign, + }, + )); + } + + // Shuffle so real and dummy positions are indistinguishable. + entries.shuffle(&mut rng); + + let (pub_post_set, wrap_slots): (Vec<_>, Vec<_>) = entries.into_iter().unzip(); + + let gating = FoFCommentGating { + slot_binder_nonce, + pub_post_set, + wrap_slots, + revocation_list: Vec::new(), + }; + + Ok(Some(FoFCommentGatingBuilt { + gating, + // Returned to the caller because the author needs them locally: + // - cek: to decrypt their own comments later + // - own pub_x: to find their own slot in pub_post_set for + // authoring author-side comments + future access-grants + cek, + slot_binder_nonce, + })) +} + +/// Output of [`build_fof_comment_gating`]. The gating block goes into +/// `Post.fof_gating`; the side outputs are author-local state the +/// caller should cache (e.g., in `own_post_slot_provenance` introduced +/// in the cascade-revocation slice later). +#[derive(Debug, Clone)] +pub struct FoFCommentGatingBuilt { + pub gating: FoFCommentGating, + /// The post's body/comments encryption key. Authors keep this + /// locally keyed by `post_id` so they can read/decrypt without + /// needing to unwrap a slot themselves. + pub cek: [u8; 32], + /// Same nonce as `gating.slot_binder_nonce`; mirrored here for + /// callers who want it without reaching into the gating struct. + pub slot_binder_nonce: [u8; 32], +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::Storage; + use crate::crypto::{ed25519_seed_to_x25519_private, open_wrap_slot}; + use ed25519_dalek::SigningKey; + use rand::RngCore; + + fn temp_storage() -> Storage { + Storage::open(":memory:").unwrap() + } + + fn make_persona(seed_byte: u8) -> (NodeId, [u8; 32]) { + let mut seed = [0u8; 32]; + seed[0] = seed_byte; + let signing_key = SigningKey::from_bytes(&seed); + (*signing_key.verifying_key().as_bytes(), seed) + } + + /// Author has a V_me + one received V_x → build_fof_comment_gating + /// produces a real-slot-count of 2 (own + received), padded to + /// bucket 8. + #[test] + fn build_gating_realcount_and_padding() { + let s = temp_storage(); + let (alice_id, _alice_seed) = make_persona(1); + let (bob_id, _bob_seed) = make_persona(2); + + // Alice has her own V_me + let mut v_me_alice = [0u8; 32]; + rand::rng().fill_bytes(&mut v_me_alice); + s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap(); + + // Alice received a V_x from Bob + let mut v_x_bob = [0u8; 32]; + rand::rng().fill_bytes(&mut v_x_bob); + s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap(); + + let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("gating built"); + // Real count = 2 (own + Bob). Bucket = 8 (minimum floor). + assert_eq!(built.gating.pub_post_set.len(), 8); + assert_eq!(built.gating.wrap_slots.len(), 8); + assert_eq!(built.gating.revocation_list.len(), 0); + + // Every slot is exactly 48+48 bytes; prefilter is 2 bytes. + for slot in &built.gating.wrap_slots { + assert_eq!(slot.read_ciphertext.len(), 48); + assert_eq!(slot.sign_ciphertext.len(), 48); + } + + // Alice can find HER OWN slot by trial-unwrap with her V_me. + let mut own_hit = None; + for (idx, slot) in built.gating.wrap_slots.iter().enumerate() { + if let Some(opened) = open_wrap_slot( + &v_me_alice, &built.slot_binder_nonce, + &slot.read_ciphertext, &slot.sign_ciphertext, + ) { + assert_eq!(opened.cek, built.cek); + own_hit = Some((idx, opened)); + break; + } + } + let (own_idx, opened) = own_hit.expect("author's own V_me must unlock one slot"); + + // The pub_x in pub_post_set at the same index must match the + // ed25519 pubkey derived from the unwrapped priv_x seed. + let signing_key = SigningKey::from_bytes(&opened.priv_x_seed); + let derived_pub_x = signing_key.verifying_key().to_bytes(); + assert_eq!(built.gating.pub_post_set[own_idx], derived_pub_x); + + // A holder of V_x_bob can also unlock exactly one slot (Bob's + // chain to Alice's post). + let mut bob_hit = None; + for (idx, slot) in built.gating.wrap_slots.iter().enumerate() { + if let Some(opened) = open_wrap_slot( + &v_x_bob, &built.slot_binder_nonce, + &slot.read_ciphertext, &slot.sign_ciphertext, + ) { + bob_hit = Some((idx, opened)); + break; + } + } + let (bob_idx, bob_opened) = bob_hit.expect("Bob's V_x must unlock one slot"); + assert_ne!(bob_idx, own_idx, "different V_x's must hit different slots"); + assert_eq!(bob_opened.cek, built.cek, "same CEK across all real slots"); + } + + #[test] + fn build_gating_returns_none_without_vme() { + let s = temp_storage(); + let (alice_id, _) = make_persona(5); + // No V_me inserted. + let built = build_fof_comment_gating(&s, &alice_id).unwrap(); + assert!(built.is_none(), "no V_me → no gating block"); + } + + #[test] + fn build_gating_deduplicates_repeated_v_x() { + let s = temp_storage(); + let (alice_id, _) = make_persona(7); + let (bob_id, _) = make_persona(8); + let (carol_id, _) = make_persona(9); + + let mut v_me_alice = [0u8; 32]; + rand::rng().fill_bytes(&mut v_me_alice); + s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap(); + + // Two different vouchers happened to issue the SAME key bytes + // (contrived but tests dedup). Real probability is 2^-256; + // here we force it for the test. + let same_key = [0xCC; 32]; + s.insert_received_vouch_key(&alice_id, &bob_id, 1, &same_key, 2000, None).unwrap(); + s.insert_received_vouch_key(&alice_id, &carol_id, 1, &same_key, 3000, None).unwrap(); + + let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + // Unique-key count = 2 (V_me_alice + same_key). Bucket = 8. + // We can't assert real count directly without exposing internals, + // but we can confirm exactly two distinct successful unwraps: + let mut alice_hits = 0; + let mut same_key_hits = 0; + for slot in &built.gating.wrap_slots { + if open_wrap_slot(&v_me_alice, &built.slot_binder_nonce, + &slot.read_ciphertext, &slot.sign_ciphertext).is_some() { + alice_hits += 1; + } + if open_wrap_slot(&same_key, &built.slot_binder_nonce, + &slot.read_ciphertext, &slot.sign_ciphertext).is_some() { + same_key_hits += 1; + } + } + assert_eq!(alice_hits, 1, "exactly one slot for V_me_alice"); + assert_eq!(same_key_hits, 1, "exactly one slot for the duplicated key (dedup'd)"); + } + + // Silences the unused-import warning when the X25519 derivation is + // only exercised in future slices. + #[test] + fn x25519_derivation_helper_compiles() { + let mut seed = [0u8; 32]; + rand::rng().fill_bytes(&mut seed); + let _ = ed25519_seed_to_x25519_private(&seed); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index a9cf280..a4e6d7f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -7,6 +7,7 @@ pub mod crypto; pub mod group_key_distribution; pub mod http; pub mod export; +pub mod fof; pub mod identity; pub mod import; pub mod announcement; diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs index 55970a6..2205c76 100644 --- a/crates/core/src/profile.rs +++ b/crates/core/src/profile.rs @@ -267,7 +267,7 @@ pub fn build_vouch_grant_batch( /// Minimum bucket is 8 (so a single-target post still publishes 8 /// wrappers, hiding "this persona has no vouchees" entirely). /// Power-of-2 up to 256; linear +128 steps above 256. -fn next_vouch_batch_bucket(real: usize) -> usize { +pub(crate) fn next_vouch_batch_bucket(real: usize) -> usize { if real <= 8 { return 8; } if real <= 256 { // smallest power of 2 >= real From 673f9e2261f1da4ef120915283c784122f43b3fc Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 14:01:57 -0400 Subject: [PATCH 18/34] feat(fof-layer2): wire FoF gating into post-create path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threads optional fof_gating through create_post_inner so a published post can carry the author-signed gating snapshot at publish time (covered by PostId = BLAKE3(Post)). New public entry point: Node::create_post_with_fof_comments(content, attachments) -> (PostId, Post, PostVisibility, cek: [u8; 32]) Builds the FoFCommentGating block via fof::build_fof_comment_gating from the default persona's keyring (own V_me + every received V_x), then calls create_post_inner with VisibilityIntent::Public (Mode 2 keeps body public). Returns the per-post CEK to the caller for local caching (decrypting one's own comments later). Existing create_post / create_post_as / create_post_with_visibility threads `None` through the new arg — back-compat for non-FoF posts. CommentPolicy::FriendsOfFriends is NOT published as a SetPolicy diff in this commit; the post's `fof_gating` field is itself the signal that the post supports FoF commenting. The four-check CDN verify gate (next commit) reads fof_gating directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/node.rs | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 44fb070..02940d6 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -1014,6 +1014,7 @@ impl Node { content, intent, attachment_data, + None, ).await } @@ -1038,9 +1039,43 @@ impl Node { content, intent, attachment_data, + None, ).await } + /// FoF Layer 2: create a Mode 2 post (public body, FoF-gated + /// comments). Intent is Public; the FoF gating block is built + /// from the default persona's keyring and embedded in + /// `Post.fof_gating`. The author retains the per-post CEK locally + /// for decrypting their own comments later. + /// + /// Returns `(post_id, post, visibility, cek)`. `visibility` is + /// always Public for Mode 2. + pub async fn create_post_with_fof_comments( + &self, + content: String, + attachment_data: Vec<(Vec, String)>, + ) -> anyhow::Result<(PostId, Post, PostVisibility, [u8; 32])> { + // Build the gating block from the default persona's keyring. + let built = { + let storage = self.storage.get().await; + crate::fof::build_fof_comment_gating(&*storage, &self.default_posting_id)? + .ok_or_else(|| anyhow::anyhow!( + "default persona has no V_me; rotate or recreate before FoF posts" + ))? + }; + let cek = built.cek; + let (post_id, post, visibility) = self.create_post_inner( + &self.default_posting_id, + &self.default_posting_secret, + content, + VisibilityIntent::Public, + attachment_data, + Some(built.gating), + ).await?; + Ok((post_id, post, visibility, cek)) + } + async fn create_post_inner( &self, posting_id: &NodeId, @@ -1048,6 +1083,7 @@ impl Node { content: String, intent: VisibilityIntent, attachment_data: Vec<(Vec, String)>, + fof_gating: Option, ) -> anyhow::Result<(PostId, Post, PostVisibility)> { // Validate attachments if attachment_data.len() > 4 { @@ -1163,7 +1199,7 @@ impl Node { content: final_content, attachments, timestamp_ms: now, - fof_gating: None, + fof_gating, }; let post_id = compute_post_id(&post); From 00522f4c4be7549429a62c4ed0543d8954042324 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 14:04:22 -0400 Subject: [PATCH 19/34] feat(fof-layer2): reader unlock + commenter authoring + sig verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the reader/commenter side of FoF Layer 2 in crates/core/src/fof.rs: - find_unlock_for_post(storage, post): scans every persona × every held V_x (own V_me + received) against the post's wrap_slots using the 2B prefilter tag. Returns first successful unlock as a PostUnlock { persona_id, slot_index, cek, priv_x_seed }. - FoFCommentPayload: serializable inner-plaintext shape (body + parent_comment_id + optional vouch_mac). Wrapped under CEK_comments inside InlineComment.encrypted_payload. - build_fof_comment(parent_post_id, unlock, slot_binder_nonce, commenter_id, commenter_secret, body, parent_comment_id, now_ms) → InlineComment: encrypts the body under CEK_comments, signs (encrypted_payload || post_id || pub_x_index_le) with priv_x for group_sig, signs the conventional comment tuple with commenter's identity key, fills pub_x_index from the unlock. - verify_fof_group_sig(comment, gating): CDN-level Ed25519 verify of group_sig against pub_post_set[pub_x_index]. Returns false on any shape/index/key/signature failure. Used by the four-check accept rule (next slice). - decrypt_fof_comment_payload(comment, cek, slot_binder_nonce): inverse of build_fof_comment's encryption step. Used by authors with cached CEKs and by readers who just unlocked. End-to-end roundtrip test covers: Alice publishes Mode 2 FoF post with Bob's V_x in the gating; Bob's device unlocks via his V_me; Bob authors a comment; verify_fof_group_sig accepts it; tampered payload and wrong pub_x_index both reject; Alice decrypts the payload. 138 → 139 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/fof.rs | 315 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 308 insertions(+), 7 deletions(-) diff --git a/crates/core/src/fof.rs b/crates/core/src/fof.rs index f806b75..1aa6151 100644 --- a/crates/core/src/fof.rs +++ b/crates/core/src/fof.rs @@ -2,14 +2,16 @@ //! comments. See `docs/fof-spec/layer-2-mode2-fof-comments.md` for the //! wire shape and threat model. //! -//! This module owns the author-side **publish path**: -//! - Generate the post's CEK + per-V_x signing keypairs. -//! - Seal one wrap slot per unique V_x in the author's keyring. -//! - Pad with dummy slots + dummy pub_x entries (bucketed per Layer 3). -//! - Shuffle real + dummy together so positions don't leak ordering. +//! This module owns: +//! - **Author publish path** ([`build_fof_comment_gating`]): seal wrap +//! slots, generate per-V_x signing keypairs, bucket-pad, shuffle. +//! - **Reader/commenter unlock path** ([`find_unlock_for_post`], +//! [`build_fof_comment`]): trial-decrypt slots with any held V_x; +//! if a slot opens, derive priv_x + comments-CEK, encrypt the +//! comment body, sign with priv_x, attach pub_x_index. //! -//! The reader/commenter unwrap path and CDN verification path live in -//! sibling modules (added in subsequent slices). +//! The CDN four-check verification path and revocation handling live +//! in sibling modules (added in subsequent slices). use anyhow::Result; use ed25519_dalek::SigningKey; @@ -140,6 +142,205 @@ pub struct FoFCommentGatingBuilt { pub slot_binder_nonce: [u8; 32], } +// --- Reader / commenter side --- + +/// FoF Layer 2: a persona's successful unlock of a gated post. +/// Carries everything the persona needs to read encrypted comments AND +/// author new ones. +#[derive(Debug, Clone)] +pub struct PostUnlock { + /// Which persona unlocked the post (the V_x that matched belongs to + /// this persona's keyring — owned `V_me` or received). + pub persona_id: NodeId, + /// Index into `pub_post_set` / `wrap_slots` of the slot that + /// unlocked. Comments author by this persona will set + /// `InlineComment.pub_x_index` to this. + pub slot_index: u32, + /// Per-post shared CEK (unwrapped from the slot's read part). + pub cek: [u8; 32], + /// Ed25519 signing seed for the per-V_x keypair admitted to this + /// post (unwrapped from the slot's sign part). Used to sign + /// `group_sig` on FoF comments. + pub priv_x_seed: [u8; 32], +} + +/// Trial-decrypt the post's `fof_gating.wrap_slots` against every +/// persona on this device. Returns the first successful unlock found, +/// or `None` if no held V_x matches. +/// +/// Iteration order: personas as listed by storage; within each persona, +/// own current `V_me` first, then received V_x's. Slots are scanned in +/// order; the 2B prefilter lets us skip non-matching slots in O(1) per. +pub fn find_unlock_for_post( + storage: &Storage, + post: &crate::types::Post, +) -> Result> { + let Some(gating) = post.fof_gating.as_ref() else { return Ok(None); }; + let personas = storage.list_posting_identities()?; + for persona in &personas { + // Build this persona's V_x ring: own current + every received. + let mut keys: Vec<[u8; 32]> = Vec::new(); + if let Some((_, own_key)) = storage.current_own_vouch_key(&persona.node_id)? { + keys.push(own_key); + } + for (_owner, _epoch, key) in storage.list_received_vouch_keys(&persona.node_id)? { + keys.push(key); + } + for v_x in &keys { + let prefilter = crate::crypto::wrap_slot_prefilter_tag(v_x, &gating.slot_binder_nonce); + for (idx, slot) in gating.wrap_slots.iter().enumerate() { + if slot.prefilter_tag != prefilter { + continue; + } + if let Some(opened) = crate::crypto::open_wrap_slot( + v_x, + &gating.slot_binder_nonce, + &slot.read_ciphertext, + &slot.sign_ciphertext, + ) { + return Ok(Some(PostUnlock { + persona_id: persona.node_id, + slot_index: idx as u32, + cek: opened.cek, + priv_x_seed: opened.priv_x_seed, + })); + } + } + } + } + Ok(None) +} + +/// FoF Layer 2: inner plaintext encrypted under CEK_comments. Wrapped +/// inside [`crate::types::InlineComment::encrypted_payload`]. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct FoFCommentPayload { + /// User-visible comment body. + pub body: String, + /// Optional reply parent. + #[serde(default)] + pub parent_comment_id: Option<[u8; 32]>, + /// HMAC(V_x, post_id || comment_hash)[:16B] — author-side + /// attribution to a specific voucher-chain. Computed by the + /// commenter; not yet enforced in this commit. + #[serde(default)] + pub vouch_mac: Option<[u8; 16]>, +} + +/// Build a Mode 2 / Mode 1 FoF comment on a parent post. The post must +/// have `fof_gating = Some`; the caller must already hold a successful +/// `PostUnlock` for it (typically from [`find_unlock_for_post`]). +/// +/// Produces an `InlineComment` with empty `content` (the body lives +/// encrypted in `encrypted_payload`) + `pub_x_index` + `group_sig` + +/// `encrypted_payload`. The conventional `signature` field carries the +/// commenter's identity-key signature over the existing payload (so +/// non-FoF nodes can still verify identity without unwrapping the slot). +pub fn build_fof_comment( + parent_post_id: &[u8; 32], + unlock: &PostUnlock, + slot_binder_nonce: &[u8; 32], + commenter_id: &NodeId, + commenter_secret: &[u8; 32], + body: &str, + parent_comment_id: Option<[u8; 32]>, + now_ms: u64, +) -> Result { + use ed25519_dalek::{Signer, SigningKey}; + + // Encrypt the comment body under CEK_comments derived from the + // post's CEK + slot_binder_nonce. + let cek_comments = crate::crypto::derive_cek_comments(&unlock.cek, slot_binder_nonce); + let payload = FoFCommentPayload { + body: body.to_string(), + parent_comment_id, + vouch_mac: None, + }; + let plaintext = serde_json::to_vec(&payload) + .map_err(|e| anyhow::anyhow!("serialize fof comment payload: {}", e))?; + let encrypted = crate::crypto::encrypt_bytes_with_cek(&plaintext, &cek_comments)?; + + // Sign (encrypted || post_id || pub_x_index_le) with priv_x. + let priv_x_signer = SigningKey::from_bytes(&unlock.priv_x_seed); + let mut to_sign = Vec::with_capacity(encrypted.len() + 32 + 4); + to_sign.extend_from_slice(&encrypted); + to_sign.extend_from_slice(parent_post_id); + to_sign.extend_from_slice(&unlock.slot_index.to_le_bytes()); + let group_sig = priv_x_signer.sign(&to_sign).to_bytes().to_vec(); + + // Conventional InlineComment.signature: commenter's identity-key + // signature over the existing comment_signature tuple. We use empty + // content here (the body lives in encrypted_payload). Existing + // non-FoF receivers can still verify the identity sig and tombstone + // logic continues to work. + let identity_signature = crate::crypto::sign_comment( + commenter_secret, + commenter_id, + parent_post_id, + "", + now_ms, + None, + ); + + Ok(crate::types::InlineComment { + author: *commenter_id, + post_id: *parent_post_id, + content: String::new(), + timestamp_ms: now_ms, + signature: identity_signature, + deleted_at: None, + ref_post_id: None, + pub_x_index: Some(unlock.slot_index), + group_sig: Some(group_sig), + encrypted_payload: Some(encrypted), + }) +} + +/// Verify the `group_sig` on an incoming FoF comment against the post's +/// `pub_post_set`. Used by the CDN four-check accept rule (next slice). +/// Returns `true` iff the comment carries a valid Ed25519 signature +/// under `pub_post_set[pub_x_index]` over +/// (encrypted_payload || post_id || pub_x_index_le). +pub fn verify_fof_group_sig( + comment: &crate::types::InlineComment, + gating: &FoFCommentGating, +) -> bool { + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + let Some(pub_x_index) = comment.pub_x_index else { return false; }; + let Some(group_sig) = comment.group_sig.as_ref() else { return false; }; + let Some(encrypted_payload) = comment.encrypted_payload.as_ref() else { return false; }; + let idx = pub_x_index as usize; + if idx >= gating.pub_post_set.len() { return false; } + if group_sig.len() != 64 { return false; } + let pub_x = &gating.pub_post_set[idx]; + let Ok(verifying_key) = VerifyingKey::from_bytes(pub_x) else { return false; }; + let sig_bytes: [u8; 64] = match group_sig.as_slice().try_into() { + Ok(b) => b, Err(_) => return false, + }; + let sig = Signature::from_bytes(&sig_bytes); + let mut to_verify = Vec::with_capacity(encrypted_payload.len() + 32 + 4); + to_verify.extend_from_slice(encrypted_payload); + to_verify.extend_from_slice(&comment.post_id); + to_verify.extend_from_slice(&pub_x_index.to_le_bytes()); + verifying_key.verify(&to_verify, &sig).is_ok() +} + +/// Decrypt the `encrypted_payload` of an FoF comment back to its +/// plaintext body / vouch_mac / parent_comment_id, using the CEK +/// recovered via [`find_unlock_for_post`]. +pub fn decrypt_fof_comment_payload( + comment: &crate::types::InlineComment, + cek: &[u8; 32], + slot_binder_nonce: &[u8; 32], +) -> Result { + let encrypted = comment.encrypted_payload.as_ref() + .ok_or_else(|| anyhow::anyhow!("comment has no encrypted_payload"))?; + let cek_comments = crate::crypto::derive_cek_comments(cek, slot_binder_nonce); + let plaintext = crate::crypto::decrypt_bytes_with_cek(encrypted, &cek_comments)?; + serde_json::from_slice(&plaintext) + .map_err(|e| anyhow::anyhow!("deserialize fof comment payload: {}", e)) +} + #[cfg(test)] mod tests { use super::*; @@ -282,4 +483,104 @@ mod tests { rand::rng().fill_bytes(&mut seed); let _ = ed25519_seed_to_x25519_private(&seed); } + + /// End-to-end FoF comment roundtrip: + /// 1. Alice authors a Mode 2 FoF post (her keyring: own V_me + Bob's V_x). + /// 2. A "receiver device" (Bob's) holds Bob's V_x. It finds the unlock. + /// 3. Bob authors a comment on Alice's post. + /// 4. The comment's group_sig verifies against the post's pub_post_set. + /// 5. Alice (using her cached cek) decrypts Bob's comment plaintext. + #[test] + fn fof_comment_authoring_roundtrip() { + use crate::types::PostingIdentity; + use ed25519_dalek::SigningKey; + + // Alice's device: has her V_me and Bob's V_x (received from Bob). + let alice_storage = temp_storage(); + let (alice_id, alice_seed) = make_persona(1); + alice_storage.upsert_posting_identity(&PostingIdentity { + node_id: alice_id, secret_seed: alice_seed, + display_name: "Alice".into(), created_at: 1000, + }).unwrap(); + let mut v_me_alice = [0u8; 32]; + rand::rng().fill_bytes(&mut v_me_alice); + alice_storage.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap(); + + let (bob_id, _) = make_persona(2); + let mut v_x_bob = [0u8; 32]; + rand::rng().fill_bytes(&mut v_x_bob); + alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap(); + + // Alice publishes the gating block. + let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built"); + let parent_post_id = [0xCC; 32]; + + // Bob's device: has his V_me (which is v_x_bob since he handed it out). + let bob_storage = temp_storage(); + let (bob_id_, bob_seed) = make_persona(2); + assert_eq!(bob_id, bob_id_); + bob_storage.upsert_posting_identity(&PostingIdentity { + node_id: bob_id, secret_seed: bob_seed, + display_name: "Bob".into(), created_at: 1500, + }).unwrap(); + bob_storage.insert_own_vouch_key(&bob_id, 1, &v_x_bob, 1500).unwrap(); + + // Wrap Alice's published gating into a Post struct as it would + // appear on the wire. + let post = crate::types::Post { + author: alice_id, + content: "alice's public post".into(), + attachments: vec![], + timestamp_ms: 3000, + fof_gating: Some(built.gating.clone()), + }; + + // Bob's device unlocks the post via his V_me (= v_x_bob). + let unlock = find_unlock_for_post(&bob_storage, &post).unwrap() + .expect("Bob's persona must unlock the post"); + assert_eq!(unlock.persona_id, bob_id); + assert_eq!(unlock.cek, built.cek, "Bob recovers Alice's CEK"); + + // Bob authors a comment. + let comment = build_fof_comment( + &parent_post_id, + &unlock, + &built.slot_binder_nonce, + &bob_id, + &bob_seed, + "great post alice", + None, + 4000, + ).unwrap(); + assert!(comment.content.is_empty(), "FoF comment body is encrypted, not in content"); + assert!(comment.pub_x_index.is_some()); + assert!(comment.group_sig.is_some()); + assert!(comment.encrypted_payload.is_some()); + + // CDN-level verification: group_sig validates against pub_post_set. + assert!(verify_fof_group_sig(&comment, &built.gating), + "valid FoF comment must pass CDN four-check (group_sig leg)"); + + // Tamper detection: flip a byte in encrypted_payload and re-verify. + let mut tampered = comment.clone(); + let mut payload = tampered.encrypted_payload.clone().unwrap(); + payload[0] ^= 0x01; + tampered.encrypted_payload = Some(payload); + assert!(!verify_fof_group_sig(&tampered, &built.gating), + "tampered encrypted_payload must invalidate group_sig"); + + // Tamper detection: claim a different pub_x_index. + let mut wrong_idx = comment.clone(); + let bad_idx = (comment.pub_x_index.unwrap() + 1) % (built.gating.pub_post_set.len() as u32); + wrong_idx.pub_x_index = Some(bad_idx); + assert!(!verify_fof_group_sig(&wrong_idx, &built.gating), + "wrong pub_x_index must invalidate group_sig"); + + // Alice (using her cached CEK) decrypts Bob's comment payload. + let plaintext = decrypt_fof_comment_payload(&comment, &built.cek, &built.slot_binder_nonce) + .expect("Alice decrypts FoF comment"); + assert_eq!(plaintext.body, "great post alice"); + + let _signing_key = SigningKey::from_bytes(&unlock.priv_x_seed); // exercise the import + } } From 63ff5ad6eb159709b20b308130a60aaae5c0315e Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 14:06:34 -0400 Subject: [PATCH 20/34] feat(fof-layer2): CDN four-check verification on incoming FoF comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the propagation-side accept rule per docs/fof-spec/layer-2-mode2-fof-comments.md. When a BlobHeaderDiffOp:: AddComment arrives for a post whose CommentPolicy.allow_comments is FriendsOfFriends, the receive path now: 1. Looks up the parent post in storage. If the post lacks fof_gating, drop (policy says FoF but no key material to verify against). 2. Calls fof::verify_fof_group_sig (which folds together: valid pub_x_index range + Ed25519 verify of group_sig against pub_post_set[pub_x_index] over the binding tuple). 3. Checks pub_post_set[pub_x_index] is NOT in fof_gating.revocation_list (initially empty; revocation diffs land in a future slice but the check is in place now). 4. Continues to the existing identity_sig verify step. Any failure → continue (drop, don't store, don't forward). This kills the bandwidth-amplification DoS that a single admitted FoF member could otherwise mount by spamming forged group_sigs. Receive-side storage of FoF comments is via the existing storage.store_comment call; the InlineComment shape carries the FoF fields (pub_x_index, group_sig, encrypted_payload) through unchanged. 139 tests pass (relay_cooldown flake is pre-existing and unrelated). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/connection.rs | 46 ++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index e7b1fb5..2c27307 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -6172,10 +6172,48 @@ impl ConnectionManager { } crate::types::CommentPermission::Public => {} crate::types::CommentPermission::FriendsOfFriends => { - // FoF four-check verification gate lives - // in a future slice; for now treat as - // "drop until verified" (safest default). - continue; + // FoF Layer 2 CDN four-check accept rule: + // 1. parent post must carry fof_gating + // (otherwise the policy is ambient + // with no key material to verify); + // 2. pub_x_index must point at a real + // entry in pub_post_set; + // 3. group_sig must validate against + // pub_post_set[pub_x_index]; + // 4. revocation_list must not contain + // pub_post_set[pub_x_index]; + // 5. identity_sig (existing comment + // signature field) verified below. + // + // Failures drop the comment without + // forwarding — kills the bandwidth-DoS + // attack an admitted-but-malicious FoF + // member could otherwise mount. + let parent = match storage.get_post(&payload.post_id) { + Ok(Some(p)) => p, + _ => continue, + }; + let Some(gating) = parent.fof_gating.as_ref() else { continue; }; + if !crate::fof::verify_fof_group_sig(comment, gating) { + continue; + } + // Revocation check (step 4). The + // revocation_list on the post's stored + // copy is the on-publish snapshot; + // revocation diffs that arrive later + // are applied against the local + // BlobHeader copy (separate slice). + if let Some(idx) = comment.pub_x_index { + let pub_x = gating.pub_post_set + .get(idx as usize) + .copied(); + if let Some(pub_x) = pub_x { + if gating.revocation_list.iter() + .any(|r| r.revoked_pub_x == pub_x) { + continue; + } + } + } } } if !crate::crypto::verify_comment_signature( From 583033e065d54f94a574e142114b0355ed507e33 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 14:51:02 -0400 Subject: [PATCH 21/34] feat(fof-layer2): persist FoF fields + revocation table Storage now persists the FoF Layer 2 wire fields across restarts: posts.fof_gating_json - JSON-serialized FoFCommentGating, added via migration to existing DBs. get_post / store_post / store_post_with_visibility / store_post_with_intent all roundtrip the field. comments.{pub_x_index, group_sig, encrypted_payload} - Added via migration. store_comment writes them; get_comments reads them back. Old non-FoF comments deserialize to None for all three. fof_revocations (new table) - (post_id, revoked_pub_x, revoked_at_ms, reason_code, author_sig) - Local live state. Distinct from the post's snapshot fof_gating.revocation_list (rarely populated on publish). New storage methods: - add_fof_revocation(...): idempotent insert. - is_fof_pub_x_revoked(post_id, pub_x): cheap COUNT for CDN-verify. - list_fof_revocations(post_id): batch read. - delete_fof_comments_by_pub_x_index(post_id, pub_x_index): cascade delete for the receive-side revocation handler (next slice). CDN four-check gate in connection.rs now consults BOTH the post's snapshot revocation_list and the live fof_revocations table. 139 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/connection.rs | 15 +-- crates/core/src/storage.rs | 196 +++++++++++++++++++++++++++++++--- 2 files changed, 189 insertions(+), 22 deletions(-) diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 2c27307..926adcd 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -6197,12 +6197,12 @@ impl ConnectionManager { if !crate::fof::verify_fof_group_sig(comment, gating) { continue; } - // Revocation check (step 4). The - // revocation_list on the post's stored - // copy is the on-publish snapshot; - // revocation diffs that arrive later - // are applied against the local - // BlobHeader copy (separate slice). + // Revocation check (step 4). Two + // sources: the post's snapshot + // revocation_list (rarely populated on + // publish) and the live local table + // fof_revocations (accumulated as + // revocation diffs arrive on the wire). if let Some(idx) = comment.pub_x_index { let pub_x = gating.pub_post_set .get(idx as usize) @@ -6212,6 +6212,9 @@ impl ConnectionManager { .any(|r| r.revoked_pub_x == pub_x) { continue; } + if storage.is_fof_pub_x_revoked(&payload.post_id, &pub_x).unwrap_or(false) { + continue; + } } } } diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index b9452ea..95d0eea 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -361,6 +361,11 @@ impl Storage { timestamp_ms INTEGER NOT NULL, signature BLOB NOT NULL, ref_post_id BLOB, + -- FoF Layer 2: optional comment fields (NULL on non-FoF) + pub_x_index INTEGER, + group_sig BLOB, + encrypted_payload BLOB, + deleted_at INTEGER, PRIMARY KEY (author, post_id, timestamp_ms) ); CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id); @@ -469,7 +474,23 @@ impl Storage { granted_at_ms INTEGER NOT NULL, current INTEGER NOT NULL DEFAULT 1, PRIMARY KEY (voucher_persona_id, target_persona_id) - );", + ); + -- FoF Layer 2: per-post revocations applied locally as + -- revocation diffs propagate through the CDN. The post's + -- own fof_gating.revocation_list is the t=0 snapshot + -- (usually empty); this table is the live accumulated + -- state. CDN-verify rejects any comment whose + -- pub_post_set[pub_x_index] appears here for this post. + CREATE TABLE IF NOT EXISTS fof_revocations ( + post_id BLOB NOT NULL, + revoked_pub_x BLOB NOT NULL, + revoked_at_ms INTEGER NOT NULL, + reason_code INTEGER NOT NULL DEFAULT 0, + author_sig BLOB NOT NULL, + PRIMARY KEY (post_id, revoked_pub_x) + ); + CREATE INDEX IF NOT EXISTS idx_fof_revocations_post + ON fof_revocations(post_id);", )?; Ok(()) } @@ -843,6 +864,32 @@ impl Storage { // 0.6.2-beta: seed post_recipients index from existing encrypted posts. self.seed_post_recipients_from_posts()?; + // FoF Layer 2: add comment columns for pub_x_index / group_sig / + // encrypted_payload. Old DBs have NULL → deserializes to None. + let has_comment_pub_x = self.conn.prepare( + "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='pub_x_index'" + )?.query_row([], |row| row.get::<_, i64>(0))?; + if has_comment_pub_x == 0 { + self.conn.execute_batch( + "ALTER TABLE comments ADD COLUMN pub_x_index INTEGER; + ALTER TABLE comments ADD COLUMN group_sig BLOB; + ALTER TABLE comments ADD COLUMN encrypted_payload BLOB;" + )?; + } + + // FoF Layer 2: post.fof_gating is serialized as JSON in a new + // column so we can rehydrate the gating block on receive paths + // (CDN verify needs pub_post_set / revocation_list). Stored + // alongside the existing post fields. + let has_post_fof = self.conn.prepare( + "SELECT COUNT(*) FROM pragma_table_info('posts') WHERE name='fof_gating_json'" + )?.query_row([], |row| row.get::<_, i64>(0))?; + if has_post_fof == 0 { + self.conn.execute_batch( + "ALTER TABLE posts ADD COLUMN fof_gating_json TEXT;" + )?; + } + Ok(()) } @@ -862,8 +909,13 @@ impl Storage { ) -> anyhow::Result { let attachments_json = serde_json::to_string(&post.attachments)?; let visibility_json = serde_json::to_string(visibility)?; + let fof_json = match &post.fof_gating { + Some(g) => Some(serde_json::to_string(g)?), + None => None, + }; let inserted = self.conn.execute( - "INSERT OR IGNORE INTO posts (id, author, content, attachments, timestamp_ms, visibility) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + "INSERT OR IGNORE INTO posts (id, author, content, attachments, timestamp_ms, visibility, fof_gating_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ id.as_slice(), post.author.as_slice(), @@ -871,6 +923,7 @@ impl Storage { attachments_json, post.timestamp_ms as i64, visibility_json, + fof_json, ], )?; if inserted > 0 { @@ -880,20 +933,24 @@ impl Storage { } pub fn get_post(&self, id: &PostId) -> anyhow::Result> { - let mut stmt = self - .conn - .prepare("SELECT author, content, attachments, timestamp_ms FROM posts WHERE id = ?1")?; + let mut stmt = self.conn.prepare( + "SELECT author, content, attachments, timestamp_ms, fof_gating_json + FROM posts WHERE id = ?1", + )?; let mut rows = stmt.query(params![id.as_slice()])?; if let Some(row) = rows.next()? { let attachments: Vec = serde_json::from_str( &row.get::<_, String>(2)? ).unwrap_or_default(); + let fof_json: Option = row.get(4)?; + let fof_gating = fof_json + .and_then(|s| serde_json::from_str::(&s).ok()); Ok(Some(Post { author: blob_to_nodeid(row.get(0)?)?, content: row.get(1)?, attachments, timestamp_ms: row.get::<_, i64>(3)? as u64, - fof_gating: None, + fof_gating, })) } else { Ok(None) @@ -2845,8 +2902,14 @@ impl Storage { let attachments_json = serde_json::to_string(&post.attachments)?; let visibility_json = serde_json::to_string(visibility)?; let intent_json = serde_json::to_string(intent)?; + let fof_json = match &post.fof_gating { + Some(g) => Some(serde_json::to_string(g)?), + None => None, + }; let inserted = self.conn.execute( - "INSERT OR IGNORE INTO posts (id, author, content, attachments, timestamp_ms, visibility, visibility_intent) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + "INSERT OR IGNORE INTO posts + (id, author, content, attachments, timestamp_ms, visibility, visibility_intent, fof_gating_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ id.as_slice(), post.author.as_slice(), @@ -2855,6 +2918,7 @@ impl Storage { post.timestamp_ms as i64, visibility_json, intent_json, + fof_json, ], )?; if inserted > 0 { @@ -4960,6 +5024,94 @@ impl Storage { Ok(()) } + /// FoF Layer 2: record a post-level revocation locally. Idempotent + /// on `(post_id, revoked_pub_x)`. Subsequent incoming comments + /// where `pub_post_set[pub_x_index] == revoked_pub_x` are rejected + /// at the CDN verify step (see `is_fof_pub_x_revoked`). + /// + /// Retroactive delete of already-stored comments under the revoked + /// pub_x is handled by `delete_fof_comments_by_pub_x`, which the + /// receive path calls after applying the revocation (it has the + /// post + pub_post_set context required to resolve pub_x_index → + /// pub_x bytes). + pub fn add_fof_revocation( + &self, + post_id: &PostId, + revoked_pub_x: &[u8; 32], + revoked_at_ms: u64, + reason_code: u8, + author_sig: &[u8], + ) -> anyhow::Result<()> { + self.conn.execute( + "INSERT OR IGNORE INTO fof_revocations + (post_id, revoked_pub_x, revoked_at_ms, reason_code, author_sig) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + post_id.as_slice(), + revoked_pub_x.as_slice(), + revoked_at_ms as i64, + reason_code as i64, + author_sig, + ], + )?; + Ok(()) + } + + /// FoF Layer 2: delete locally-stored comments on a post whose + /// `pub_x_index` matches the given index. Returns the number of + /// rows deleted. Called by the receive path after applying a + /// revocation (the index → pub_x_bytes resolution happens in the + /// caller via `pub_post_set`). + pub fn delete_fof_comments_by_pub_x_index( + &self, + post_id: &PostId, + pub_x_index: u32, + ) -> anyhow::Result { + let n = self.conn.execute( + "DELETE FROM comments + WHERE post_id = ?1 AND pub_x_index = ?2", + params![post_id.as_slice(), pub_x_index as i64], + )?; + Ok(n) + } + + /// FoF Layer 2: is the given pub_x revoked for this post? + pub fn is_fof_pub_x_revoked( + &self, + post_id: &PostId, + pub_x: &[u8; 32], + ) -> anyhow::Result { + let n: i64 = self.conn.prepare( + "SELECT COUNT(*) FROM fof_revocations + WHERE post_id = ?1 AND revoked_pub_x = ?2", + )?.query_row( + params![post_id.as_slice(), pub_x.as_slice()], + |row| row.get(0), + )?; + Ok(n > 0) + } + + /// FoF Layer 2: list all revoked pub_x's for a post (for the CDN + /// four-check; cheaper than a per-comment query when verifying many + /// comments in a row). + pub fn list_fof_revocations(&self, post_id: &PostId) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT revoked_pub_x FROM fof_revocations WHERE post_id = ?1", + )?; + let rows = stmt.query_map(params![post_id.as_slice()], |row| { + let b: Vec = row.get(0)?; + Ok(b) + })?; + let mut out = Vec::new(); + for r in rows { + let b = r?; + let arr: [u8; 32] = b.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid revoked_pub_x in storage"))?; + out.push(arr); + } + Ok(out) + } + /// Increment and return the next bio-publish epoch for a persona. /// Counter is monotonic; used by receivers' scan cache to short-circuit /// re-scanning unchanged bios. Stored in `settings` keyed by persona. @@ -5240,12 +5392,17 @@ impl Storage { /// deleted_at tombstone, store it so the tombstone propagates. pub fn store_comment(&self, comment: &InlineComment) -> anyhow::Result<()> { self.conn.execute( - "INSERT INTO comments (author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + "INSERT INTO comments + (author, post_id, content, timestamp_ms, signature, deleted_at, + ref_post_id, pub_x_index, group_sig, encrypted_payload) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) ON CONFLICT(author, post_id, timestamp_ms) DO UPDATE SET content = CASE WHEN excluded.deleted_at IS NOT NULL THEN content ELSE excluded.content END, deleted_at = CASE WHEN excluded.deleted_at IS NOT NULL THEN excluded.deleted_at ELSE deleted_at END, - ref_post_id = COALESCE(excluded.ref_post_id, ref_post_id)", + ref_post_id = COALESCE(excluded.ref_post_id, ref_post_id), + pub_x_index = COALESCE(excluded.pub_x_index, pub_x_index), + group_sig = COALESCE(excluded.group_sig, group_sig), + encrypted_payload = COALESCE(excluded.encrypted_payload, encrypted_payload)", params![ comment.author.as_slice(), comment.post_id.as_slice(), @@ -5254,6 +5411,9 @@ impl Storage { comment.signature, comment.deleted_at.map(|v| v as i64), comment.ref_post_id.as_ref().map(|r| r.as_slice()), + comment.pub_x_index.map(|i| i as i64), + comment.group_sig.as_ref().map(|b| b.as_slice()), + comment.encrypted_payload.as_ref().map(|b| b.as_slice()), ], )?; Ok(()) @@ -5280,7 +5440,8 @@ impl Storage { /// Get live (non-tombstoned) comments for a post. Used for UI display. pub fn get_comments(&self, post_id: &PostId) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT author, post_id, content, timestamp_ms, signature, ref_post_id + "SELECT author, post_id, content, timestamp_ms, signature, ref_post_id, + pub_x_index, group_sig, encrypted_payload FROM comments WHERE post_id = ?1 AND deleted_at IS NULL ORDER BY timestamp_ms ASC" )?; let rows = stmt.query_map(params![post_id.as_slice()], |row| { @@ -5290,11 +5451,14 @@ impl Storage { let ts: i64 = row.get(3)?; let sig: Vec = row.get(4)?; let ref_post: Option> = row.get(5)?; - Ok((author, pid, content, ts, sig, ref_post)) + let pxi: Option = row.get(6)?; + let gsig: Option> = row.get(7)?; + let epl: Option> = row.get(8)?; + Ok((author, pid, content, ts, sig, ref_post, pxi, gsig, epl)) })?; let mut result = Vec::new(); for row in rows { - let (author_bytes, pid_bytes, content, ts, sig, ref_post) = row?; + let (author_bytes, pid_bytes, content, ts, sig, ref_post, pxi, gsig, epl) = row?; let author = blob_to_nodeid(author_bytes)?; let post_id = blob_to_postid(pid_bytes)?; let ref_post_id = match ref_post { @@ -5309,9 +5473,9 @@ impl Storage { signature: sig, deleted_at: None, ref_post_id, - pub_x_index: None, - group_sig: None, - encrypted_payload: None, + pub_x_index: pxi.map(|v| v as u32), + group_sig: gsig, + encrypted_payload: epl, }); } Ok(result) From 6a76adef8f8d36d902d158326434243f1652c01c Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 14:59:23 -0400 Subject: [PATCH 22/34] =?UTF-8?q?feat(fof-layer2):=20revocation=20diff=20?= =?UTF-8?q?=E2=80=94=20sign=20+=20verify=20+=20propagate=20+=20cascade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the full revocation primitive end-to-end: Wire format: - BlobHeaderDiffOp::FoFRevocation { post_id, revoked_pub_x, revoked_at_ms, reason_code, author_sig }. 64-byte Ed25519 sig by post author over (post_id || revoked_pub_x || ms_le || reason). fof.rs additions: - sign_fof_revocation(author_secret, ...): builds the canonical signing tuple and signs. - verify_fof_revocation(post_author, ...): Ed25519 verify; false on any shape/key/sig failure. CDN-verified before any side effect. - apply_fof_revocation_locally(storage, ...): records in fof_revocations + cascades retroactive delete of locally-stored comments matching the revoked pub_x via pub_post_set lookup. Receive path (connection.rs): new arm for FoFRevocation diffs. Looks up post.author from storage, verifies author_sig (rejects diffs where payload.author != post author or sig invalid), then applies locally. Propagation continues via existing mechanism. Author API (node.rs): Node::revoke_fof_commenter(post_id, pub_x_index, reason_code) resolves pub_x from gating.pub_post_set, signs with the persona's identity secret, applies locally for immediate UI update, then propagates via propagate_engagement_diff. Two new fof tests bring the suite to 141 passing: - fof_revocation_cascades: full author → publish → commenter → revoke → cascade-delete + recorded-in-storage roundtrip. - fof_revocation_wrong_author_rejected: Mallory signs claiming Alice's authorship → verify rejects. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/connection.rs | 22 ++++ crates/core/src/fof.rs | 195 ++++++++++++++++++++++++++++++++++ crates/core/src/node.rs | 64 +++++++++++ crates/core/src/types.rs | 13 +++ 4 files changed, 294 insertions(+) diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 926adcd..11f262e 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -6248,6 +6248,28 @@ impl ConnectionManager { let _ = storage.set_comment_policy(&payload.post_id, new_policy); } } + BlobHeaderDiffOp::FoFRevocation { + post_id, revoked_pub_x, revoked_at_ms, reason_code, author_sig, + } => { + // Verify author identity signature before applying. + // payload.author is the engagement-diff sender; the + // post's real author lives in storage. + let post_author = match storage.get_post(post_id) { + Ok(Some(p)) => p.author, + _ => continue, + }; + if !crate::fof::verify_fof_revocation( + &post_author, post_id, revoked_pub_x, + *revoked_at_ms, *reason_code, author_sig, + ) { + continue; + } + // Apply: record + cascade-delete stored comments. + let _ = crate::fof::apply_fof_revocation_locally( + &storage, post_id, revoked_pub_x, + *revoked_at_ms, *reason_code, author_sig, + ); + } BlobHeaderDiffOp::ThreadSplit { new_post_id } => { let _ = storage.store_thread_meta(&crate::types::ThreadMeta { post_id: *new_post_id, diff --git a/crates/core/src/fof.rs b/crates/core/src/fof.rs index 1aa6151..5cf167c 100644 --- a/crates/core/src/fof.rs +++ b/crates/core/src/fof.rs @@ -341,6 +341,90 @@ pub fn decrypt_fof_comment_payload( .map_err(|e| anyhow::anyhow!("deserialize fof comment payload: {}", e)) } +// --- Revocation: sign + verify + apply --- + +/// Bytes covered by a `BlobHeaderDiffOp::FoFRevocation.author_sig`. +/// Constructed identically on both ends so the verify is deterministic. +fn fof_revocation_signing_bytes( + post_id: &[u8; 32], + revoked_pub_x: &[u8; 32], + revoked_at_ms: u64, + reason_code: u8, +) -> [u8; 32 + 32 + 8 + 1] { + let mut out = [0u8; 32 + 32 + 8 + 1]; + out[..32].copy_from_slice(post_id); + out[32..64].copy_from_slice(revoked_pub_x); + out[64..72].copy_from_slice(&revoked_at_ms.to_le_bytes()); + out[72] = reason_code; + out +} + +/// Author-side: sign a revocation entry with the post author's +/// identity secret. Returns the 64-byte Ed25519 signature. +pub fn sign_fof_revocation( + author_secret: &[u8; 32], + post_id: &[u8; 32], + revoked_pub_x: &[u8; 32], + revoked_at_ms: u64, + reason_code: u8, +) -> Vec { + use ed25519_dalek::{Signer, SigningKey}; + let signing_key = SigningKey::from_bytes(author_secret); + let bytes = fof_revocation_signing_bytes(post_id, revoked_pub_x, revoked_at_ms, reason_code); + signing_key.sign(&bytes).to_bytes().to_vec() +} + +/// CDN-side: verify a revocation entry's author_sig against the post +/// author's public key. Returns `false` on any shape/key/signature +/// failure. +pub fn verify_fof_revocation( + post_author: &NodeId, + post_id: &[u8; 32], + revoked_pub_x: &[u8; 32], + revoked_at_ms: u64, + reason_code: u8, + author_sig: &[u8], +) -> bool { + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + if author_sig.len() != 64 { return false; } + let sig_bytes: [u8; 64] = match author_sig.try_into() { + Ok(b) => b, Err(_) => return false, + }; + let sig = Signature::from_bytes(&sig_bytes); + let Ok(verifying_key) = VerifyingKey::from_bytes(post_author) else { return false; }; + let bytes = fof_revocation_signing_bytes(post_id, revoked_pub_x, revoked_at_ms, reason_code); + verifying_key.verify(&bytes, &sig).is_ok() +} + +/// Apply a verified revocation to local storage + cascade delete +/// already-stored comments. Idempotent on `(post_id, revoked_pub_x)`. +/// Returns the count of comments deleted. +/// +/// Must only be called after `verify_fof_revocation` returns true. +/// The caller (CDN receive path) is responsible for that gate. +pub fn apply_fof_revocation_locally( + storage: &Storage, + post_id: &[u8; 32], + revoked_pub_x: &[u8; 32], + revoked_at_ms: u64, + reason_code: u8, + author_sig: &[u8], +) -> Result { + storage.add_fof_revocation(post_id, revoked_pub_x, revoked_at_ms, reason_code, author_sig)?; + + // Resolve pub_x -> pub_x_index via the post's pub_post_set, then + // cascade-delete locally-stored comments matching that index. + let Some(post) = storage.get_post(post_id)? else { return Ok(0); }; + let Some(gating) = post.fof_gating.as_ref() else { return Ok(0); }; + let mut deleted = 0; + for (idx, pub_x) in gating.pub_post_set.iter().enumerate() { + if pub_x == revoked_pub_x { + deleted += storage.delete_fof_comments_by_pub_x_index(post_id, idx as u32)?; + } + } + Ok(deleted) +} + #[cfg(test)] mod tests { use super::*; @@ -583,4 +667,115 @@ mod tests { let _signing_key = SigningKey::from_bytes(&unlock.priv_x_seed); // exercise the import } + + /// Revocation roundtrip: author publishes a Mode 2 FoF post, Bob + /// comments, author signs a revocation, apply_fof_revocation_locally + /// records it + cascade-deletes Bob's comment. + #[test] + fn fof_revocation_cascades() { + use crate::types::PostingIdentity; + + let s = temp_storage(); + let (alice_id, alice_seed) = make_persona(33); + s.upsert_posting_identity(&PostingIdentity { + node_id: alice_id, secret_seed: alice_seed, + display_name: "Alice".into(), created_at: 1000, + }).unwrap(); + let mut v_me_alice = [0u8; 32]; + rand::rng().fill_bytes(&mut v_me_alice); + s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap(); + + let (bob_id, bob_seed) = make_persona(44); + let mut v_x_bob = [0u8; 32]; + rand::rng().fill_bytes(&mut v_x_bob); + s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap(); + + let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + let post_id = [0xDE; 32]; + + // Persist the post so apply_fof_revocation_locally can resolve + // pub_x → pub_x_index via the post's pub_post_set. + let post = crate::types::Post { + author: alice_id, content: "alice".into(), attachments: vec![], + timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), + }; + s.store_post_with_intent( + &post_id, &post, + &crate::types::PostVisibility::Public, + &crate::types::VisibilityIntent::Public, + ).unwrap(); + + // Bob unlocks via his V_x and authors a comment. Persist it + // through the public store_comment path so the cascade-delete + // has something to clean up. + let bob_unlock = PostUnlock { + persona_id: bob_id, + slot_index: built.gating.pub_post_set.iter().position(|p| { + // Find a slot Bob's V_x unlocks. + let opened = crate::crypto::open_wrap_slot( + &v_x_bob, &built.slot_binder_nonce, + &built.gating.wrap_slots[built.gating.pub_post_set.iter().position(|x| x == p).unwrap()].read_ciphertext, + &built.gating.wrap_slots[built.gating.pub_post_set.iter().position(|x| x == p).unwrap()].sign_ciphertext, + ); + opened.is_some() + }).expect("Bob's slot exists") as u32, + cek: built.cek, + priv_x_seed: { + // Re-derive by re-unwrapping. + let mut seed = [0u8; 32]; + for slot in &built.gating.wrap_slots { + if let Some(o) = crate::crypto::open_wrap_slot( + &v_x_bob, &built.slot_binder_nonce, + &slot.read_ciphertext, &slot.sign_ciphertext, + ) { seed = o.priv_x_seed; break; } + } + seed + }, + }; + let comment = build_fof_comment( + &post_id, &bob_unlock, &built.slot_binder_nonce, + &bob_id, &bob_seed, "hello", None, 4000, + ).unwrap(); + s.store_comment(&comment).unwrap(); + assert_eq!(s.get_comments(&post_id).unwrap().len(), 1, "Bob's comment stored"); + + // Resolve Bob's pub_x bytes from the gating's pub_post_set. + let bob_pub_x = built.gating.pub_post_set[bob_unlock.slot_index as usize]; + + // Author signs + applies revocation. + let revoked_at = 5000; + let sig = sign_fof_revocation(&alice_seed, &post_id, &bob_pub_x, revoked_at, 0); + assert!(verify_fof_revocation(&alice_id, &post_id, &bob_pub_x, revoked_at, 0, &sig)); + + let deleted = apply_fof_revocation_locally( + &s, &post_id, &bob_pub_x, revoked_at, 0, &sig, + ).unwrap(); + assert_eq!(deleted, 1, "Bob's comment retroactively deleted"); + assert!(s.get_comments(&post_id).unwrap().is_empty(), + "no live comments remain on the post"); + + // Verify revocation is recorded for future CDN-verify lookups. + assert!(s.is_fof_pub_x_revoked(&post_id, &bob_pub_x).unwrap()); + + // Idempotent: re-apply is a no-op (returns 0 because comment already gone). + let re_deleted = apply_fof_revocation_locally( + &s, &post_id, &bob_pub_x, revoked_at, 0, &sig, + ).unwrap(); + assert_eq!(re_deleted, 0); + } + + #[test] + fn fof_revocation_wrong_author_rejected() { + let post_id = [0x01; 32]; + let revoked_pub_x = [0x02; 32]; + let (alice_id, alice_seed) = make_persona(50); + let (mallory_id, mallory_seed) = make_persona(51); + + let sig = sign_fof_revocation(&mallory_seed, &post_id, &revoked_pub_x, 1000, 0); + // Mallory signed but claims Alice authored → reject. + assert!(!verify_fof_revocation(&alice_id, &post_id, &revoked_pub_x, 1000, 0, &sig)); + // Self-signed → accept. + assert!(verify_fof_revocation(&mallory_id, &post_id, &revoked_pub_x, 1000, 0, &sig)); + let _ = alice_seed; + } } diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 02940d6..c8f77eb 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -4753,6 +4753,70 @@ impl Node { Ok(()) } + /// FoF Layer 2: revoke a specific pub_x from a FoF-gated post the + /// caller authored. Builds a signed FoFRevocation diff, applies it + /// locally (record + cascade delete), and propagates via the + /// standard engagement-diff path. Idempotent. + /// + /// Caller passes the `pub_x_index` (from a stored comment they want + /// to revoke). The pub_x bytes are resolved via the post's + /// pub_post_set; if the post or index is missing, returns Err. + pub async fn revoke_fof_commenter( + &self, + post_id: PostId, + pub_x_index: u32, + reason_code: u8, + ) -> anyhow::Result<()> { + // Resolve pub_x bytes + confirm we authored the post. + let (post_author, posting_secret, revoked_pub_x) = { + let storage = self.storage.get().await; + let post = storage.get_post(&post_id)? + .ok_or_else(|| anyhow::anyhow!("post not found"))?; + let gating = post.fof_gating.as_ref() + .ok_or_else(|| anyhow::anyhow!("post is not FoF-gated"))?; + let pub_x = gating.pub_post_set.get(pub_x_index as usize).copied() + .ok_or_else(|| anyhow::anyhow!("pub_x_index out of bounds"))?; + let identity = storage.get_posting_identity(&post.author)? + .ok_or_else(|| anyhow::anyhow!("post author not on this device"))?; + (post.author, identity.secret_seed, pub_x) + }; + + let revoked_at_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let author_sig = crate::fof::sign_fof_revocation( + &posting_secret, &post_id, &revoked_pub_x, revoked_at_ms, reason_code, + ); + + // Apply locally first so the author's UI updates immediately. + { + let storage = self.storage.get().await; + let _ = crate::fof::apply_fof_revocation_locally( + &*storage, &post_id, &revoked_pub_x, revoked_at_ms, reason_code, &author_sig, + ); + } + + // Propagate the diff. + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let diff = crate::protocol::BlobHeaderDiffPayload { + post_id, + author: post_author, + ops: vec![crate::types::BlobHeaderDiffOp::FoFRevocation { + post_id, + revoked_pub_x, + revoked_at_ms, + reason_code, + author_sig, + }], + timestamp_ms: now, + }; + self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; + + Ok(()) + } + /// Get the comment policy for a post. pub async fn get_comment_policy(&self, post_id: PostId) -> anyhow::Result> { let storage = self.storage.get().await; diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index ec73145..0771a6e 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -1120,6 +1120,19 @@ pub enum BlobHeaderDiffOp { WriteCommentSlot { post_id: PostId, slot_index: u32, data: Vec }, /// Add new encrypted comment slots (each 256 bytes) AddCommentSlots { post_id: PostId, count: u32, slots: Vec> }, + /// FoF Layer 2: author revokes a pub_x from a FoF-gated post. + /// Propagation nodes verify author_sig, drop locally-stored + /// comments by the revoked pub_x, record the revocation, and + /// forward. Retroactive + idempotent. + FoFRevocation { + post_id: PostId, + revoked_pub_x: [u8; 32], + revoked_at_ms: u64, + reason_code: u8, + /// 64-byte ed25519 sig by post author over + /// (post_id || revoked_pub_x || revoked_at_ms_le || reason_code). + author_sig: Vec, + }, /// Unknown ops from newer protocol versions — silently ignored #[serde(other)] Unknown, From 96118d7ce88966087fc1d902227c32b6376f7231 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 15:07:54 -0400 Subject: [PATCH 23/34] =?UTF-8?q?feat(fof-layer2):=20access-grant=20?= =?UTF-8?q?=E2=80=94=20retroactive=20read+comment=20widening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the access-grant primitive end-to-end: Wire format: - BlobHeaderDiffOp::FoFAccessGrant { post_id, new_pub_x, new_wrap_slot, granted_at_ms, author_sig }. 64-byte Ed25519 sig by post author over canonicalized tuple. fof.rs: - sign_fof_access_grant / verify_fof_access_grant: identical shape to revocation but covers (pub_x, wrap_slot, granted_at). - apply_fof_access_grant_locally: appends to local pub_post_set + wrap_slots. Refuses to apply if new_pub_x is already revoked (prevents accidental re-admission of a previously-blocked signer per Layer 4 resolved decision). Idempotent on (post_id, new_pub_x). storage.rs: - append_fof_access_grant(post_id, new_pub_x, new_wrap_slot): mutates the stored post's fof_gating_json column to append the new entry. PostId (in id column) is unaffected — local-evolution semantics: the stored gating diverges from the original t=0 snapshot as access-grants and revocations land. connection.rs: receive arm verifies author_sig + applies locally. Author API (node.rs): - Node::grant_fof_access(post_id, new_v_x): recovers the post's CEK by trial-unwrapping the author's own slot (find_unlock_for_post), generates a fresh per-V_x keypair, seals a new wrap slot under new_v_x with the same CEK + slot_binder_nonce, signs the grant, applies locally for immediate UI, then propagates via propagate_engagement_diff. New test brings the suite to 142 passing: - fof_access_grant_appends_and_unlocks: pre-grant Carol cannot unlock; Alice grants; post-grant Carol unlocks and recovers the CEK; duplicate grant skipped; revoked pub_x cannot be re-admitted. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/connection.rs | 17 ++++ crates/core/src/fof.rs | 175 ++++++++++++++++++++++++++++++++++ crates/core/src/node.rs | 81 ++++++++++++++++ crates/core/src/storage.rs | 24 +++++ crates/core/src/types.rs | 13 +++ 5 files changed, 310 insertions(+) diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 11f262e..f70c9d0 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -6270,6 +6270,23 @@ impl ConnectionManager { *revoked_at_ms, *reason_code, author_sig, ); } + BlobHeaderDiffOp::FoFAccessGrant { + post_id, new_pub_x, new_wrap_slot, granted_at_ms, author_sig, + } => { + let post_author = match storage.get_post(post_id) { + Ok(Some(p)) => p.author, + _ => continue, + }; + if !crate::fof::verify_fof_access_grant( + &post_author, post_id, new_pub_x, new_wrap_slot, + *granted_at_ms, author_sig, + ) { + continue; + } + let _ = crate::fof::apply_fof_access_grant_locally( + &storage, post_id, new_pub_x, new_wrap_slot, + ); + } BlobHeaderDiffOp::ThreadSplit { new_post_id } => { let _ = storage.store_thread_meta(&crate::types::ThreadMeta { post_id: *new_post_id, diff --git a/crates/core/src/fof.rs b/crates/core/src/fof.rs index 5cf167c..6bc607d 100644 --- a/crates/core/src/fof.rs +++ b/crates/core/src/fof.rs @@ -396,6 +396,82 @@ pub fn verify_fof_revocation( verifying_key.verify(&bytes, &sig).is_ok() } +// --- Access-grant: sign + verify + apply --- + +/// Bytes covered by a `BlobHeaderDiffOp::FoFAccessGrant.author_sig`. +/// Wrap-slot is canonicalized by serializing its three fields in order: +/// prefilter_tag || read_ciphertext || sign_ciphertext. +fn fof_access_grant_signing_bytes( + post_id: &[u8; 32], + new_pub_x: &[u8; 32], + new_wrap_slot: &crate::types::WrapSlot, + granted_at_ms: u64, +) -> Vec { + let mut out = Vec::with_capacity(32 + 32 + 2 + 48 + 48 + 8); + out.extend_from_slice(post_id); + out.extend_from_slice(new_pub_x); + out.extend_from_slice(&new_wrap_slot.prefilter_tag); + out.extend_from_slice(&new_wrap_slot.read_ciphertext); + out.extend_from_slice(&new_wrap_slot.sign_ciphertext); + out.extend_from_slice(&granted_at_ms.to_le_bytes()); + out +} + +/// Author-side: sign an access-grant entry. +pub fn sign_fof_access_grant( + author_secret: &[u8; 32], + post_id: &[u8; 32], + new_pub_x: &[u8; 32], + new_wrap_slot: &crate::types::WrapSlot, + granted_at_ms: u64, +) -> Vec { + use ed25519_dalek::{Signer, SigningKey}; + let signing_key = SigningKey::from_bytes(author_secret); + let bytes = fof_access_grant_signing_bytes(post_id, new_pub_x, new_wrap_slot, granted_at_ms); + signing_key.sign(&bytes).to_bytes().to_vec() +} + +/// CDN-side: verify an access-grant entry's author_sig. +pub fn verify_fof_access_grant( + post_author: &NodeId, + post_id: &[u8; 32], + new_pub_x: &[u8; 32], + new_wrap_slot: &crate::types::WrapSlot, + granted_at_ms: u64, + author_sig: &[u8], +) -> bool { + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + if author_sig.len() != 64 { return false; } + let sig_bytes: [u8; 64] = match author_sig.try_into() { + Ok(b) => b, Err(_) => return false, + }; + let sig = Signature::from_bytes(&sig_bytes); + let Ok(verifying_key) = VerifyingKey::from_bytes(post_author) else { return false; }; + let bytes = fof_access_grant_signing_bytes(post_id, new_pub_x, new_wrap_slot, granted_at_ms); + verifying_key.verify(&bytes, &sig).is_ok() +} + +/// Apply a verified access-grant to local storage. Appends the new +/// (pub_x, wrap_slot) at the tail of the stored post's fof_gating. +/// Idempotent on `(post_id, new_pub_x)`. Must only be called after +/// `verify_fof_access_grant` returns true. +/// +/// Refuses to apply if `new_pub_x` is already in the post's +/// revocation_list (prevents accidental re-admission of a previously- +/// revoked signer; spec-resolved). +pub fn apply_fof_access_grant_locally( + storage: &Storage, + post_id: &[u8; 32], + new_pub_x: &[u8; 32], + new_wrap_slot: &crate::types::WrapSlot, +) -> Result { + if storage.is_fof_pub_x_revoked(post_id, new_pub_x)? { + return Ok(false); + } + let appended = storage.append_fof_access_grant(post_id, new_pub_x, new_wrap_slot)?; + Ok(appended) +} + /// Apply a verified revocation to local storage + cascade delete /// already-stored comments. Idempotent on `(post_id, revoked_pub_x)`. /// Returns the count of comments deleted. @@ -764,6 +840,105 @@ mod tests { assert_eq!(re_deleted, 0); } + /// Access-grant roundtrip: Alice publishes a Mode 2 FoF post, then + /// later vouches for Carol. Alice signs an access-grant adding + /// Carol's V_x to the post. apply_fof_access_grant_locally appends + /// the new slot; Carol's device can now unlock the post. + #[test] + fn fof_access_grant_appends_and_unlocks() { + use crate::types::PostingIdentity; + + let s = temp_storage(); + let (alice_id, alice_seed) = make_persona(60); + s.upsert_posting_identity(&PostingIdentity { + node_id: alice_id, secret_seed: alice_seed, + display_name: "Alice".into(), created_at: 1000, + }).unwrap(); + let mut v_me_alice = [0u8; 32]; + rand::rng().fill_bytes(&mut v_me_alice); + s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap(); + + // Initial gating: Alice only. + let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + let post_id = [0xBC; 32]; + let post = crate::types::Post { + author: alice_id, content: "alice".into(), attachments: vec![], + timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), + }; + s.store_post_with_intent( + &post_id, &post, + &crate::types::PostVisibility::Public, + &crate::types::VisibilityIntent::Public, + ).unwrap(); + + // Carol's V_x (Carol vouches for herself or is granted access). + let mut v_x_carol = [0u8; 32]; + rand::rng().fill_bytes(&mut v_x_carol); + + // Pre-grant: Carol can NOT unlock the post via her V_x. + let pre_post = s.get_post(&post_id).unwrap().unwrap(); + let pre_unlock = pre_post.fof_gating.as_ref() + .map(|g| g.wrap_slots.iter().any(|slot| { + crate::crypto::open_wrap_slot( + &v_x_carol, &g.slot_binder_nonce, + &slot.read_ciphertext, &slot.sign_ciphertext, + ).is_some() + })) + .unwrap_or(false); + assert!(!pre_unlock, "Carol cannot unlock pre-grant"); + + // Alice authors an access-grant: seal a new slot under Carol's V_x. + let mut new_priv_x_seed = [0u8; 32]; + rand::rng().fill_bytes(&mut new_priv_x_seed); + let new_pub_x = SigningKey::from_bytes(&new_priv_x_seed) + .verifying_key().to_bytes(); + let sealed = crate::crypto::seal_wrap_slot( + &v_x_carol, &built.slot_binder_nonce, &built.cek, &new_priv_x_seed, + ).unwrap(); + let new_wrap_slot = crate::types::WrapSlot { + prefilter_tag: sealed.prefilter_tag, + read_ciphertext: sealed.read_ciphertext, + sign_ciphertext: sealed.sign_ciphertext, + }; + + let granted_at = 5000; + let sig = sign_fof_access_grant( + &alice_seed, &post_id, &new_pub_x, &new_wrap_slot, granted_at, + ); + assert!(verify_fof_access_grant( + &alice_id, &post_id, &new_pub_x, &new_wrap_slot, granted_at, &sig, + )); + + let applied = apply_fof_access_grant_locally( + &s, &post_id, &new_pub_x, &new_wrap_slot, + ).unwrap(); + assert!(applied, "access-grant appended"); + + // Post-grant: stored post's gating now includes Carol's slot. + let post = s.get_post(&post_id).unwrap().unwrap(); + let g = post.fof_gating.as_ref().unwrap(); + let unlocked = g.wrap_slots.iter().any(|slot| { + crate::crypto::open_wrap_slot( + &v_x_carol, &g.slot_binder_nonce, + &slot.read_ciphertext, &slot.sign_ciphertext, + ).map(|o| o.cek == built.cek).unwrap_or(false) + }); + assert!(unlocked, "Carol can now unlock the post and recover Alice's CEK"); + + // Idempotent: re-applying the same grant is a no-op. + let again = apply_fof_access_grant_locally( + &s, &post_id, &new_pub_x, &new_wrap_slot, + ).unwrap(); + assert!(!again, "duplicate grant skipped"); + + // Revocation blocks re-admission. + s.add_fof_revocation(&post_id, &new_pub_x, 6000, 0, &[0u8; 64]).unwrap(); + let blocked = apply_fof_access_grant_locally( + &s, &post_id, &new_pub_x, &new_wrap_slot, + ).unwrap(); + assert!(!blocked, "revoked pub_x must not be re-granted access"); + } + #[test] fn fof_revocation_wrong_author_rejected() { let post_id = [0x01; 32]; diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index c8f77eb..0ab4d1a 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -4817,6 +4817,87 @@ impl Node { Ok(()) } + /// FoF Layer 2: retroactively widen read+comment access on a + /// FoF-gated post the caller authored by sealing a fresh wrap slot + /// under the given V_x and appending it to the post's gating. + /// Propagates as a `FoFAccessGrant` engagement-diff. + pub async fn grant_fof_access( + &self, + post_id: PostId, + new_v_x: &[u8; 32], + ) -> anyhow::Result<()> { + use ed25519_dalek::SigningKey; + use rand::RngCore; + + // Resolve post + author + cached CEK + slot_binder_nonce. The + // author must be on this device. + let (post_author, posting_secret, cek, slot_binder_nonce) = { + let storage = self.storage.get().await; + let post = storage.get_post(&post_id)? + .ok_or_else(|| anyhow::anyhow!("post not found"))?; + let gating = post.fof_gating.as_ref() + .ok_or_else(|| anyhow::anyhow!("post is not FoF-gated"))?; + let identity = storage.get_posting_identity(&post.author)? + .ok_or_else(|| anyhow::anyhow!("post author not on this device"))?; + + // Recover the CEK: try every V_x in the author persona's + // keyring against the post's slots. The author's own slot + // will unwrap and yield CEK. + let unlock = crate::fof::find_unlock_for_post(&*storage, &post)? + .ok_or_else(|| anyhow::anyhow!("could not recover CEK for own post"))?; + (post.author, identity.secret_seed, unlock.cek, gating.slot_binder_nonce) + }; + + // Generate a fresh (priv_x, pub_x) keypair, seal a wrap slot + // under the new V_x with the same CEK + slot_binder_nonce. + let mut seed = [0u8; 32]; + rand::rng().fill_bytes(&mut seed); + let signing_key = SigningKey::from_bytes(&seed); + let new_pub_x = *signing_key.verifying_key().as_bytes(); + + let sealed = crate::crypto::seal_wrap_slot(new_v_x, &slot_binder_nonce, &cek, &seed)?; + let new_wrap_slot = crate::types::WrapSlot { + prefilter_tag: sealed.prefilter_tag, + read_ciphertext: sealed.read_ciphertext, + sign_ciphertext: sealed.sign_ciphertext, + }; + + let granted_at_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let author_sig = crate::fof::sign_fof_access_grant( + &posting_secret, &post_id, &new_pub_x, &new_wrap_slot, granted_at_ms, + ); + + // Apply locally first. + { + let storage = self.storage.get().await; + let _ = crate::fof::apply_fof_access_grant_locally( + &*storage, &post_id, &new_pub_x, &new_wrap_slot, + ); + } + + // Propagate. + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let diff = crate::protocol::BlobHeaderDiffPayload { + post_id, + author: post_author, + ops: vec![crate::types::BlobHeaderDiffOp::FoFAccessGrant { + post_id, + new_pub_x, + new_wrap_slot, + granted_at_ms, + author_sig, + }], + timestamp_ms: now, + }; + self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; + + Ok(()) + } + /// Get the comment policy for a post. pub async fn get_comment_policy(&self, post_id: PostId) -> anyhow::Result> { let storage = self.storage.get().await; diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index 95d0eea..d9a3bd6 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -5024,6 +5024,30 @@ impl Storage { Ok(()) } + /// FoF Layer 2: append a new (pub_x, wrap_slot) entry to a stored + /// post's fof_gating. Local-only mutation; PostId (in the `id` + /// column) is unaffected. Idempotent on `(post_id, new_pub_x)`. + pub fn append_fof_access_grant( + &self, + post_id: &PostId, + new_pub_x: &[u8; 32], + new_wrap_slot: &crate::types::WrapSlot, + ) -> anyhow::Result { + let Some(mut post) = self.get_post(post_id)? else { return Ok(false); }; + let Some(mut gating) = post.fof_gating.take() else { return Ok(false); }; + if gating.pub_post_set.iter().any(|p| p == new_pub_x) { + return Ok(false); // already present + } + gating.pub_post_set.push(*new_pub_x); + gating.wrap_slots.push(new_wrap_slot.clone()); + let gating_json = serde_json::to_string(&gating)?; + self.conn.execute( + "UPDATE posts SET fof_gating_json = ?1 WHERE id = ?2", + params![gating_json, post_id.as_slice()], + )?; + Ok(true) + } + /// FoF Layer 2: record a post-level revocation locally. Idempotent /// on `(post_id, revoked_pub_x)`. Subsequent incoming comments /// where `pub_post_set[pub_x_index] == revoked_pub_x` are rejected diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 0771a6e..641ac0d 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -1133,6 +1133,19 @@ pub enum BlobHeaderDiffOp { /// (post_id || revoked_pub_x || revoked_at_ms_le || reason_code). author_sig: Vec, }, + /// FoF Layer 2: author retroactively widens read access on a + /// FoF-gated post by appending a new (pub_x, wrap_slot) pair. The + /// newly-vouched persona can now decrypt and comment on the post + /// without a full re-issue. Append-only at the tail per Layer 3. + FoFAccessGrant { + post_id: PostId, + new_pub_x: [u8; 32], + new_wrap_slot: WrapSlot, + granted_at_ms: u64, + /// 64-byte ed25519 sig by post author over + /// (post_id || new_pub_x || canonical(new_wrap_slot) || granted_at_ms_le). + author_sig: Vec, + }, /// Unknown ops from newer protocol versions — silently ignored #[serde(other)] Unknown, From 10de3f61081f03036aefca70966728c9f822d635 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 16:07:07 -0400 Subject: [PATCH 24/34] feat(fof-layer2): Tauri commands + frontend compose/comment routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new Tauri commands surface Layer 2 to the frontend: - create_post_with_fof_comments(content) → { postId } Builds the FoF gating block from the default persona's keyring and publishes a Mode 2 post (public body, FoF-gated comments). - comment_on_fof_post(post_id_hex, body) Backwards-compat wrapper: comment_on_post now auto-detects FoF gating on the parent post and dispatches internally. Existing comment-send UI works unchanged. - revoke_fof_commenter(post_id_hex, pub_x_index, reason_code) Wraps the Node helper. UI for per-comment revocation can hang off this without further Node changes. Node::comment_on_post now branches on parent post fof_gating presence — this is the central routing point so neither the frontend nor existing comment-send code needs to know about FoF specifics. FoF posts get FoF comments automatically; non-FoF posts get the legacy path. Same Tauri command, same frontend handler. Frontend: - Compose: comment-perm-select gains a "Friends of Friends" option. When picked, compose dispatches to create_post_with_fof_comments instead of the standard create_post. Standard set_comment_policy diff fires after so receivers' four-check accept rule activates. - Attachments + non-default-persona FoF posts are out-of-v1; compose reports + aborts rather than silently producing a non-FoF post. Layer 2 backend + minimal-viable UI complete. Granular per-comment revoke UI and access-grant UI deferred — Node + Tauri primitives exist; the surfaces can be added without further crypto work. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/node.rs | 68 +++++++++++++++++++++++++++++++++++++ crates/tauri-app/src/lib.rs | 50 +++++++++++++++++++++++++++ frontend/app.js | 29 +++++++++++++--- frontend/index.html | 1 + 4 files changed, 144 insertions(+), 4 deletions(-) diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 0ab4d1a..76ea428 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -4578,6 +4578,21 @@ impl Node { post_id: PostId, content: String, ) -> anyhow::Result { + // FoF Layer 2: if the post carries fof_gating, route through + // the FoF comment path so the comment is encrypted under + // CEK_comments + signed under priv_x. The CDN four-check accept + // rule on receivers will then validate the comment. + let is_fof_gated = { + let storage = self.storage.get().await; + storage.get_post(&post_id) + .ok() + .flatten() + .and_then(|p| p.fof_gating) + .is_some() + }; + if is_fof_gated { + return self.comment_on_fof_post(post_id, content).await; + } self.comment_on_post_inner(post_id, content, None).await } @@ -4817,6 +4832,59 @@ impl Node { Ok(()) } + /// FoF Layer 2: author a comment on a FoF-gated post. Finds the + /// caller's unlock (any held V_x that matches one of the post's + /// slots), encrypts the body under CEK_comments, signs with the + /// per-V_x priv_x, attaches pub_x_index, stores locally, and + /// propagates via the standard engagement-diff path. + /// + /// Returns the constructed InlineComment. Errors if the post + /// isn't FoF-gated, or if no held V_x admits the caller. + pub async fn comment_on_fof_post( + &self, + post_id: PostId, + body: String, + ) -> anyhow::Result { + let (unlock, slot_binder_nonce, commenter_id, commenter_secret, post_author) = { + let storage = self.storage.get().await; + let post = storage.get_post(&post_id)? + .ok_or_else(|| anyhow::anyhow!("post not found"))?; + let gating = post.fof_gating.as_ref() + .ok_or_else(|| anyhow::anyhow!("post is not FoF-gated"))?; + let slot_binder_nonce = gating.slot_binder_nonce; + let unlock = crate::fof::find_unlock_for_post(&*storage, &post)? + .ok_or_else(|| anyhow::anyhow!("no held V_x unlocks this post — not in FoF set"))?; + let identity = storage.get_posting_identity(&unlock.persona_id)? + .ok_or_else(|| anyhow::anyhow!("unlocking persona not on device"))?; + (unlock, slot_binder_nonce, identity.node_id, identity.secret_seed, post.author) + }; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let comment = crate::fof::build_fof_comment( + &post_id, &unlock, &slot_binder_nonce, + &commenter_id, &commenter_secret, &body, None, now, + )?; + + // Store locally. + { + let storage = self.storage.get().await; + storage.store_comment(&comment)?; + } + + // Propagate via engagement-diff path. + let diff = crate::protocol::BlobHeaderDiffPayload { + post_id, + author: post_author, + ops: vec![crate::types::BlobHeaderDiffOp::AddComment(comment.clone())], + timestamp_ms: now, + }; + self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; + + Ok(comment) + } + /// FoF Layer 2: retroactively widen read+comment access on a /// FoF-gated post the caller authored by sealing a fresh wrap slot /// under the given V_x and appending it to the post's gating. diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 2b99819..96378ee 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -1145,6 +1145,53 @@ async fn list_vouches_received(state: State<'_, AppNode>) -> Result, + content: String, +) -> Result { + let node = get_node(&state).await; + let (post_id, _post, _vis, _cek) = node + .create_post_with_fof_comments(content, vec![]) + .await + .map_err(|e| e.to_string())?; + Ok(FoFPostCreatedDto { post_id: hex::encode(post_id) }) +} + +#[tauri::command] +async fn comment_on_fof_post( + state: State<'_, AppNode>, + post_id_hex: String, + body: String, +) -> Result<(), String> { + let node = get_node(&state).await; + let pid = parse_node_id(&post_id_hex)?; // PostId is also [u8; 32] + node.comment_on_fof_post(pid, body).await + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn revoke_fof_commenter( + state: State<'_, AppNode>, + post_id_hex: String, + pub_x_index: u32, + reason_code: u8, +) -> Result<(), String> { + let node = get_node(&state).await; + let pid = parse_node_id(&post_id_hex)?; + node.revoke_fof_commenter(pid, pub_x_index, reason_code).await + .map_err(|e| e.to_string()) +} + #[tauri::command] async fn list_follows(state: State<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; @@ -3163,6 +3210,9 @@ pub fn run() { revoke_vouch_for_peer, list_vouches_given, list_vouches_received, + create_post_with_fof_comments, + comment_on_fof_post, + revoke_fof_commenter, list_circles, create_circle, delete_circle, diff --git a/frontend/app.js b/frontend/app.js index 2050913..8abd5e6 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2629,8 +2629,29 @@ async function doPost() { } } + const commentPerm = document.getElementById('comment-perm-select').value; + const reactPerm = document.getElementById('react-perm-select').value; + let result; - if (selectedFiles.length > 0) { + if (commentPerm === 'friends_of_friends') { + // FoF Layer 2: body is still public (Mode 2) but the post + // carries a fof_gating block built from the author's + // keyring. Routed through a dedicated command because the + // gating block is signed at publish time (can't be added + // via SetPolicy after the fact). + // Attachments + non-default-persona FoF posts are not yet + // supported by the v1 command — fall through to the + // standard path with a warning if either applies. + if (selectedFiles.length > 0 || params.postingIdHex) { + toast('FoF posts with attachments or non-default persona require Mode 1 (later).'); + postBtn.disabled = false; + return; + } + const created = await invoke('create_post_with_fof_comments', { + content: params.content, + }); + result = { id: created.postId }; + } else if (selectedFiles.length > 0) { // Convert ArrayBuffers to base64 strings const files = selectedFiles.map(f => { const bytes = new Uint8Array(f.data); @@ -2644,9 +2665,9 @@ async function doPost() { result = await invoke('create_post', params); } - // Set engagement policy if non-default - const commentPerm = document.getElementById('comment-perm-select').value; - const reactPerm = document.getElementById('react-perm-select').value; + // Set engagement policy if non-default (FoF posts also publish + // the policy diff so receivers route the comment-receive path + // through the FoF four-check verify gate). if ((commentPerm !== 'public' || reactPerm !== 'both') && result && result.id) { try { await invoke('set_comment_policy', { diff --git a/frontend/index.html b/frontend/index.html index 577fe08..0f62760 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -104,6 +104,7 @@