Compare commits

...

34 commits

Author SHA1 Message Date
Scott Reimers
d46fcb4ef4 chore: bump version to 0.7.0 + download page updates
Bumps:
- crates/{core,cli,tauri-app}/Cargo.toml: 0.6.2 → 0.7.0
- crates/tauri-app/tauri.conf.json: 0.6.2 → 0.7.0
- gen/android/app/tauri.properties: versionName 0.7.0, versionCode 7000

website/download.html: v0.7.0 promoted to top with FoF release notes;
v0.6.2 retained in archive section. Wire-additive notice + link to
design.html#fof for full architecture.

No -beta suffix this cycle: no users on prior version means no need
for the beta carve-out. Will resume beta convention when there are
real users to migrate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:48:08 -06:00
Scott Reimers
4ec3a80b6c fix(fof): key-burn replay rejection + bounded sweep lock-hold
Two security/operational findings from the deeper pre-deploy audit:

1. Key-burn replay attack (security)
   - Before: replace_fof_slot did a blind UPDATE on receive. An
     attacker replaying an older signed FoFKeyBurn diff (Monday's)
     after a newer one (Friday's) would revert the slot.
   - Fix: new fof_key_burns table tracks (post_id, slot_index) ->
     max(burned_at_ms) seen. replace_fof_slot now refuses to apply
     a burn whose timestamp is <= the stored max. Atomic transaction
     ensures the gating-update + monotonic-record stay consistent.
   - apply_fof_key_burn_locally signature gains burned_at_ms; the
     three callers (connection.rs receive, node.rs author API, the
     fof.rs roundtrip test) all updated.

2. Unbounded sweep lock-hold (operational)
   - Before: sweep_unreadable_on_new_v_x walked up to 4096 queued
     posts per call, all under one storage lock. ~2s lock-held worst
     case. Attack: spammy bio posts trigger repeated sweeps.
   - Fix: MAX_SWEEP_PER_CALL = 256. Remaining entries processed on
     subsequent V_x arrivals. Bounds lock-hold to ~250ms worst case.

One new test (158 total):
- fof_key_burn_replay_rejected: applies Monday burn, then Friday
  burn, then replays Monday — asserts stored state stays at Friday's
  pub_x.

Bio-post replay was also evaluated: vouch grants are HPKE-sealed
(unforgeable) and stored INSERT OR IGNORE on (holder, owner, epoch)
so replay is a no-op. No fix needed.

Revocation + access-grant were also evaluated as idempotent by their
storage layers (INSERT OR IGNORE / dedup-on-pub_x). Safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:31:15 -06:00
Scott Reimers
aa190db375 fix(fof): pre-deploy hardening — wire validation + unreadable cap
DoS-resistance pass before shipping Layers 1-5. Found three concerns:

1. Receive-path lacked validation on FoF gating shape.
2. vouch_unreadable_posts queue had no upper bound.
3. Receive-path FoFClosed visibility could pair with no fof_gating.

Fix 1 (fof::validate_fof_gating_on_receive):
- Called from control::receive_post BEFORE any storage write.
- Rejects wrap_slots/pub_post_set length mismatch (preserves the
  pub_x_index lookup invariant).
- Caps wrap_slots at MAX_FOF_WRAP_SLOTS=8192. Above that we assume
  attacker-shaped; legitimate bucket rule maxes at ~real+128 above 256.
- Validates each WrapSlot.read_ciphertext / sign_ciphertext is
  exactly 48 bytes (matches seal_wrap_slot's output).
- Caps revocation_list at MAX_FOF_REVOCATION_LIST=4096.
- Bad posts never enter storage, never get re-propagated via
  neighbor-manifest diffs.

Fix 2 (fof::validate_fof_closed_has_gating):
- FoFClosed visibility + None gating is an invariant violation.
  Rejected at the same receive boundary.

Fix 3 (storage::record_unreadable_post):
- Per-persona cap of MAX_UNREADABLE_PER_PERSONA=4096. Above the cap,
  new posts get last_attempt_ms touched if already present but no
  new INSERT. Bounds sweep-on-V_x-arrival cost.

7 new tests bring the suite to 157:
- validate_rejects_length_mismatch / oversized_slots / wrong_ciphertext
- validate_accepts_well_formed_gating / post_without_gating
- validate_fof_closed_requires_gating
- unreadable_queue_is_capped

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:23:11 -06:00
Scott Reimers
12a305889e feat(fof-layer5): unlock cache + retry sweep + author-direct fast path
Three new tables + cache/sweep wiring per docs/fof-spec/
layer-5-prefilter-and-cache.md:

vouch_unlock_cache (reader_persona, author) -> winning V_x
- Records the V_x that last unlocked a post from this author. Next
  post from same author: hot path is 1 HMAC + 1 AEAD attempt.

vouch_unreadable_posts (reader_persona, post_id)
- Queue of FoF posts that no held V_x currently unlocks. Swept when
  a new V_x lands in the persona's keyring.

own_fof_post_ceks (author_persona, post_id) -> (CEK, slot_binder_nonce)
- Author-direct decrypt fast path. Populated at publish so authors
  skip wrap-slot trial entirely when reading their own posts.

find_unlock_for_post:
- Cache fast path: look up winning V_x, prefilter, single AEAD.
- Full scan fallback on cache miss; record cache hit on success;
  record unreadable on full miss.

read_fof_closed_body:
- Author-direct fast path: lookup_own_fof_post_cek before trial-unlock.

sweep_unreadable_on_new_v_x:
- Walks ALL unreadable for the persona (the new V_x can unlock posts
  authored by anyone — the V_x owner may be a chain-link in some
  third party's keyring). Wired into the vouch-grant scan path so
  every new V_x triggers a sweep automatically.

Both FoF publish paths (Mode 1 + Mode 2) now cache CEK at publish.

Bug fix found by the sweep test: storage::get_post_with_visibility
wasn't loading fof_gating_json (always returned None). Fixed; reader
paths through that function now see the gating block.

16 fof:: tests pass (2 new: cache populates+hits, sweep after V_x
arrival). 150 total tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:29:12 -06:00
Scott Reimers
ce710a6596 feat(fof-layer4): Tauri commands + Settings "Rotate my vouch key" UI
Three new Tauri commands surface Layer 4 to the frontend:

- rotate_v_me() -> { newEpoch }: generates next V_me epoch +
  republishes bio. Old epoch retained in vouch_keys_own; existing
  vouchees receive the new key on their next bio-scan.
- cascade_revoke_v_me_epoch(retired_epoch, reason_code) ->
  { postsRevoked }: bulk per-post revocation across every author
  post that sealed slots under (self, retired_epoch). Useful as a
  follow-up after rotate_v_me when the author wants to actively
  cut off comment access on old posts.
- key_burn_post_slot(post_id_hex, slot_index, new_v_x_hex): the
  leaked-V_me primitive. Re-seals one slot under a different V_x.

Frontend (Settings → Vouches):
- New "Rotate my vouch key" button + status pill below the Given /
  Received lists.
- Confirmation prompt explains the grandfather-by-default semantics:
  "old posts remain readable to anyone who held the old key — cascade-
  revoke separately if you want to cut off old-content access."
- Wired once per settings-tab activation.

Cascade-revoke and key-burn surfaces aren't visualized yet (require
per-post selection UI); the Tauri commands are available for follow-up
UI work or scripting via the desktop dev console.

Workspace builds clean; 148 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:18:31 -06:00
Scott Reimers
fdbf97f2d7 feat(fof-layer4): supersedes_post_id field on Post
Adds an optional supersedes_post_id pointer to Post for the
"re-issue with narrower access" path. Author publishes a new post
that references an earlier one; receivers can render "this is a
re-issued version of an earlier post" + offer to view the original.

Covered by PostId = BLAKE3(Post) — receivers can verify it wasn't
forged by anyone but the author.

#[serde(default)] for back-compat; existing posts deserialize with
None. All existing Post construction sites bulk-updated to set the
field to None.

Storage::get_post returns None for the field today; a follow-up can
add a dedicated column if/when receivers need to render the
pointer. The author-side re-issue helper that creates posts with
this field set comes in the Tauri/UI slice — the wire shape is in
place now.

148 tests pass (no regressions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:29:55 -06:00
Scott Reimers
c2f2203331 feat(fof-layer4): FoFKeyBurn primitive — in-place wrap_slot replacement
For leaked-V_me scenarios. The author re-seals a single slot under a
fresh V_me, invalidating the leaked key's access to this specific
post on the wire. Comments signed under the old pub_x at that slot
are NOT auto-deleted; pair with revoke_fof_commenter if comment
cleanup is desired.

Wire format (BlobHeaderDiffOp::FoFKeyBurn):
  post_id, slot_index, new_pub_x, new_wrap_slot, burned_at_ms,
  author_sig (64B ed25519 over canonical tuple).

fof.rs:
- sign_fof_key_burn / verify_fof_key_burn: canonical signing tuple
  includes post_id, slot_index_le, new_pub_x, prefilter+read+sign
  bytes from WrapSlot, burned_at_ms_le. Identical shape to access-
  grant but with slot_index instead of append.
- apply_fof_key_burn_locally: delegates to storage.replace_fof_slot.

storage.rs:
- replace_fof_slot(post_id, slot_index, new_pub_x, new_wrap_slot):
  mutates the stored post's fof_gating_json. Bounds-checks slot_index.
  Local-only; PostId unaffected.

connection.rs: receive arm. Verifies author_sig + applies.

node.rs:
- Node::key_burn_post_slot(post_id, slot_index, new_v_x): recovers
  CEK via find_unlock_for_post, generates fresh per-V_x keypair,
  seals new slot under new_v_x with the existing CEK +
  slot_binder_nonce. Signs + applies locally + propagates.

CEK is NOT rotated by this op — body remains encrypted under the
same CEK as before. Locally-cached plaintext on devices that
already-decrypted is unrecoverable by any wire mechanism (out of
scope per spec).

Test brings the total to 147:
- fof_key_burn_replaces_slot: Alice burns her slot from V_me_old to
  V_me_new; V_me_old no longer unlocks; V_me_new unlocks and yields
  the same CEK; pub_post_set updates to the new pub_x.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:20:26 -06:00
Scott Reimers
c0de21d37b feat(fof-layer4): provenance table + pure V_me rotation + cascade revoke
Lays the foundation for Layer 4 lifecycle operations:

Storage (own_post_slot_provenance):
- New author-local table mapping (post_id, slot_index) to
  (v_x_owner, v_x_epoch, pub_x). Populated at FoF post-publish.
  Used by cascade revocation to find the pub_x's that need revoking
  when a V_me epoch is retired. Never on the wire.
- record_post_slot_provenance + list_provenance_for_v_x_epoch APIs.

fof::build_fof_comment_gating now returns RealSlotProvenance entries
for each real (non-dummy) slot it sealed. Owner = persona who issued
the V_x (author's own persona_id for self-slot). Both Mode 1 and
Mode 2 publish paths persist provenance after compute_post_id.

Node API:
- rotate_v_me() — pure rotation. Generates next V_me epoch in
  vouch_keys_own (old epoch retained, is_current=0), republishes bio
  for existing vouch targets. Returns new epoch. Used for periodic
  refresh / leak response; doesn't revoke anyone.
- cascade_revoke_v_me_epoch(epoch, reason) — for every post the
  author authored where slots were sealed under (self, epoch),
  publish a per-pub_x revocation diff via revoke_fof_commenter. The
  existing Layer 2 cascade-delete then sweeps locally-stored comments.
  Returns the count of revocations published.

These combine to give the spec's "rotation + optional cascade" UX:
rotate first (cheap, grandfathers old posts), then cascade if the
user wants to actively cut off old-content access.

13 fof tests pass (new: fof_gating_real_slot_provenance asserting
provenance entries match real slots' pub_x values).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:17:47 -06:00
Scott Reimers
66b78041fc feat(fof-layer3): Mode 1 publish + read + Tauri + UI wiring
End-to-end FoFClosed (Mode 1: encrypted body + FoF comments):

Node API:
- create_post_fof_closed(content) -> (PostId, Post, cek)
  Builds gating, encrypts body via fof::encrypt_fof_body, base64s it
  into post.content, stores with visibility=FoFClosed +
  intent=Public, propagates via update_neighbor_manifests_as.
- read_fof_closed_body(post_id) -> Option<String>
  Trial-unlocks via find_unlock_for_post, decrypts body, returns
  plaintext. Returns None for non-FoFClosed or non-member readers.

Tauri commands:
- create_post_fof_closed, read_fof_closed_body. Registered in
  generate_handler!.

Feed rendering:
- PostDto.visibility carries the new "fof-closed" string.
- renderPost(): FoFClosed posts render with a locked placeholder
  (data-fof-closed-pending=post_id span). Visual badge added.
- unlockFoFClosedPlaceholders(rootEl): post-render async pass that
  scans for placeholder spans and dispatches read_fof_closed_body
  for each. Fills in body for FoF readers; falls back to a
  "not in this FoF set" notice otherwise.
- Wired into feed-list and my-posts-list render paths.

Compose:
- "Body+Comments: FoF only (Mode 1)" option in comment-perm-select.
  Selected → dispatches to create_post_fof_closed.

CLI feed renderer + Tauri feed-DTO match arms updated to handle
FoFClosed.

New end-to-end test brings total to 146:
- fof_closed_body_end_to_end: Alice authors FoFClosed body; Bob (with
  Alice's V_me in his keyring) unlocks + decrypts; Carol (no
  matching V_x) cannot unlock and sees only ciphertext.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:19:42 -06:00
Scott Reimers
856f386231 feat(fof-layer3): PostVisibility::FoFClosed + body crypto + bucket padding
Adds the Mode 1 (encrypted body) primitives:

PostVisibility::FoFClosed
- New tag variant. The actual gating data (slot_binder_nonce,
  pub_post_set, wrap_slots) lives in Post.fof_gating — single source
  of truth shared between Mode 2 (Public + fof_gating) and Mode 1
  (FoFClosed + fof_gating). Invariant: FoFClosed implies Some(gating).

fof::encrypt_fof_body / decrypt_fof_body
- ChaCha20-Poly1305 under the gating CEK with slot_binder_nonce as
  AAD (binds body decrypt to the post's gating; an attacker who
  steals CEK can't reuse it against a different post).
- Plaintext format: real_len_u32_le || body_bytes || random_padding.
  Length prefix lets the reader strip padding after decrypt.
- Bucketed body padding: power-of-2 from 1KB up to 256KB, then
  +256KB linear above. Different bodies in the same bucket produce
  identically-sized ciphertexts (test asserts this).

fof::next_body_size_bucket(real) -> usize
- Min 1KB, power-of-2 to 256KB, then +256KB steps. Aligns with the
  future storage chunk size at 256KB+.

Three new tests (145 total):
- body_bucket_rule_boundaries: spec-conformance for the bucket sizes.
- fof_body_roundtrip: encrypt → decrypt; wrong CEK rejects; wrong AAD
  (slot_binder_nonce) rejects.
- fof_body_padding_hides_real_length: 5B body and 500B body produce
  same-sized on-wire ciphertexts (1KB bucket).

8 match arms updated to handle FoFClosed across import, network, node,
storage. Most paths skip FoFClosed-specific handling (it goes through
the FoF wrap_slot path); revoke_post_access bails with a pointer to
the FoF revoke helpers; index_post_recipients no-ops (FoF has no
per-recipient identifiers on the wire).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:22:46 -04:00
Scott Reimers
10de3f6108 feat(fof-layer2): Tauri commands + frontend compose/comment routing
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) <noreply@anthropic.com>
2026-05-14 16:07:07 -04:00
Scott Reimers
96118d7ce8 feat(fof-layer2): access-grant — retroactive read+comment widening
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) <noreply@anthropic.com>
2026-05-14 15:07:54 -04:00
Scott Reimers
6a76adef8f feat(fof-layer2): revocation diff — sign + verify + propagate + cascade
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) <noreply@anthropic.com>
2026-05-14 14:59:23 -04:00
Scott Reimers
583033e065 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) <noreply@anthropic.com>
2026-05-14 14:51:02 -04:00
Scott Reimers
63ff5ad6eb feat(fof-layer2): CDN four-check verification on incoming FoF comments
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) <noreply@anthropic.com>
2026-05-14 14:06:34 -04:00
Scott Reimers
00522f4c4b feat(fof-layer2): reader unlock + commenter authoring + sig verify
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) <noreply@anthropic.com>
2026-05-14 14:04:22 -04:00
Scott Reimers
673f9e2261 feat(fof-layer2): wire FoF gating into post-create path
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) <noreply@anthropic.com>
2026-05-14 14:01:57 -04:00
Scott Reimers
bdcd2142cd feat(fof-layer2): author publish-side build_fof_comment_gating
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) <noreply@anthropic.com>
2026-05-14 13:50:56 -04:00
Scott Reimers
0f5147a31c feat(fof-layer2): wire types — WrapSlot, FoFCommentGating, CommentPermission::FriendsOfFriends
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) <noreply@anthropic.com>
2026-05-14 13:39:46 -04:00
Scott Reimers
74fec3b1fb feat(fof-layer2): wrap-slot dual-derivation seal/open primitives
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) <noreply@anthropic.com>
2026-05-13 07:16:42 -04:00
Scott Reimers
34c5b60686 feat(fof-layer1): Tauri commands + frontend UI for vouches
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) <noreply@anthropic.com>
2026-05-13 06:47:18 -04:00
Scott Reimers
d1afcec26a feat(fof-layer1): receive-path scan populates vouch_keys_received
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) <noreply@anthropic.com>
2026-05-13 01:44:54 -04:00
Scott Reimers
3ee5c30ad2 feat(fof-layer1): publish path embeds VouchGrantBatch
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<VouchGrantBatch>
  + 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) <noreply@anthropic.com>
