From b8b38a6f030bf750d0be41f670b8dba102d64940 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Fri, 24 Apr 2026 07:38:12 -0400 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20Layer=201=20=E2=80=94=20HPKE-sealed?= =?UTF-8?q?=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 2/3] =?UTF-8?q?docs:=20Layer=202=20=E2=80=94=20CDN-verifie?= =?UTF-8?q?d=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 3/3] =?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`