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