2026-05-13 01:39:09 -04:00
Scott Reimers
bc008c5049 feat(fof-layer1): wire types + V_me auto-gen on persona creation
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<Vec<u8>> 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) <noreply@anthropic.com>
2026-05-13 01:35:19 -04:00
Scott Reimers
8a53d83306 feat(fof-layer1): schema + storage API + vouch-grant crypto primitives
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}/<bio_post_id>". 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) <noreply@anthropic.com>
2026-05-13 01:29:43 -04:00
Scott Reimers
d7ce2f734c 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) <noreply@anthropic.com>
2026-05-13 01:20:43 -04:00
Scott Reimers
73b1e24f9a docs: spec cleanup — Layer 5 wording, Layer 3 banner, Layer 6 superseded
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) <noreply@anthropic.com>
2026-05-13 01:10:43 -04:00
Scott Reimers
971766cb3c docs: Layer 4 — rotation, revocation, key lifecycle
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) <noreply@anthropic.com>
2026-05-13 01:07:04 -04:00
Scott Reimers
4123e032cb docs: Layer 3 round 2 — append-at-tail grants, min bucket 8
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) <noreply@anthropic.com>
2026-05-12 21:58:06 -04:00
Scott Reimers
9040d70bf6 docs: correct padding rule — bucketed throughout, not random above 256
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) <noreply@anthropic.com>
2026-05-12 21:47:44 -04:00
Scott Reimers
3ee20736aa 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) <noreply@anthropic.com>
2026-05-12 21:43:11 -04:00
Scott Reimers
a79cab049f docs: Layer 2 round 2 — resolve 5 questions + access-grant primitive
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) <noreply@anthropic.com>
2026-04-24 10:37:24 -04:00
Scott Reimers
553fbd3a20 docs: Layer 2 — CDN-verified FoF comments (per-V_x keypair)
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) <noreply@anthropic.com>
2026-04-24 08:25:40 -04:00
Scott Reimers
b8b38a6f03 docs: Layer 1 — HPKE-sealed vouch grants via bio post
Replace DM-wrapped VouchGrant with HPKE (RFC 9180) per-recipient
wrappers in the voucher's bio post. Recipient anonymity via HPKE key
privacy; readers trial-decrypt per persona. 48B per wrapper, one
ephemeral pubkey per batch. Scan gated to follows + manual gesture.
Bucket padding + per-publish shuffle for size/position opacity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 07:38:12 -04:00
34 changed files with 6275 additions and 254 deletions

6
Cargo.lock generated
View file

