Commit graph

12 commits

Author SHA1 Message Date
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
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
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
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
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