@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "itsgoin-cli"
version = "0.6.2"
version = "0.7.0"
dependencies = [
"anyhow",
"hex",
@ -2744,7 +2744,7 @@ dependencies = [
[[package]]
name = "itsgoin-core"
version = "0.6.2"
version = "0.7.0"
dependencies = [
"anyhow",
"base64 0.22.1",
@ -2767,7 +2767,7 @@ dependencies = [
[[package]]
name = "itsgoin-desktop"
version = "0.6.2"
version = "0.7.0"
dependencies = [
"anyhow",
"base64 0.22.1",

View file

@ -1,6 +1,6 @@
[package]
name = "itsgoin-cli"
version = "0.6.2"
version = "0.7.0"
edition = "2021"
[[bin]]

View file

@ -905,6 +905,7 @@ async fn print_post(
itsgoin_core::types::PostVisibility::GroupEncrypted { epoch, .. } => {
format!(" [group-encrypted, epoch {}]", epoch)
}
itsgoin_core::types::PostVisibility::FoFClosed => " [fof-closed]".to_string(),
};
let ts = post.timestamp_ms / 1000;
@ -917,6 +918,10 @@ async fn print_post(
Some(text) => text.to_string(),
None => "(encrypted)".to_string(),
},
itsgoin_core::types::PostVisibility::FoFClosed => match decrypted {
Some(text) => text.to_string(),
None => "(fof-closed; not in this FoF set)".to_string(),
},
};
println!("---");

View file

@ -1,6 +1,6 @@
[package]
name = "itsgoin-core"
version = "0.6.2"
version = "0.7.0"
edition = "2021"
[dependencies]

View file

@ -143,6 +143,8 @@ pub fn build_announcement_post(
content: serde_json::to_string(&content).unwrap_or_default(),
attachments: vec![],
timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
}
}

View file

@ -6171,6 +6171,53 @@ impl ConnectionManager {
}
}
crate::types::CommentPermission::Public => {}
crate::types::CommentPermission::FriendsOfFriends => {
// 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). 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)
.copied();
if let Some(pub_x) = pub_x {
if gating.revocation_list.iter()
.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;
}
}
}
}
}
if !crate::crypto::verify_comment_signature(
&comment.author,
@ -6201,6 +6248,63 @@ 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::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::FoFKeyBurn {
post_id, slot_index, new_pub_x, new_wrap_slot, burned_at_ms, author_sig,
} => {
let post_author = match storage.get_post(post_id) {
Ok(Some(p)) => p.author,
_ => continue,
};
if !crate::fof::verify_fof_key_burn(
&post_author, post_id, *slot_index, new_pub_x, new_wrap_slot,
*burned_at_ms, author_sig,
) {
continue;
}
let _ = crate::fof::apply_fof_key_burn_locally(
&storage, post_id, *slot_index, new_pub_x, new_wrap_slot,
*burned_at_ms,
);
}
BlobHeaderDiffOp::ThreadSplit { new_post_id } => {
let _ = storage.store_thread_meta(&crate::types::ThreadMeta {
post_id: *new_post_id,

View file

@ -23,6 +23,8 @@ mod tests {
content: "hello world".to_string(),
attachments: vec![],
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
};
let id1 = compute_post_id(&post);
let id2 = compute_post_id(&post);
@ -36,12 +38,16 @@ mod tests {
content: "hello".to_string(),
attachments: vec![],
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
};
let post2 = Post {
author: [1u8; 32],
content: "world".to_string(),
attachments: vec![],
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
};
assert_ne!(compute_post_id(&post1), compute_post_id(&post2));
}
@ -53,6 +59,8 @@ mod tests {
content: "test".to_string(),
attachments: vec![],
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
};
let id = compute_post_id(&post);
assert!(verify_post_id(&id, &post));

View file

@ -120,6 +120,15 @@ pub fn receive_post(
_ => {}
}
// FoF Layer 2/3 hardening (pre-deploy audit): reject malformed
// FoF gating blocks BEFORE storage. Bounds wrap_slots count,
// enforces 1:1 wrap_slots/pub_post_set parity, validates
// ciphertext field sizes, caps revocation_list. Also enforces
// the FoFClosed-implies-gating invariant. Rejection prevents
// bad data from being re-propagated via neighbor-manifest diffs.
crate::fof::validate_fof_closed_has_gating(post, visibility)?;
crate::fof::validate_fof_gating_on_receive(post)?;
let stored = if let Some(intent) = intent {
s.store_post_with_intent(id, post, visibility, intent)?
} else {
@ -155,6 +164,8 @@ pub fn build_delete_control_post(
content: serde_json::to_string(&op).unwrap_or_default(),
attachments: vec![],
timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
}
}
@ -182,6 +193,8 @@ pub fn build_visibility_control_post(
content: serde_json::to_string(&op).unwrap_or_default(),
attachments: vec![],
timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
}
}
@ -212,6 +225,8 @@ mod tests {
content: "hello".to_string(),
attachments: vec![],
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
};
let post_id = crate::content::compute_post_id(&post);
s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap();
@ -240,6 +255,8 @@ mod tests {
content: "hello".to_string(),
attachments: vec![],
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
};
let post_id = crate::content::compute_post_id(&post);
s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap();

View file

@ -12,6 +12,23 @@ 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";
/// 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);
@ -193,6 +210,260 @@ 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<Vec<u8>> {
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)
}
// --- 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<u8>, // 48 bytes
pub sign_ciphertext: Vec<u8>, // 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<SealedWrapSlot> {
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<OpenedWrapSlot> {
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<WrappedKey>)`.
pub fn encrypt_post_with_cek(
@ -1347,4 +1618,150 @@ 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)");
}
// --- 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);
}
}

1965
crates/core/src/fof.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -61,6 +61,8 @@ pub fn build_distribution_post(
content: ciphertext_b64,
attachments: vec![],
timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
};
let post_id = compute_post_id(&post);
let visibility = PostVisibility::Encrypted { recipients: wrapped_keys };
@ -241,6 +243,8 @@ mod tests {
content: ciphertext,
attachments: vec![],
timestamp_ms: 200,
fof_gating: None,
supersedes_post_id: None,
};
let forged_vis = PostVisibility::Encrypted { recipients: wrapped };

View file

@ -62,6 +62,9 @@ fn parse_exported_intent(raw: Option<&str>, vis: &PostVisibility) -> VisibilityI
// No intent recorded — infer from the visibility shape.
match vis {
PostVisibility::Public => VisibilityIntent::Public,
// FoF Layer 3: FoFClosed pairs with VisibilityIntent::Public.
// The FoF gating handles audience; intent is the structural tag.
PostVisibility::FoFClosed => VisibilityIntent::Public,
PostVisibility::Encrypted { recipients } => {
// Heuristic: DMs typically wrap to 1-2 people (recipient + self);
// Friends posts wrap to every public follow (usually many).
@ -286,6 +289,8 @@ pub async fn import_as_personas(
content: ep.content.clone(),
attachments: attachments.clone(),
timestamp_ms: ep.timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
};
// Preserve the original visibility intent from the export.
@ -459,6 +464,8 @@ pub async fn import_public_posts(
content: ep.content.clone(),
attachments: attachments.clone(),
timestamp_ms: ep.timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
};
// Read blob data from archive
@ -677,6 +684,16 @@ pub async fn merge_with_key(
skipped += 1;
continue;
}
PostVisibility::FoFClosed => {
// FoF Layer 3 import: skip for now. The recovered
// post would need its fof_gating + CEK to decrypt,
// and the receiving persona's keyring may not
// include the right V_x. Re-issue via the author's
// device is the supported path.
debug!(post = ep.id, "FoFClosed post — skipping (import not yet supported)");
skipped += 1;
continue;
}
};
// Create new post under our identity
@ -685,6 +702,8 @@ pub async fn merge_with_key(
content: plaintext,
attachments: attachments.clone(),
timestamp_ms: ep.timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
};
// Read blob data from archive (may need decryption for encrypted posts)

View file

@ -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;

View file

@ -2234,6 +2234,11 @@ pub fn should_send_post(
.map(|members| members.iter().any(|m| query_list.contains(m)))
.unwrap_or(false)
}
// FoF Layer 3: FoFClosed posts have no per-recipient identifiers
// on the wire. Match like Public (by author): the post propagates
// through the same CDN diversity path as public content; only
// FoF readers can decrypt.
PostVisibility::FoFClosed => query_list.contains(&post.author),
}
}
@ -2253,6 +2258,8 @@ mod tests {
content: "test".to_string(),
attachments: vec![],
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
}
}

View file

@ -63,6 +63,35 @@ pub struct Node {
budget_last_reset_ms: Arc<AtomicU64>,
}
/// 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<Path>) -> anyhow::Result<Self> {
@ -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)
}
@ -698,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;
@ -772,12 +819,16 @@ impl Node {
avatar_cid: None,
timestamp_ms: pi.created_at,
signature,
vouch_grants: None,
bio_epoch: 0,
};
let post = Post {
author: pi.node_id,
content: serde_json::to_string(&content).unwrap_or_default(),
attachments: vec![],
timestamp_ms: pi.created_at,
fof_gating: None,
supersedes_post_id: None,
};
let post_id = crate::content::compute_post_id(&post);
{
@ -964,6 +1015,7 @@ impl Node {
content,
intent,
attachment_data,
None,
).await
}
@ -988,9 +1040,187 @@ 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<u8>, 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 provenance = built.real_slot_provenance.clone();
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?;
// FoF Layer 4: persist provenance so cascade-revocation can
// resolve "which pub_x's on which of my posts were sealed
// under V_me epoch N" later.
// FoF Layer 5: cache the CEK + slot_binder_nonce for author-
// direct decrypt without trial-unlocking on read.
{
let storage = self.storage.get().await;
for entry in &provenance {
let _ = storage.record_post_slot_provenance(
&self.default_posting_id, &post_id, entry.slot_index,
&entry.v_x_owner, entry.v_x_epoch, &entry.pub_x,
);
}
// Recover slot_binder_nonce via the Post we just built — it
// lives inside fof_gating.
if let Some(gating) = post.fof_gating.as_ref() {
let _ = storage.cache_own_fof_post_cek(
&self.default_posting_id, &post_id,
&cek, &gating.slot_binder_nonce,
);
}
}
Ok((post_id, post, visibility, cek))
}
/// FoF Layer 3: read the decrypted body of a FoFClosed post if any
/// of this device's personas can unlock it. Returns `Ok(None)` for
/// non-FoFClosed posts and for FoFClosed posts not reachable via
/// any held V_x. Errors only on storage/crypto faults.
pub async fn read_fof_closed_body(
&self,
post_id: &PostId,
) -> anyhow::Result<Option<String>> {
use base64::Engine;
let storage = self.storage.get().await;
let (post, visibility) = match storage.get_post_with_visibility(post_id)? {
Some(pv) => pv,
None => return Ok(None),
};
if !matches!(visibility, PostVisibility::FoFClosed) {
return Ok(None);
}
let gating = match post.fof_gating.as_ref() {
Some(g) => g,
None => return Ok(None),
};
// FoF Layer 5: author-direct fast path. If this device authored
// the post, the CEK was cached at publish time; skip the
// wrap-slot trial entirely.
let (cek, slot_binder_nonce) = if let Some((cek, nonce)) =
storage.lookup_own_fof_post_cek(&post.author, post_id)?
{
(cek, nonce)
} else {
let unlock = match crate::fof::find_unlock_for_post(&*storage, &post)? {
Some(u) => u,
None => return Ok(None),
};
(unlock.cek, gating.slot_binder_nonce)
};
drop(storage);
let body_ct = base64::engine::general_purpose::STANDARD
.decode(post.content.as_bytes())
.map_err(|e| anyhow::anyhow!("FoFClosed body base64 decode: {}", e))?;
let plaintext = crate::fof::decrypt_fof_body(&body_ct, &cek, &slot_binder_nonce)?;
Ok(Some(plaintext))
}
/// FoF Layer 3: create a Mode 1 post (FoFClosed). The body is
/// encrypted under the gating CEK before storage; only readers
/// who can unlock a wrap_slot can decrypt it. Comments are also
/// FoF-gated, inheriting Layer 2's path.
///
/// Returns `(post_id, post, visibility, cek)`.
pub async fn create_post_fof_closed(
&self,
content: String,
) -> anyhow::Result<(PostId, Post, [u8; 32])> {
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 slot_binder_nonce = built.slot_binder_nonce;
let provenance = built.real_slot_provenance.clone();
// Encrypt + pad body under the gating CEK. Output is base64'd
// so it can live in Post.content (which is a String).
let encrypted_body = crate::fof::encrypt_fof_body(&content, &cek, &slot_binder_nonce)?;
let body_b64 = {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(&encrypted_body)
};
// Build + store + propagate. Visibility is FoFClosed (tag);
// gating lives in Post.fof_gating.
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
let post = Post {
author: self.default_posting_id,
content: body_b64,
attachments: vec![],
timestamp_ms: now,
fof_gating: Some(built.gating),
supersedes_post_id: None,
};
let post_id = crate::content::compute_post_id(&post);
{
let storage = self.storage.get().await;
storage.store_post_with_intent(
&post_id, &post,
&PostVisibility::FoFClosed,
&VisibilityIntent::Public,
)?;
// FoF Layer 4: persist provenance for cascade-revoke.
for entry in &provenance {
let _ = storage.record_post_slot_provenance(
&self.default_posting_id, &post_id, entry.slot_index,
&entry.v_x_owner, entry.v_x_epoch, &entry.pub_x,
);
}
// FoF Layer 5: cache CEK for author-direct decrypt.
let _ = storage.cache_own_fof_post_cek(
&self.default_posting_id, &post_id, &cek, &slot_binder_nonce,
);
}
self.update_neighbor_manifests_as(
&self.default_posting_id,
&self.default_posting_secret,
&post_id,
now,
).await;
Ok((post_id, post, cek))
}
async fn create_post_inner(
&self,
posting_id: &NodeId,
@ -998,6 +1228,7 @@ impl Node {
content: String,
intent: VisibilityIntent,
attachment_data: Vec<(Vec<u8>, String)>,
fof_gating: Option<crate::types::FoFCommentGating>,
) -> anyhow::Result<(PostId, Post, PostVisibility)> {
// Validate attachments
if attachment_data.len() > 4 {
@ -1113,6 +1344,8 @@ impl Node {
content: final_content,
attachments,
timestamp_ms: now,
fof_gating,
supersedes_post_id: None,
};
let post_id = compute_post_id(&post);
@ -1126,8 +1359,13 @@ impl Node {
let _ = storage.pin_blob(&att.cid);
}
// Initialize encrypted receipt + comment slots for non-public posts
if !matches!(visibility, PostVisibility::Public) {
// Initialize encrypted receipt + comment slots for non-public posts.
// FoFClosed posts use the FoF wrap_slots mechanism for both
// reads and comments — they don't use the legacy receipt/
// comment slot path. Skip init for FoFClosed.
if !matches!(visibility, PostVisibility::Public)
&& !matches!(visibility, PostVisibility::FoFClosed)
{
let participant_count = match &visibility {
PostVisibility::Encrypted { recipients } => recipients.len(),
PostVisibility::GroupEncrypted { .. } => {
@ -1142,7 +1380,7 @@ impl Node {
_ => 2,
}
}
PostVisibility::Public => unreachable!(),
PostVisibility::Public | PostVisibility::FoFClosed => unreachable!(),
};
let receipt_slots: Vec<Vec<u8>> = (0..participant_count)
@ -1396,6 +1634,15 @@ impl Node {
).ok()
})
}
// FoF Layer 3: FoFClosed body decrypt requires
// trial-unlocking via the post's wrap_slots against
// every persona's received-vouch keyring — which is
// an async storage lookup, not available in this
// sync helper. Feed rendering for FoFClosed posts
// goes through a dedicated async path that resolves
// the unlock + decrypts; this helper returns None
// and lets the caller fall back.
PostVisibility::FoFClosed => None,
};
(id, post, vis, decrypted)
})
@ -1503,12 +1750,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;
@ -1637,6 +1894,220 @@ 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(())
}
/// FoF Layer 4: pure V_me rotation. Generates a new V_me epoch for
/// the default persona without revoking any vouchee. Republishes
/// the persona's bio post under the new key for every current
/// target. Used for periodic refresh or leak response (combined
/// with `cascade_revoke_v_me_epoch` for old-content cleanup +
/// `key_burn_post` for leaked-key scenarios).
///
/// Returns the new epoch number.
pub async fn rotate_v_me(&self) -> anyhow::Result<u32> {
use rand::RngCore;
let (default_id, display_name, bio, avatar_cid, posting_secret, new_epoch) = {
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 missing"))?;
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),
};
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);
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
storage.insert_own_vouch_key(&default_id, next_epoch, &new_key, now_ms)?;
(default_id, name, bio, avatar, pi.secret_seed, next_epoch)
};
// Republish bio so existing vouch targets receive the new key.
self.publish_profile_post_as(
&default_id, &posting_secret, &display_name, &bio, avatar_cid,
).await?;
Ok(new_epoch)
}
/// FoF Layer 4: cascade revocation. For every FoF post authored by
/// the default persona where slots were sealed under V_me at
/// `retired_epoch`, publish a per-pub_x revocation diff. Existing
/// stored comments by those pub_x's are cascade-deleted via the
/// standard apply_fof_revocation path.
///
/// Returns the number of post-level revocations published.
/// Typically called after `rotate_v_me` when the user wants to
/// retire access for vouchees they no longer want commenting on
/// old posts. Optional — by default rotation grandfathers old
/// posts.
pub async fn cascade_revoke_v_me_epoch(
&self,
retired_epoch: u32,
reason_code: u8,
) -> anyhow::Result<usize> {
// Look up all (post_id, pub_x) pairs sealed under (self, retired_epoch).
let pairs = {
let storage = self.storage.get().await;
storage.list_provenance_for_v_x_epoch(
&self.default_posting_id,
&self.default_posting_id,
retired_epoch,
)?
};
let mut published = 0usize;
for (post_id, _pub_x, slot_index) in pairs {
// Use the existing per-post revocation helper. It signs +
// applies locally + propagates.
if self.revoke_fof_commenter(post_id, slot_index, reason_code).await.is_ok() {
published += 1;
}
}
Ok(published)
}
/// 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<Vec<(NodeId, String, u64)>> {
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<Vec<(NodeId, String, u32, u64)>> {
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.
@ -1684,6 +2155,15 @@ impl Node {
Ok(None)
}
}
// FoF Layer 3: blob decryption for FoFClosed posts requires
// the CEK recovered via wrap_slots. This sync helper doesn't
// have storage access for the keyring trial-unlock; the
// async caller path goes through get_blob_for_post which
// can perform the unlock. For now return None — blob
// decryption for FoF posts is wired in the receive/render
// slice. (v0 ships with FoF body decryption only; binary
// attachments arrive in a follow-up.)
PostVisibility::FoFClosed => Ok(None),
}
}
@ -2807,6 +3287,9 @@ impl Node {
PostVisibility::GroupEncrypted { .. } => {
anyhow::bail!("cannot revoke individual access on a group-encrypted post; remove from circle instead")
}
PostVisibility::FoFClosed => {
anyhow::bail!("cannot revoke individual access on a FoF-gated post via this path; use revoke_fof_commenter (Layer 2) or grant_fof_access (Layer 3)")
}
};
let new_recipient_ids: Vec<NodeId> = existing_recipients
@ -2886,6 +3369,8 @@ impl Node {
content: new_content,
attachments: post.attachments.clone(),
timestamp_ms: post.timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
};
let new_post_id = compute_post_id(&new_post);
@ -4344,6 +4829,21 @@ impl Node {
post_id: PostId,
content: String,
) -> anyhow::Result<crate::types::InlineComment> {
// 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
}
@ -4391,6 +4891,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;
@ -4516,6 +5019,286 @@ 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(())
}
/// 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<crate::types::InlineComment> {
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.
/// 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(())
}
/// FoF Layer 4: in-place wrap-slot replacement for leaked-V_me
/// scenarios. Re-seals the slot at `slot_index` under `new_v_x`
/// (typically a freshly-rotated V_me), publishes a signed
/// FoFKeyBurn diff. Local stored copy of the post mutates to
/// replace the slot. Post body remains encrypted under the
/// existing CEK (CEK isn't rotated by this op).
pub async fn key_burn_post_slot(
&self,
post_id: PostId,
slot_index: u32,
new_v_x: &[u8; 32],
) -> anyhow::Result<()> {
use ed25519_dalek::SigningKey;
use rand::RngCore;
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"))?;
if slot_index as usize >= gating.wrap_slots.len() {
anyhow::bail!("slot_index out of bounds");
}
let identity = storage.get_posting_identity(&post.author)?
.ok_or_else(|| anyhow::anyhow!("post author not on this device"))?;
// Recover CEK by trial-unlocking the author's own slot.
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 fresh per-V_x keypair, seal a new slot under new_v_x.
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 burned_at_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
let author_sig = crate::fof::sign_fof_key_burn(
&posting_secret, &post_id, slot_index, &new_pub_x, &new_wrap_slot, burned_at_ms,
);
// Apply locally for immediate UI update.
{
let storage = self.storage.get().await;
let _ = crate::fof::apply_fof_key_burn_locally(
&*storage, &post_id, slot_index, &new_pub_x, &new_wrap_slot,
burned_at_ms,
);
}
// 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::FoFKeyBurn {
post_id,
slot_index,
new_pub_x,
new_wrap_slot,
burned_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<Option<crate::types::CommentPolicy>> {
let storage = self.storage.get().await;
@ -4608,6 +5391,11 @@ impl Node {
Ok(None)
}
}
// FoF Layer 3: FoFClosed posts don't use the legacy
// receipt/comment slot mechanism — they use the FoF gating's
// CEK_comments. This helper isn't used for FoF posts;
// return None so callers fall back to the FoF-specific path.
PostVisibility::FoFClosed => Ok(None),
}
}

View file

@ -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,14 +76,113 @@ 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<u32> = 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.
}
}
// FoF Layer 5: if a new V_x just landed for this persona,
// sweep the unreadable-posts queue for (persona, author) and
// re-attempt unlock. Posts that were previously not-in-set
// become readable as soon as this V_x lands.
if unlocked.is_some() {
let _ = crate::fof::sweep_unreadable_on_new_v_x(s, &persona.node_id, author);
}
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.
///
/// 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<crate::types::VouchGrantBatch>,
bio_epoch: u32,
) -> Post {
let timestamp_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -88,12 +195,16 @@ pub fn build_profile_post(
avatar_cid,
timestamp_ms,
signature,
vouch_grants,
bio_epoch,
};
Post {
author: *author,
content: serde_json::to_string(&content).unwrap_or_default(),
attachments: vec![],
timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
}
}
@ -103,6 +214,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<Option<crate::types::VouchGrantBatch>> {
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<u8>> = 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.
pub(crate) 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)
@ -130,7 +345,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");
@ -145,7 +360,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());
@ -157,7 +372,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;
@ -167,7 +382,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);
@ -178,4 +393,162 @@ 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,
fof_gating: None,
supersedes_post_id: None,
};
// 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,
fof_gating: None,
supersedes_post_id: None,
};
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");
}
}

File diff suppressed because it is too large Load diff

View file

@ -39,6 +39,22 @@ pub struct Post {
pub attachments: Vec<Attachment>,
/// 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<FoFCommentGating>,
/// FoF Layer 4: optional pointer to a post this one supersedes.
/// Used by the "re-issue with narrower access" path (advanced
/// rotation). Readers may display "this is a re-issued version
/// of an earlier post" + offer to view the original if still
/// cached. Covered by PostId since it's part of the signed Post.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supersedes_post_id: Option<PostId>,
}
/// A reference to a media blob attached to a post
@ -209,6 +225,16 @@ pub enum PostVisibility {
/// 60 bytes: nonce(12) || encrypted_cek(32) || tag(16)
wrapped_cek: Vec<u8>,
},
/// FoF Layer 3 (Mode 1): post body is encrypted under the CEK
/// carried in the post's `fof_gating.wrap_slots`. Tag variant only —
/// the actual gating data (slot_binder_nonce / pub_post_set /
/// wrap_slots) lives in `Post.fof_gating` so Mode 1 and Mode 2
/// share a single home for the FoF state.
///
/// Invariant: when visibility is FoFClosed, `Post.fof_gating` must
/// be Some. Posts with FoFClosed + None gating are rejected at
/// receive time.
FoFClosed,
}
impl Default for PostVisibility {
@ -274,6 +300,50 @@ pub struct ProfilePostContent {
/// 64-byte ed25519 signature. See `crypto::sign_profile` for the byte
/// layout signed by the posting identity.
pub signature: Vec<u8>,
/// 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<VouchGrantBatch>,
/// 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,
/// 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.
pub wrappers: Vec<Vec<u8>>,
}
/// Content payload of a `VisibilityIntent::Announcement` post.
@ -874,6 +944,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,
@ -888,6 +961,23 @@ pub struct InlineComment {
/// for the expanded view.
#[serde(default)]
pub ref_post_id: Option<PostId>,
/// 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<u32>,
/// 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<Vec<u8>>,
/// 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<Vec<u8>>,
}
/// Permission level for comments on a post
@ -900,6 +990,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 {
@ -943,6 +1040,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<u8>,
/// AEAD ciphertext under KDF(V_x, slot_binder_nonce, "sign"); 48B
/// (32B sealed priv_x ed25519 seed + 16B tag).
pub sign_ciphertext: Vec<u8>,
}
/// 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<u8>,
}
/// 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<WrapSlot>,
/// Initially empty. Receivers accumulate revocations as diffs
/// arrive; the on-wire t=0 snapshot is empty.
#[serde(default)]
pub revocation_list: Vec<RevocationEntry>,
}
/// Author-controlled engagement policy for a post
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommentPolicy {
@ -981,6 +1137,50 @@ pub enum BlobHeaderDiffOp {
WriteCommentSlot { post_id: PostId, slot_index: u32, data: Vec<u8> },
/// Add new encrypted comment slots (each 256 bytes)
AddCommentSlots { post_id: PostId, count: u32, slots: Vec<Vec<u8>> },
/// 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<u8>,
},
/// 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<u8>,
},
/// FoF Layer 4: in-place wrap_slot replacement for leaked-V_me
/// scenarios. The author re-seals the slot at `slot_index` under a
/// fresh V_x (or a different held V_x), invalidating the leaked
/// key's read access to this specific post. The corresponding
/// pub_x in pub_post_set is also replaced so future comments must
/// sign under the new keypair. Comments signed under the OLD
/// pub_x at this slot are NOT auto-deleted by this op — use a
/// revocation diff alongside if comment cleanup is desired.
FoFKeyBurn {
post_id: PostId,
slot_index: u32,
new_pub_x: [u8; 32],
new_wrap_slot: WrapSlot,
burned_at_ms: u64,
/// 64-byte ed25519 sig by post author over
/// (post_id || slot_index_le || new_pub_x || canonical(new_wrap_slot) || burned_at_ms_le).
author_sig: Vec<u8>,
},
/// Unknown ops from newer protocol versions — silently ignored
#[serde(other)]
Unknown,

View file

@ -1,6 +1,6 @@
[package]
name = "itsgoin-desktop"
version = "0.6.2"
version = "0.7.0"
edition = "2021"
[lib]

File diff suppressed because one or more lines are too long

View file

@ -256,6 +256,10 @@ async fn post_to_dto(
Some(text) => ("encrypted-for-me".to_string(), Some(text.to_string())),
None => ("encrypted".to_string(), None),
},
// FoF Layer 3: FoFClosed body. Decrypted is None from the sync
// feed-pre-decrypt helper; the frontend calls read_fof_closed_body
// for any post with visibility == "fof-closed" to fill in the body.
PostVisibility::FoFClosed => ("fof-closed".to_string(), None),
};
let recipients = match vis {
PostVisibility::Encrypted { recipients } => {
@ -346,6 +350,10 @@ async fn decrypt_just_created(
None
}
}
// FoF Layer 3: FoFClosed body decrypt happens via the dedicated
// async read_fof_closed_body command. This sync helper returns
// None and the frontend dispatches the FoF read explicitly.
PostVisibility::FoFClosed => None,
}
}
@ -910,6 +918,7 @@ async fn post_to_dto_batch(
Some(text) => ("encrypted-for-me".to_string(), Some(text.clone())),
None => ("encrypted".to_string(), None),
},
PostVisibility::FoFClosed => ("fof-closed".to_string(), None),
};
let recipients = match vis {
PostVisibility::Encrypted { recipients } => {
@ -1089,6 +1098,187 @@ async fn list_ignored_peers(state: State<'_, AppNode>) -> Result<Vec<IgnoredPeer
Ok(out)
}
// --- FoF Layer 1: Vouches ---
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct VouchGivenDto {
node_id: String,
display_name: String,
granted_at_ms: u64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct VouchReceivedDto {
node_id: String,
display_name: String,
epoch: u32,
received_at_ms: u64,
}
#[tauri::command]
async fn 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.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<Vec<VouchGivenDto>, 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<Vec<VouchReceivedDto>, 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())
}
// --- FoF Layer 2: comment-gated post + commenting ---
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct FoFPostCreatedDto {
post_id: String,
}
#[tauri::command]
async fn create_post_with_fof_comments(
state: State<'_, AppNode>,
content: String,
) -> Result<FoFPostCreatedDto, String> {
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())
}
// FoF Layer 3: Mode 1 (FoFClosed) — encrypted body + FoF comments.
#[tauri::command]
async fn create_post_fof_closed(
state: State<'_, AppNode>,
content: String,
) -> Result<FoFPostCreatedDto, String> {
let node = get_node(&state).await;
let (post_id, _post, _cek) = node
.create_post_fof_closed(content)
.await
.map_err(|e| e.to_string())?;
Ok(FoFPostCreatedDto { post_id: hex::encode(post_id) })
}
/// Returns the decrypted body of a FoFClosed post if any local persona
/// can unlock it. `None` means "ciphertext only" (not in the FoF set).
#[tauri::command]
async fn read_fof_closed_body(
state: State<'_, AppNode>,
post_id_hex: String,
) -> Result<Option<String>, String> {
let node = get_node(&state).await;
let pid = parse_node_id(&post_id_hex)?;
node.read_fof_closed_body(&pid).await.map_err(|e| e.to_string())
}
// FoF Layer 4: V_me lifecycle + cascade + key-burn.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct VmeRotatedDto {
new_epoch: u32,
}
#[tauri::command]
async fn rotate_v_me(state: State<'_, AppNode>) -> Result<VmeRotatedDto, String> {
let node = get_node(&state).await;
let new_epoch = node.rotate_v_me().await.map_err(|e| e.to_string())?;
Ok(VmeRotatedDto { new_epoch })
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CascadeRevokeResultDto {
posts_revoked: usize,
}
#[tauri::command]
async fn cascade_revoke_v_me_epoch(
state: State<'_, AppNode>,
retired_epoch: u32,
reason_code: u8,
) -> Result<CascadeRevokeResultDto, String> {
let node = get_node(&state).await;
let n = node.cascade_revoke_v_me_epoch(retired_epoch, reason_code)
.await
.map_err(|e| e.to_string())?;
Ok(CascadeRevokeResultDto { posts_revoked: n })
}
#[tauri::command]
async fn key_burn_post_slot(
state: State<'_, AppNode>,
post_id_hex: String,
slot_index: u32,
new_v_x_hex: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let pid = parse_node_id(&post_id_hex)?;
let new_v_x_bytes = hex::decode(&new_v_x_hex)
.map_err(|e| format!("invalid new_v_x hex: {}", e))?;
let new_v_x: [u8; 32] = new_v_x_bytes.as_slice().try_into()
.map_err(|_| "new_v_x must be 32 bytes".to_string())?;
node.key_burn_post_slot(pid, slot_index, &new_v_x).await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn list_follows(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
let node = get_node(&state).await;
@ -3103,6 +3293,18 @@ pub fn run() {
ignore_peer,
unignore_peer,
list_ignored_peers,
vouch_for_peer,
revoke_vouch_for_peer,
list_vouches_given,
list_vouches_received,
create_post_with_fof_comments,
comment_on_fof_post,
revoke_fof_commenter,
create_post_fof_closed,
read_fof_closed_body,
rotate_v_me,
cascade_revoke_v_me_epoch,
key_burn_post_slot,
list_circles,
create_circle,
delete_circle,

View file

@ -1,6 +1,6 @@
{
"productName": "itsgoin",
"version": "0.6.2",
"version": "0.7.0",
"identifier": "com.itsgoin.app",
"build": {
"frontendDist": "../../frontend",

View file

@ -42,12 +42,12 @@ 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.
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 (400500 keys × 400500 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.
---
@ -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,9 +81,11 @@ 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.
---

View file

@ -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 23, which use HMAC prefilter over `V_x`), vouch grants have no prior shared secret. Readers pay a full X25519 scalar-mult per wrapper per persona. Cost is acceptable at realistic vouch-set sizes (<40ms for 600 trials).
- **Scan is follow-gated by default.** Clients auto-scan bio posts of followed personas. For non-followed personas, scanning is a manual "check this person's bio for a vouch" gesture. Rationale: vouches that would be relevant to a reader overwhelmingly come from people the reader follows.
- **Wrapper count padded to fixed buckets (64 / 128 / 256 / 512).** Dummy wrappers are random bytes shaped identically to real wrappers. Prevents observers from reading vouch-set size off the bio post.
- **Wrapper order shuffled on every publish.** Prevents positional inference of which vouchee slot changed between bio-post revisions.
- **Epoch tag is part of the key.** Each `V_x` has associated `(owner_id, epoch)` so receivers can distinguish fresh from stale when the voucher rotates.
- **Keyring is per-persona, not per-device.** Multi-persona users have independent keyrings. Layer 3 reader logic iterates personas when trial-decrypting.
- **`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.
---
@ -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?)
key_material BLOB(32),
created_at_ms INTEGER,
is_current INTEGER, -- 1 for the active V_me, 0 for retained past epochs
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
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<Wrapper>, // padded to next bucket in {64, 128, 256, 512}
}
Wrapper {
ciphertext: [u8; 32], // HPKE-sealed V_me (32B key)
tag: [u8; 16], // AEAD auth tag
}
```
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<VouchGrantBatch>,
bio_epoch: u32, // monotonic per-persona; incremented on every bio-post revision
}
```
Adding/removing a vouchee is a bio-post update: new batch, new `batch_eph_pub`, wrappers re-shuffled, `bio_epoch` incremented, republish. Propagates via the standard bio-post CDN path.
**Incremental grants via bio-post comments** (deferred): Scott's variant of appending additional wrappers as author-only comments on the bio post (to avoid full republish on every small change) is a future optimization. v1 ships with full republish per change; simpler mental model and keeps the wire contract symmetric. Revisit if bio-post bandwidth becomes a concern.
---
## Reader / scanner behavior
### On follow (new)
1. Fetch the followed persona's latest bio post.
2. If `vouch_grants.is_some()` AND `(scanner_persona, bio_author, bio_epoch)` not in scan cache:
- For each persona `P` in the reader's persona set:
- For each wrapper in the batch:
- Derive `shared_secret = X25519(P.x25519_priv, batch_eph_pub)`.
- Derive `key, nonce = HKDF(shared_secret, info="itsgoin/vouch-grant/v1/" || bio_post_id, ...)`.
- Attempt `AEAD-Open(wrapper.ciphertext, wrapper.tag, key, nonce)`.
- On success: extract `V_me`, insert into `vouch_keys_received` as `(holder=P, owner=bio_author, epoch=v_x_epoch)`. Record scan-cache hit.
3. If no wrapper unlocked across any persona: record scan-cache miss. Done.
### On bio-post update (existing follow)
Same as above, but trial only the wrappers that are new relative to the prior bio_epoch. Since wrappers are shuffled on every publish, "new wrappers" = all wrappers on every update. **TBD**: whether to persist the exact wrapper-ciphertext set per bio_epoch so deltas can be computed, or just rescan the full batch (pay the cost, skip the bookkeeping). Lead leaning: **rescan full batch**; the cost (~40ms × number of followed personas publishing updates) is tolerable and the bookkeeping adds state-sync complexity.
### On manual "check bio" gesture
Treat as new-follow scan. User-invoked. Useful when a non-followed persona has vouched for the user.
### Multi-persona trial order
Personas iterated in stable sorted order (by persona_id). First unlock wins; stop scanning wrappers once any persona succeeds on any wrapper (the wrapper was addressed to that persona specifically — other wrappers in the batch are for other recipients).
**TBD — OPUS**: confirm early-exit is correct. One caveat: the voucher may grant to MULTIPLE of the reader's personas (e.g., "I vouch for your work-persona AND your private-persona"). In that case we want to unlock all matching wrappers, not just the first. Lead leaning: do not early-exit on persona; continue scanning with remaining personas so all applicable grants are received.
---
## Cost analysis
Per wrapper per persona: 1 X25519 scalar mult + 1 HKDF + 1 AEAD-Open attempt. ~60µs on ARM mobile; faster on desktop.
Typical bio update: 200 wrappers × 3 personas = 600 trials ≈ 36ms. Invisible to user.
Padded bucket at 512 × 3 personas = 1536 trials ≈ 90ms. Still acceptable for a one-time ingest per bio update.
Scan-cache prevents re-pay on unchanged bios.
---
@ -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 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.
---
## 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.

View file

@ -1,28 +1,46 @@
# 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)` 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 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 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.
- **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 +57,251 @@ 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<WrapSlot>, // 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]>, // real pub_x + dummy pubkeys, random-order; .len() == wrap_slots.len()
wrap_slots: Vec<WrapSlot>, // real slots + rand(32..=128) dummy slots, shuffled
revocation_list: Vec<RevocationEntry>, // initially empty; appended via signed diffs
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<u8>, // 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<u8>, // 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<CommentId>
}
```
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. 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.
---
## 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 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.
**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), 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:** 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.
---
## 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.
- **Rate limiting.** Operational knob. Per-`commenter_id` + per-`pub_x_index` caps in propagation-node config. Out of spec.
- **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.
---
## 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, 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 (retroactive delete + forward).
- Access-grant author comment mechanism for post-hoc read widening.
- Per-post ephemeral-keypair generation.
- 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.

View file

@ -1,8 +1,10 @@
# Layer 3 — Mode 1: `FOF_CLOSED` Posts
**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`.
**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`.
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 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.
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.
---
@ -20,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).
- **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.
- **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).
---
@ -119,21 +122,27 @@ 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.
## Resolved (2026-04-24)
- **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.
---
## 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 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 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).

View file

@ -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 12. 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<WrapSlot>, // 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<PostId>,
}
```
---
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).

View file

@ -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.

View file

@ -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.
---

View file

@ -486,11 +486,18 @@ function renderPost(post, index) {
visBadge = '<span class="vis-badge vis-encrypted-mine">encrypted</span>';
} else if (post.visibility === 'encrypted') {
visBadge = '<span class="vis-badge vis-encrypted">encrypted</span>';
} else if (post.visibility === 'fof-closed') {
visBadge = '<span class="vis-badge vis-encrypted-mine">fof-closed</span>';
}
let displayContent;
if (post.visibility === 'encrypted' && !post.decryptedContent) {
displayContent = '<span class="encrypted-placeholder">(encrypted)</span>';
} else if (post.visibility === 'fof-closed' && !post.decryptedContent) {
// FoF Layer 3: render as locked placeholder. The frontend fires
// an async read_fof_closed_body call after first paint to fill
// in the body for FoF readers (see loadFeed below).
displayContent = `<span class="encrypted-placeholder" data-fof-closed-pending="${post.id}">(fof-closed — unlocking…)</span>`;
} else if (post.decryptedContent) {
displayContent = escapeHtml(post.decryptedContent);
} else {
@ -579,6 +586,29 @@ function renderMessage(post, index, showFollowBtn) {
</div>`;
}
/// FoF Layer 3: post-render pass — find all "(fof-closed — unlocking…)"
/// placeholders in the given root and dispatch read_fof_closed_body.
/// Replaces each placeholder's text with the decrypted body, or with a
/// "not in this FoF set" notice if the caller can't unlock.
async function unlockFoFClosedPlaceholders(rootEl) {
const placeholders = rootEl.querySelectorAll('[data-fof-closed-pending]');
for (const el of placeholders) {
const postId = el.dataset.fofClosedPending;
try {
const body = await invoke('read_fof_closed_body', { postIdHex: postId });
if (body) {
el.textContent = body;
el.classList.remove('encrypted-placeholder');
} else {
el.textContent = '(fof-closed — not in this FoF set)';
}
} catch (_) {
el.textContent = '(fof-closed — error)';
}
delete el.dataset.fofClosedPending;
}
}
function renderEmptyState(message, hint) {
return `<div class="empty-state">
<div class="empty-state-icon"></div>
@ -793,6 +823,9 @@ async function loadFeed(force) {
}
} else {
feedList.innerHTML = filterBanner + posts.map(renderPost).join('');
// FoF Layer 3: any rendered FoFClosed post enters with a
// placeholder body; trigger the async unlock pass to fill in.
unlockFoFClosedPlaceholders(feedList);
if (authorFilterNodeId) {
const clearBtn = document.getElementById('clear-author-filter');
if (clearBtn) clearBtn.onclick = clearAuthorFilter;
@ -947,6 +980,7 @@ async function loadMyPosts(force) {
myPostsList.innerHTML = renderEmptyState('No posts yet', 'Write your first post above!');
} else {
myPostsList.innerHTML = mine.map(renderPost).join('');
unlockFoFClosedPlaceholders(myPostsList);
if (_myPostsHasMore) {
const sentinel = document.createElement('div');
sentinel.id = 'myposts-scroll-sentinel';
@ -1633,8 +1667,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 +1690,9 @@ async function openBioModal(nodeId, preloadedName) {
${following
? `<button id="bio-unfollow" class="btn btn-ghost btn-sm">Unfollow</button>`
: `<button id="bio-follow" class="btn btn-primary btn-sm">Follow</button>`}
${isVouched
? `<button id="bio-revoke-vouch" class="btn btn-ghost btn-sm">Revoke Vouch</button>`
: `<button id="bio-vouch" class="btn btn-ghost btn-sm">Vouch</button>`}
<button id="bio-message" class="btn btn-ghost btn-sm">Message</button>
${isIgnored
? `<button id="bio-unignore" class="btn btn-ghost btn-sm">Unignore</button>`
@ -1700,6 +1739,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 = `<p class="status-err">Error: ${e}</p>`;
}
@ -2603,8 +2663,40 @@ 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).
if (selectedFiles.length > 0 || params.postingIdHex) {
toast('FoF posts with attachments or non-default persona not yet supported.');
postBtn.disabled = false;
return;
}
const created = await invoke('create_post_with_fof_comments', {
content: params.content,
});
result = { id: created.postId };
} else if (commentPerm === 'fof_closed') {
// FoF Layer 3 / Mode 1: body itself encrypted under the
// gating CEK. Non-FoF observers see only ciphertext;
// FoF readers unlock + decrypt on render via
// read_fof_closed_body.
if (selectedFiles.length > 0 || params.postingIdHex) {
toast('FoFClosed posts with attachments or non-default persona not yet supported.');
postBtn.disabled = false;
return;
}
const created = await invoke('create_post_fof_closed', {
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);
@ -2618,9 +2710,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', {
@ -3062,7 +3154,16 @@ 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();
// FoF Layer 4: rotate button wires once per settings open.
const rotateBtn = document.getElementById('rotate-v-me-btn');
if (rotateBtn && !rotateBtn._wired) {
rotateBtn.addEventListener('click', rotateVMe);
rotateBtn._wired = true;
}
}
});
});
});
@ -3618,6 +3719,79 @@ async function loadIgnored() {
}
}
async function rotateVMe() {
const btn = document.getElementById('rotate-v-me-btn');
const status = document.getElementById('rotate-v-me-status');
if (!btn) return;
if (!confirm('Rotate your vouch key? A new key will be issued to all current vouchees via your next bio post. Old posts remain readable to anyone who held the old key — cascade-revoke separately if you want to cut off old-content access.')) return;
btn.disabled = true;
status.textContent = 'Rotating…';
try {
const { newEpoch } = await invoke('rotate_v_me');
status.textContent = `Rotated → epoch ${newEpoch}`;
toast(`Vouch key rotated to epoch ${newEpoch}`);
loadVouches();
} catch (e) {
status.textContent = '';
toast('Error: ' + e);
} finally {
btn.disabled = false;
}
}
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 = '<p class="empty-hint" style="margin:0">No vouches given.</p>';
} else {
givenEl.innerHTML = given.map(v => {
const label = escapeHtml(v.displayName || v.nodeId.slice(0, 12));
const icon = generateIdenticon(v.nodeId, 18);
return `<div class="peer-card" data-node-id="${v.nodeId}">
<div class="peer-card-row">${icon} ${label}</div>
<div class="peer-card-actions">
<button class="btn btn-ghost btn-sm revoke-vouch-btn" data-node-id="${v.nodeId}" data-name="${label}">Revoke</button>
</div>
</div>`;
}).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 = '<p class="empty-hint" style="margin:0">No vouches received.</p>';
} else {
recvEl.innerHTML = received.map(v => {
const label = escapeHtml(v.displayName || v.nodeId.slice(0, 12));
const icon = generateIdenticon(v.nodeId, 18);
return `<div class="peer-card" data-node-id="${v.nodeId}">
<div class="peer-card-row">${icon} ${label}</div>
<div class="peer-card-actions empty-hint" style="font-size:0.75rem">epoch ${v.epoch}</div>
</div>`;
}).join('');
}
} catch (e) {
givenEl.innerHTML = `<p class="status-err">Error: ${e}</p>`;
}
}
// --- Release announcement / upgrade banner ---
async function loadUpgradeBanner() {
const banner = document.getElementById('upgrade-banner');

View file

@ -104,6 +104,8 @@
<select id="comment-perm-select" title="Comment permission">
<option value="public">Comments: All</option>
<option value="followers_only">Comments: Followers</option>
<option value="friends_of_friends">Comments: Friends of Friends</option>
<option value="fof_closed">Body+Comments: FoF only (Mode 1)</option>
<option value="none">Comments: Off</option>
</select>
<select id="react-perm-select" title="React permission">
@ -198,6 +200,26 @@
<div id="ignored-list" style="text-align:left"></div>
</div>
<div class="section-card" style="text-align:center">
<h3 style="margin-bottom:0.25rem">Vouches</h3>
<p class="empty-hint" style="margin-bottom:0.5rem">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 &mdash; the revoked friend keeps access to your existing posts, but not future ones.</p>
<div style="display:flex;gap:1rem;text-align:left">
<div style="flex:1;min-width:0">
<h4 style="margin:0 0 0.4rem;font-size:0.9rem">Given</h4>
<div id="vouches-given-list"></div>
</div>
<div style="flex:1;min-width:0">
<h4 style="margin:0 0 0.4rem;font-size:0.9rem">Received</h4>
<div id="vouches-received-list"></div>
</div>
</div>
<div style="margin-top:0.75rem;padding-top:0.75rem;border-top:1px solid var(--border)">
<p class="empty-hint" style="margin-bottom:0.4rem">Rotate your vouch key (V_me) to issue a fresh key to all your current vouchees. Old posts remain readable by anyone who held the old key &mdash; use "Cascade revoke" afterward to actively cut off comment access to your old posts.</p>
<button id="rotate-v-me-btn" class="btn btn-ghost btn-sm">Rotate my vouch key</button>
<span id="rotate-v-me-status" class="empty-hint" style="margin-left:0.5rem;font-size:0.8rem"></span>
</div>
</div>
<div class="section-card" style="text-align:center">
<h3 style="margin-bottom:0.25rem">Updates</h3>
<p class="empty-hint" style="margin-bottom:0.5rem">Network-wide release announcements are signed by the bootstrap anchor and arrive via the CDN. Choose which channel to follow.</p>

View file

@ -6,6 +6,187 @@ 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 26 untouched in this pass.
**Stopping point**: Scott asked to hold merges until Layers 26 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 46 iterations.
**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:
**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 14 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:
- **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)`.
**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.
**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 46 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:
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`
**Started**: late April 23 UTC

View file

@ -68,6 +68,7 @@
<a href="#erasure-cdn">18b. Erasure-Coded CDN Replication</a>
<a href="#sync">19. Sync Protocol</a>
<a href="#encryption">20. Encryption</a>
<a href="#fof">20a. Friend-of-Friend Visibility</a>
<a href="#deletes">21. Delete Propagation</a>
<a href="#privacy">22. Social Graph Privacy</a>
<a href="#multidevice">23. Multi-Device Identity</a>
@ -1304,6 +1305,7 @@ END</code></pre>
<tr><td><code>Public</code></td><td>None</td><td>Unlimited</td></tr>
<tr><td><code>Encrypted { recipients }</code></td><td>~60 bytes per recipient</td><td>~500 (256KB cap)</td></tr>
<tr><td><code>GroupEncrypted { group_id, epoch, wrapped_cek }</code></td><td>~100 bytes total</td><td>Unlimited (one CEK wrap for the group)</td></tr>
<tr><td><code>FoFClosed { pub_post_set, wrap_slots }</code> <span class="badge badge-planned">Planned</span></td><td>~154 bytes per admitted V_x, padded</td><td>Bucketed (8/16/32/64/128/256, then +128 steps)</td></tr>
</table>
<h3>PostId integrity</h3>
@ -1350,6 +1352,105 @@ END</code></pre>
<p>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. <code>CircleProfileUpdate</code> (<code>0xB4</code>) wire message. Public profiles can be hidden (<code>public_visible=false</code> strips display_name/bio).</p>
</section>
<!-- 20a. Friend-of-Friend Visibility -->
<section id="fof">
<h2>20a. Friend-of-Friend Visibility <span class="badge badge-planned">Planned</span></h2>
<div class="note">
<strong>Distinct from directory vouches.</strong> The "FoF vouch" described here is a <em>cryptographic</em> primitive for post readership and comment gating (per-persona symmetric key <code>V_me</code>). It is unrelated to the <em>directory vouch</em> system in <a href="#directory">section 27</a>, which governs discovery-layer trust and bot-ring resistance. The two share vocabulary but operate at different layers.
</div>
<h3>The problem</h3>
<p>Existing visibility variants gate by explicit recipient lists (<code>Encrypted{recipients}</code>) or named-circle membership (<code>GroupEncrypted</code>). 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 <em>cryptographic reachability</em> through a unilateral vouch graph, with no recipient IDs on the wire and no centrally-computed membership lists.</p>
<h3>User-facing model</h3>
<p>Authors pick one of four visibility levels at compose time:</p>
<table>
<tr><th>Level</th><th>Reaches</th></tr>
<tr><td><strong>Public</strong></td><td>All readers (unchanged)</td></tr>
<tr><td><strong>Friends-only</strong></td><td>Personas you have vouched for</td></tr>
<tr><td><strong>Friends-of-Friends</strong></td><td>Your vouchees + every vouchee of anyone who vouched for you (emergent FoF)</td></tr>
<tr><td><strong>Custom</strong> <span class="badge badge-planned">v2</span></td><td>Author-selected subset of held vouch keys</td></tr>
</table>
<h3>Core primitives</h3>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong><code>V_me</code></strong>: a 32-byte symmetric key owned by each persona. Distributed to everyone the persona vouches for. Anyone holding <code>V_alice</code> can decrypt wrap slots Alice sealed under it.</li>
<li><strong>Keyring</strong>: per-persona, holds the persona's own <code>V_me</code> plus every <code>V_x</code> received from vouchers. The union of these is what makes FoF reach emergent: an author wraps a post slot under every <code>V_x</code> they hold, and any reader whose keyring intersects with that set can decrypt.</li>
<li><strong>Wrap slot</strong>: an anonymous AEAD ciphertext in the post header, sealed under one <code>V_x</code>. Carries the post's CEK plus a per-slot signing key. No recipient ID visible on the wire.</li>
<li><strong>Prefilter tag</strong>: a 2-byte <code>HMAC(V_x, post_id)[:2B]</code> on each slot. Readers precompute tags for keys in their keyring and skip non-matching slots, cutting trial-decrypt cost ~65,000&times; per post.</li>
<li><strong><code>pub_post_set</code></strong>: 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.</li>
</ul>
<h3>Distribution: vouches ride bio posts</h3>
<p>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 <strong>recipient anonymity</strong> &mdash; wrapper ciphertext reveals nothing about the recipient's pubkey. Each wrapper is 48&nbsp;bytes (32-byte sealed <code>V_me</code> + 16-byte AEAD tag); one shared ephemeral pubkey per batch.</p>
<p>Readers auto-scan bio posts of accounts they follow, trial-decrypting each wrapper against each of their personas. Cost is ~60&micro;s per wrapper per persona on mobile &mdash; a 200-wrapper bio scanned against 3 personas is ~36&nbsp;ms. The bio's <code>VouchGrantBatch</code> is padded with dummy wrappers and shuffled on every publish so observers see neither vouch-set size nor change-targets.</p>
<h3>Two modes</h3>
<div class="card">
<h3>Mode 2: Public body, FoF-gated comments</h3>
<p>Body is plaintext in the CDN (indexable, cacheable, shardable &mdash; 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 <code>CommentPolicy::FriendsOfFriends</code> variant.</p>
</div>
<div class="card">
<h3>Mode 1: <code>FoFClosed</code> (body + comments encrypted)</h3>
<p>New <code>PostVisibility::FoFClosed</code> 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 <code>wrap_slots</code>.</p>
</div>
<h3>CDN-level comment verification</h3>
<p>Each wrap slot is dual-derived: one half yields the shared CEK (read capability), the other yields a per-V_x Ed25519 <em>signing</em> keypair (<code>priv_x</code> sealed inside the slot; the corresponding <code>pub_x</code> published in the post's <code>pub_post_set</code>). Comments declare which <code>pub_x</code> signed them and carry the signature.</p>
<p>Propagation nodes verify three things before forwarding a comment:</p>
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><code>pub_x_index</code> points to a valid entry in <code>pub_post_set</code>.</li>
<li>That entry is not in the post's <code>revocation_list</code>.</li>
<li>The comment's <code>group_sig</code> validates against that <code>pub_x</code>.</li>
</ol>
<p>Any failure &rarr; 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.</p>
<h3>Privacy properties</h3>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Unilateral</strong>: vouching is a one-way act, no handshake. The FoF graph forms without bilateral negotiation.</li>
<li><strong>Graph-private</strong>: wrap slots carry no recipient IDs. Observers cannot enumerate who can read a post.</li>
<li><strong>Bucketed padding</strong>: 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&nbsp;KB then +256&nbsp;KB steps for bodies). Observers learn the bucket, not the position within it.</li>
<li><strong>Recipient anonymity on vouch distribution</strong>: HPKE key privacy ensures bio-post wrappers do not reveal recipients.</li>
<li><strong>Per-post chain pseudonym</strong> (accepted tradeoff): the <code>pub_x_index</code> 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.</li>
</ul>
<h3>Revocation &amp; key lifecycle</h3>
<p>Three complementary mechanisms:</p>
<div class="card">
<h3>Per-post comment revocation (default)</h3>
<p>The author signs a <code>RevocationEntry</code> for a specific <code>pub_x</code> on a specific post. Propagation nodes <strong>delete</strong> locally-stored comments by that signer, remove the entry from <code>pub_post_set</code>, append to <code>revocation_list</code>, and forward the diff. Retroactive: the mesh self-cleans as the diff sweeps through.</p>
</div>
<div class="card">
<h3>Persona-wide <code>V_me</code> rotation</h3>
<p>To remove a vouchee, the persona generates <code>V_me_new</code> and issues it to every non-revoked vouchee via the next bio-post batch. Revoked vouchees retain <code>V_me_old</code>. Old posts (sealed under <code>V_me_old</code>) stay readable by anyone who holds the old key &mdash; <strong>grandfathered by default</strong>. The CDN does not auto-cascade revocations.</p>
<p>Receivers append new <code>V_me</code> values to their keyring (chain), so newly-issued keys do not invalidate prior ones &mdash; the receiver keeps holding the old key for reading historical content.</p>
</div>
<div class="card">
<h3>Opt-in cascade + key burn (advanced)</h3>
<p>If the author wants to cut off comment authority on old posts after a rotation, they cascade by publishing per-<code>pub_x</code> revocations on each affected post. Local-only <code>own_post_slot_provenance</code> table maps "which pub_x was sealed under which V_me" so the author can target precisely.</p>
<p>For the rare case of a leaked <code>V_me</code>, an optional <code>KeyBurnDiff</code> 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.</p>
</div>
<h3>Performance budget</h3>
<p>At realistic scale (~500 vouchees, ~500 wrap slots per post), reader-side decryption uses an unlock cache: the first time persona <code>P</code> decrypts a post from author <code>A</code> via key <code>V_x</code>, the <code>(P, V_x)</code> tuple is cached. Subsequent posts from <code>A</code> try that key first &mdash; one HMAC + one AEAD attempt in the hot path. Full scan only on cache miss; newly-received <code>V_x</code> triggers a retry sweep over the unreadable-posts table.</p>
<h3>Post-quantum readiness</h3>
<p>Body encryption, wrap slots, and HKDF/HMAC are all symmetric &mdash; PQ-safe. Comment signing uses Ed25519 today; the spec shape is algorithm-agnostic so ML-DSA-65 (~2&nbsp;KB pubkey, ~3.3&nbsp;KB signature) can substitute, optionally with a Merkle-commit variant on <code>pub_post_set</code> to keep header size bounded.</p>
<h3>Implementation</h3>
<p>Full crypto-level byte layouts, data models, wire-format additions, ship criteria, and integration tests are specified in <code>docs/fof-spec/</code>. The implementation is layered for bottom-up shipping:</p>
<table>
<tr><th>Layer</th><th>Scope</th><th>Status</th></tr>
<tr><td>1</td><td>Vouch primitive (V_x keys, keyring, bio-post HPKE wrappers, scan policy)</td><td><span class="badge badge-planned">Planned</span></td></tr>
<tr><td>2</td><td>Mode 2: public posts with FoF-gated comments, CDN-level verification</td><td><span class="badge badge-planned">Planned</span></td></tr>
<tr><td>3</td><td>Mode 1: <code>FoFClosed</code> body + wrap slots + anonymous prefilter</td><td><span class="badge badge-planned">Planned</span></td></tr>
<tr><td>4</td><td>Rotation, revocation, key lifecycle (grandfather + cascade + key-burn)</td><td><span class="badge badge-planned">Planned</span></td></tr>
<tr><td>5</td><td>Unlock cache + prefilter optimization (perf-critical at scale)</td><td><span class="badge badge-planned">Planned</span></td></tr>
</table>
</section>
<!-- 21. Delete Propagation -->
<section id="deletes">
<h2>21. Delete Propagation</h2>
@ -1578,6 +1679,10 @@ END</code></pre>
<p><em>The directory is an opt-in convenience layer for discovery and creator protection. It is not node access &mdash; 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.</em></p>
</div>
<div class="note">
<strong>Distinct from FoF cryptographic vouches.</strong> The "vouch" described in this section is a <em>directory-layer</em> trust signal governing discovery and bot-ring resistance. It is unrelated to the <em>cryptographic vouch</em> (<code>V_me</code>) in <a href="#fof">section 20a</a>, which gates post readership and commenting via per-persona symmetric keys. The two share vocabulary but operate at different layers.
</div>
<h3>Scope</h3>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Whitelist track</strong> &mdash; discoverability, vouch-based entry, graph-scoped visibility.</li>

View file

@ -46,6 +46,41 @@
<p style="margin: 0.5rem 0 0 0; font-size: 0.8rem; color: var(--text-muted);">v0.5.3 is kept online only as an upgrade bridge &mdash; it no longer connects to the live network.</p>
</div>
<h2 style="margin-top: 2rem;">v0.7.0 &mdash; May 15, 2026</h2>
<p style="color: var(--text-muted); font-size: 0.85rem;">Friend-of-Friend gating is live. Posts can be public to readers but FoF-gated for comments (Mode 2), or fully FoF-gated for body + comments (Mode 1, <code>FoFClosed</code>). The CDN verifies comment signatures before propagating, killing the bandwidth-DoS attack a single admitted FoF member could otherwise mount. Vouches distribute via HPKE-sealed wrappers in your bio post &mdash; no DMs, no recipient IDs on the wire.</p>
<div class="downloads">
<a href="itsgoin-0.7.0.apk" class="download-btn btn-android">
Android APK
<span class="sub">v0.7.0</span>
</a>
<a href="itsgoin_0.7.0_amd64.AppImage" class="download-btn btn-linux">
Linux AppImage
<span class="sub">v0.7.0</span>
</a>
<a href="itsgoin-cli-0.7.0-linux-amd64" class="download-btn btn-linux">
Linux CLI / Anchor
<span class="sub">v0.7.0</span>
</a>
<a href="itsgoin-0.7.0-windows-x64-setup.exe" class="download-btn btn-windows">
Windows Installer
<span class="sub">v0.7.0</span>
</a>
</div>
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 1rem;">
<li><strong>Vouch primitive (V_me).</strong> Every persona owns a 32B symmetric key issued to vouchees via HPKE-sealed anonymous wrappers in the voucher's bio post. Receivers auto-scan followed bios; the keyring is per-persona.</li>
<li><strong>Mode 2: public posts, FoF-gated comments.</strong> Body indexable and CDN-shardable like any public post; comments encrypted under a per-post CEK only FoF members can unwrap. Non-FoF observers see only ciphertext.</li>
<li><strong>Mode 1: <code>FoFClosed</code> posts.</strong> Body itself encrypted under the FoF gating. Non-members propagate the ciphertext but cannot read it.</li>
<li><strong>CDN-level comment verification.</strong> Per-V_x signing keypair + propagation-node four-check accept rule. Any admitted FoF member who tries to flood junk gets dropped at first hop &mdash; not just at the author's render-time filter.</li>
<li><strong>Bucketed padding.</strong> Wrap-slot count and body size pad to power-of-2 buckets (up to 256 / 256KB) then linear steps above. Observer learns the bucket, never the real count.</li>
<li><strong>Revocation + access-grant.</strong> Author can per-post revoke a chain (cascade-deletes stored comments by that signer); or post-hoc grant access to a newly-vouched persona without republishing.</li>
<li><strong>V_me rotation, cascade, key-burn.</strong> Pure rotation grandfathers old content. Optional cascade revokes that chain across all the author's old posts. Key-burn swaps a single slot in-place for leaked-key scenarios.</li>
<li><strong>Unlock cache + retry sweep.</strong> First successful unlock from an author is cached; later posts hot-path to a single AEAD attempt. Posts no held V_x unlocks queue up; sweep on new V_x arrival.</li>
<li><strong>Pre-deploy hardening.</strong> Receive-path FoF wire-shape validation, queue-size caps, key-burn replay rejection (monotonic timestamps).</li>
</ul>
<p style="color: var(--text-muted); font-size: 0.85rem;">v0.7.0 is a wire-additive release: new <code>PostVisibility::FoFClosed</code> variant, new <code>BlobHeaderDiffOp::FoF{Revocation,AccessGrant,KeyBurn}</code>, new fields on <code>InlineComment</code> and <code>ProfilePostContent</code>. Old clients don't understand FoF gating; upgrade for FoF features. See <a href="design.html#fof">design.html section 20a</a> for the full architecture.</p>
<h2 style="margin-top: 2rem;">v0.6.2 &mdash; April 23, 2026</h2>
<p style="color: var(--text-muted); font-size: 0.85rem;">Every remaining persona-signed direct push is off the wire. Deletes, visibility changes, profile updates, and group-key distribution now travel as encrypted / signed posts through the CDN. Groups are a first-class primitive. Plus two pre-release fixes &mdash; an admin-forgery check on group keys and a cap on concurrent port-scan hole punches that explains the 10 Mbps upload storm some users saw on VPNs.</p>
@ -68,19 +103,7 @@
</a>
</div>
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 1rem;">
<li><strong>Deletes + visibility changes travel as signed control posts</strong> through the CDN. The <code>DeleteRecord</code> / <code>VisibilityUpdate</code> direct pushes are gone.</li>
<li><strong>Profile display data (name, bio, avatar) travels as a persona-signed profile post.</strong> Peer-visible names are back &mdash; but bound to the posting identity, not the network endpoint.</li>
<li><strong>Rich comments</strong> &mdash; a comment can reference a separate post for long bodies or attachments; inline preview is signed alongside the reference.</li>
<li><strong>Groups as a primitive</strong> &mdash; many-way posting anchored at a public root post. Circles remain one-way (admin-only).</li>
<li><strong>Group keys distribute as encrypted posts</strong> &mdash; the <code>GroupKeyDistribute</code> wire message is gone.</li>
<li><strong>Audience removed.</strong> Simpler social graph; anyone-can-send model via follows.</li>
<li><strong>PostPush / PostNotification wire messages retired</strong> &mdash; all content propagates via CDN.</li>
<li><strong>Port-scan hole punches are now capped at 1 concurrent</strong> &mdash; fixes sustained multi-Mbps upload on obfuscated VPNs after anchor connect.</li>
<li><strong>Outgoing-connect dedup</strong> &mdash; auto-reconnect, rebalance, and relay-introduction no longer race to the same peer.</li>
<li><strong>Security fix: group-key distribution verifies the claimed admin matches the post author</strong>, preventing a pollution attack where a peer who knows your posting id could overwrite your stored group key.</li>
</ul>
<p style="color: var(--text-muted); font-size: 0.85rem;">v0.6.2 is a wire-breaking fork from v0.6.1 (the retired message types are not optional). Upgrade both ends.</p>
<p style="color: var(--text-muted); font-size: 0.85rem;">v0.6.2 was the last release before FoF gating. v0.7.0 is wire-additive; v0.6.2 clients won't understand FoF posts but otherwise interop.</p>
<h2 style="margin-top: 2rem;">v0.6.1 &mdash; April 22, 2026</h2>
<p style="color: var(--text-muted); font-size: 0.85rem;">Network identity is now fully separated from posting identity on every install. Plus: Android auto-backup disabled by default, Reset actually resets, import preserves your personas, and display name is optional.</p>