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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
per-author feed filter, ignore primitive
The old People tab was built on network-layer presence (`is_online`,
`last_seen` from the mesh), which was lost when v0.6.1 anonymized the
network id from the posting id. Every named follow is authored under a
posting id that doesn't appear in the connection-layer tables; the
"Online" section listed nobody useful and Discover depended on the
same broken signal.
Replaced with signals derived from signed content:
- Following is sorted by most-recent-post timestamp (the real meaning
of "activity" in a post-anonymization world).
- Discover lists named peers we've received signed profile posts from
(via Phase 2d), filtered by follows / ignores / self.
- Click-a-name surfaces a bio modal with View Posts / Follow / Message
/ Ignore actions.
- Author-scoped feed filter (`View Posts` on any person) renders a
"Showing posts from X" banner with a Clear button.
- Ignore is a new local-only primitive; ignored peers' posts and
profiles are excluded everywhere and the ignored list is editable
in Settings.
Core changes:
- New `ignored_peers(node_id, ignored_at)` table + storage helpers
(`add_ignored_peer`, `remove_ignored_peer`, `list_ignored_peers`,
`is_ignored_peer`). Schema created fresh; no migration since the
table is purely additive and empty on prior installs.
- All 6 feed-query sites now also exclude `author IN ignored_peers`.
- New `Storage::last_activity_for_authors(&[NodeId])` — one batched
query returning max post timestamp per author, excluding non-feed
intents (Control / Profile / Announcement / GroupKeyDistribute).
- New `Storage::list_discoverable_profiles(&self_id)` — named profile
rows where node_id is not self, not in follows, not in ignored, and
`public_visible = 1`. Sorted by profile `updated_at` DESC.
- New `Storage::delete_setting(key)` — missing counterpart to
set/get.
- Node wrappers: `last_activity_for_follows`, `ignore_peer`
(also drops any follow + social route for the ignored peer),
`unignore_peer`, `list_ignored_peers`, `list_discoverable_profiles`.
- `list_follows` Tauri command now sources `last_activity_ms` from
the posts-driven batched query rather than the network peer record.
Tauri commands: `list_discover`, `ignore_peer`, `unignore_peer`,
`list_ignored_peers`.
Frontend:
- Following list: see-new-activity button pattern (staged data +
explicit user click to rearrange, so the list doesn't reorder under
a tap mid-scroll). Periodic people-tab polling stages + lights up
the button; clicking it re-renders.
- Discover: rewrites the old peer-table-based list to a profile-post
feed. Each card shows name + bio + profile-update age, plus Follow
/ Posts / Ignore actions.
- Bio modal: reuses the existing generic popover. Loads display name
+ bio via `resolve_display`, shows follow state, offers View Posts /
Follow-or-Unfollow / Message / Ignore-or-Unignore.
- Author filter: banner renders at the top of the feed when active;
clear button restores full feed. Filter state is a single `authorFilterNodeId`
field consumed by `filterFeedPosts`.
- Settings → Ignored section lists ignored peers with unignore buttons.
124 / 124 core tests pass.
Two bugs the v0.6.2 Discover story was going to expose:
1) Many named personas don't have a profile post yet, so the planned
profile-post-driven Discover listing would show them as headless.
Existing personas predate the Phase 2d profile-post primitive, and
imported personas arrive with a display_name but no matching post.
2) Fresh install always generates a blank disposable persona before
the user can pick fresh-vs-import. If the user picks import, that
blank persona lingers forever — visible in the Personas list, a
potential default, confusing.
Fixes:
Profile-post backfill (node.rs):
- New `Node::backfill_profile_posts_for_named_personas`: scans posting
identities, skips unnamed or already-covered ones, and emits a
signed profile post at the persona's own `created_at` timestamp. Run
once from `Node::open_with_bind` after migrations. Idempotent via
the new `Storage::has_profile_post_by_author` check. Chronology is
preserved (post timestamp matches persona creation), so a later
genuine profile update the user authors always wins the `> old_ts`
monotonicity check on receivers.
- `Node::create_posting_identity(name)`: if `name` is non-empty, emits
the profile post inline so new named personas are Discover-able the
moment they're created. Uses the new `publish_profile_post_as`
helper, which signs with the persona's own secret (not the default
posting secret) and propagates via `update_neighbor_manifests_as`.
First-run persona marker + targeted prune (node.rs + storage.rs + import):
- `Node::open_with_bind`'s auto-gen block now also writes
`first_run_auto_persona_id = <hex>` into the settings kv. This marks
the specific disposable persona from the fresh-install flow; later
prune logic uses this id, not a "any empty persona" rule, so manual
empty personas are never touched.
- `Node::try_prune_first_run_auto_persona`: deletes the marked id iff
all four gates pass — still exists, no display_name, no authored
posts, no authored reactions/comments, and it's no longer the
current default. Any one failure → clear the marker and keep the
persona. New storage helpers `has_any_post_by_author`,
`has_any_engagement_by_author` back the check.
- `set_profile` clears the marker when a non-empty name lands on the
marked persona (user claimed it).
- `storage.delete_setting(key)` — new one-line helper.
- `import_as_personas_cmd` in tauri-app calls the prune after import
completes; the cmd's return message reports "(cleared blank starter
persona)" when it fires.
- New `get_first_run_auto_persona_id` Tauri command so the frontend
can filter the blank persona out of the Personas list while it
still exists.
- Frontend `loadPersonas` filters the marker id out of
`personasCache` before rendering.
Tests: 124 / 124 core tests pass.
Two bugs ganging up:
1) Import ignored the intent field. `ExportedPost.intent` was always
serialized on export but the import path hardcoded every encrypted
post to `VisibilityIntent::Friends` (import.rs:308-311), discarding
whatever `ep.intent` said. DMs got misfiled as Friends.
2) The Messages tab filter only surfaces posts whose `intentKind` is
`direct` (or `unknown` with the right visibility shape). Posts with
`intentKind = friends` skip the filter — DMs became invisible after
an "everything" import, even though the rows were in the DB and the
per-persona decrypt loop would have resolved them to plaintext.
Fixes:
- `parse_exported_intent(raw, vis)` in import.rs: parses the Debug-format
intent string the export writes, handling Public / Friends / Circle /
Direct / Control / Profile / Announcement / GroupKeyDistribute. For
`Direct`, recovers the recipient list from `PostVisibility::Encrypted`
since the Debug format for `Vec<NodeId>` isn't cleanly parseable.
- Heuristic fallback when the export carries no intent (pre-v0.6.1
source DBs, where the intent column wasn't populated): Encrypted
posts with <=3 recipients are classified as `Direct`, larger
recipient lists stay `Friends`. DMs typically wrap to 1-2 people;
Friends wraps to every public follow.
- `StagedImport.posts` tuple grows an `intent` slot; the store step
uses the parsed/inferred intent instead of the hardcoded default.
One-time startup migration:
- Sweeps existing posts where `visibility_intent = "Friends"` and
visibility is Encrypted with <=3 recipients; rewrites to Direct.
Guarded by `mig_import_dm_fixup_v1` settings key so it runs once
per DB. Handles already-imported corrupt state so users don't need
to re-import.
Tests: 124 / 124 core tests pass.
The anchor rotated its network key on 2026-04-22 22:57 UTC during the
v0.6.1 upgrade (keeping the old key as its posting identity). The
DEFAULT_ANCHOR constant was never updated, so every v0.6.1 and v0.6.2
client has been pinning the old cert identity when connecting, producing
a TLS "UnknownIssuer" handshake error.
Symptom: fresh clients can't bootstrap; existing installs drop when the
anchor's old connection times out and can't re-handshake.
Verified: rebuilt CLI with the new constant successfully connects to
the anchor, completes the initial exchange, registers as a mesh peer,
and runs a pull sync.
Note: `DEFAULT_ANCHOR_POSTING_ID` in lib.rs still holds the OLD key
(17af14...) — that's correct, it's the anchor's posting identity used
to verify signed announcements, distinct from the network key used for
QUIC cert verification.
New primitive: `VisibilityIntent::Announcement`, a public post whose
author MUST be the hardcoded bootstrap anchor posting identity
(`DEFAULT_ANCHOR_POSTING_ID`) and whose content carries an ed25519
signature by that key. Forged announcements (any other author, or
bad signature) are rejected by `control::receive_post` before storage
— they never enter the DB and never propagate via neighbor-manifest
diffs. Only the real anchor can publish announcements, and it does so
sparingly as part of the release deploy flow.
Uses release announcements to drive an in-app upgrade banner:
- Anchor publishes a signed `{category:release, version, channel,
download_url, ...}` post during every deploy.
- Clients receive it via the normal CDN; `apply_announcement_if_applicable`
stores the latest-per-category/channel in the settings kv, keyed
e.g. `announcement:release:stable`.
- Welcome screen checks storage on startup; if the stored release
version > CARGO_PKG_VERSION on the user's selected channel, a banner
appears with a Download button that opens the system browser.
- Settings gets "Updates" section with Stable / Beta radio + Check-now
button + current status line.
Core:
- `DEFAULT_ANCHOR_POSTING_ID: NodeId` constant (32 bytes, the anchor's
current posting id — `17af141956ae...`).
- New `VisibilityIntent::Announcement` variant; feed filters in all 6
`get_feed*` / `list_posts*` query sites updated to also exclude the
new intent AND the pre-existing `GroupKeyDistribute` intent.
- `types::AnnouncementContent` + `ReleaseAnnouncement` structs.
- `crypto::{sign,verify}_announcement` — length-prefixed field digest
with a "has release" 1-byte flag.
- New `announcement` module with `verify_announcement_post`,
`apply_announcement_if_applicable`, `latest_release`,
`build_announcement_post`, and a `StoredAnnouncement` envelope saved
to settings so the UI can render without a full post scan.
- `Node::publish_announcement` refuses to run unless the default posting
id equals the anchor constant — accidental use on client installs
fails loud.
Wire / receive:
- `control::receive_post` verifies announcement signatures upfront
alongside Control and Profile. Same pattern; same guarantees.
CLI one-shots (no daemon):
- `itsgoin <data_dir> --print-identity` — prints network_id +
default_posting_id, exits.
- `itsgoin <data_dir> --announce --ann-category release
--ann-channel stable --ann-version X --ann-title ... --ann-body ...
--ann-url https://itsgoin.com/download.html` — builds + stores +
propagates the signed post, exits.
deploy.sh:
- Now runs the announce one-shot inside the anchor-restart window
(after binary swap, before start). The DB is free during that gap,
so the one-shot can write without conflicting with the running
daemon. The restarted daemon loads all storage on boot and serves
the new announcement to pulling peers.
Tauri IPC:
- `check_release_announcement(channel)` → Option<ReleaseAnnouncementDto>
— returns None when up-to-date.
- `get_update_channel` / `set_update_channel(channel)` — persists in
settings kv key `ui_update_channel`; defaults to stable.
- `open_url_external(url)` — desktop-only (xdg-open / open / cmd start);
refuses non-http(s) URLs. Android needs the opener plugin — TODO.
Frontend:
- Upgrade banner on the welcome screen, populated by
`loadUpgradeBanner()`. Hidden when no newer release is known.
- Settings → Updates section with Stable/Beta radio + Check-now button
+ current status line.
Tests: announcement signature roundtrip; non-anchor author rejection;
non-announcement intent is a no-op. 124 / 124 core tests pass.
The Phase 2f `canonical_root_post_id` column was added to both the
CREATE TABLE block (for fresh DBs) and a conditional ALTER TABLE (for
upgrading DBs). The matching CREATE INDEX, however, was inlined in the
CREATE TABLE block — which runs BEFORE the ALTER. On an upgrading DB
with the v0.6.1 schema, CREATE TABLE IF NOT EXISTS is a no-op against
the existing (old) table, so the table still lacks the column when the
index statement runs and SQLite bails with "Error code 1: SQL error or
missing database" at offset 74.
Caught on the v0.6.2 anchor deploy — the anchor died right after start
because its v0.6.1 DB couldn't apply migrations.
Fix: remove the index from the CREATE TABLE block; run an unconditional
`CREATE INDEX IF NOT EXISTS idx_group_keys_root ...` in the migration
section, after the conditional ALTER has added the column. Idempotent
in both paths — fresh DB (column from CREATE TABLE) and upgrading DB
(column from ALTER).
121 / 121 core tests pass.
Six phase commits landed for v0.6.2 (2b through 2g) plus three
pre-release fixes from the final audit pass:
- 2b: control-post flow (delete / visibility change) + retire BlobDeleteNotice
- 2c: remove audience primitive + retire PostPush / PostNotification /
AudienceRequest / AudienceResponse
- 2d: profile posts signed by the posting identity
- 2e: rich comments with ref_post_id + signed preview
- 2f: groups as a distinct primitive alongside circles
- 2g: GroupKeyDistribute → encrypted post (last persona-signed
direct push gone)
- audit fix: reject group-key distribution posts where the claimed
admin doesn't match the post author
- audit fix: cap concurrent port-scan hole punches at one (the
10 Mbps-on-VPN storm)
- audit fix: dedup concurrent outgoing connects to the same peer
Wire-breaking fork from v0.6.1. Retired message types 0x42
(PostNotification), 0x43 (PostPush), 0x44 (AudienceRequest), 0x45
(AudienceResponse), 0x95 (BlobDeleteNotice), 0xA0 (GroupKeyDistribute)
are not optional.
121/121 core tests pass.
Multiple code paths could each fire an outgoing connect to the same
peer simultaneously with no coordination: the existing
connections.contains_key() check under a lock, drop lock, connect
pattern leaves a window where another path passes its own check and
spawns a parallel attempt. Auto-reconnect, rebalance-slots, and the
relay-introduction target-side handler were the three identified races
(rank 1–3 in the pre-release audit). Observable as multiple
near-simultaneous "Auto-connected to peer [hex]" / "Target-side hole
punch succeeded to [hex]" log lines for the same peer.
Fix: add `pending_connects: Arc<std::sync::Mutex<HashSet<NodeId>>>` to
ConnectionManager plus a `PendingConnectGuard` RAII type. Entry to each
outgoing-connect path now acquires a guard via `try_begin_connect(peer)`
under the CM lock; the guard inserts the NodeId into the set and the
Drop impl removes it. Concurrent callers for the same peer see the
NodeId already in `pending_connects` (or already in the `connections` /
`sessions` maps) and return None, so they skip their attempt.
Scope:
- Only gates outgoing duplicates to the SAME peer. Different peers
connect independently. Inbound connections from the guarded peer are
not affected — the simultaneous-open race is still resolved by the
existing check-before-insert on registration.
- The std::sync::Mutex is held for a single O(1) hash op on
acquire / drop — never across an await — so the guard lifetime spans
the full connect attempt without blocking anything else.
Sites wired:
- Auto-reconnect after unexpected disconnect (connection.rs ~4492)
- Rebalance-slots outgoing loop (connection.rs ~8049)
- Relay-introduction target-side both handlers (connection.rs ~3945,
~5783)
Tests: `pending_connect_guard_gates_same_peer_and_releases_on_drop`
asserts second same-peer acquire is refused, different peers acquire
independently, and drop releases the slot. 121 / 121 core tests pass.
Two pre-release fixes found during audit.
1) GroupKeyDistribute admin forgery (critical)
`group_key_distribution::try_apply_distribution_post` trusted the
`admin` field inside the decrypted payload without verifying it
matched the post's author. Exploit: any peer who learns a victim's
posting NodeId (public — appears as a recipient on any DM/group
post) and observes a target group_id in the wild could craft an
encrypted distribution post claiming to be from the legitimate
admin. The victim's storage uses INSERT OR REPLACE on group_keys,
so a successful forgery would overwrite the victim's legitimate
group key record and stored seed, breaking future rotations / key
distributions from the real admin.
Fix: reject the distribution post when `content.admin != post.author`.
Added test `forged_admin_is_rejected` that seeds a legitimate
record, attempts a forgery, and asserts the legitimate record is
untouched.
2) Cap concurrent port-scan hole punches at 1 (bandwidth)
`hole_punch_with_scanning` fires ~100 QUIC ClientHellos/sec for up
to SCAN_MAX_DURATION_SECS (300s), ~1 Mbps per active scanner. With
no cap, the growth loop / anchor referrals / replication paths
could spawn several scanners at once and drive sustained multi-Mbps
upload — particularly pathological on obfuscated VPNs where every
probe stalls at a proxy timeout, explaining the reported 10 Mbps
sustained upload after anchor connect.
Fix: module-level `tokio::sync::Semaphore(1)` guarding entry to the
scanning loop. Second-and-beyond callers fall back to the cheaper
`hole_punch_parallel` (standard punching, no 100/sec port walk)
instead of spawning another scanner. Permit is held for the scanner
lifetime and released on return. Added unit test
`scanner_semaphore_caps_concurrent_scans_at_one`.
Both changes leave the successful-call path untouched (single scanner
still runs; legitimate key distributions still apply). 120 / 120 core
tests pass.
Removes the last persona-signed direct push on the wire. Group/circle
seeds no longer travel via the 0xA0 `GroupKeyDistribute` uni-stream from
admin to member. Instead the admin publishes an encrypted post containing
the seed + metadata; each member is a recipient; the post propagates via
the normal CDN. Members decrypt with their posting secret to recover the
seed.
Eliminates the wire-level coordination signal between an admin endpoint
and each member endpoint when a group is created, a member is added, or
a key is rotated.
Core pieces:
- New `VisibilityIntent::GroupKeyDistribute` variant.
- New `types::GroupKeyDistributionContent` — JSON payload inside the
encrypted post: group_id, circle_name, epoch, group_public_key, admin,
canonical_root_post_id, group_seed.
- New `group_key_distribution` module:
- `build_distribution_post(admin, admin_secret, record, group_seed, members)`
returns `(PostId, Post, PostVisibility::Encrypted)` — wraps the CEK
per member using standard `crypto::encrypt_post`.
- `try_apply_distribution_post(s, post, visibility, our_personas)`
iterates every posting identity's secret trying to decrypt; on
success stores `group_key` + `group_seed` and returns true.
- `process_pending(s, our_personas)` scans stored
GroupKeyDistribute-intent posts and applies any we can decrypt.
Node API:
- `add_to_circle`: builds a distribution post wrapping the current seed
to just the new member, stores with intent=GroupKeyDistribute, and
propagates via `update_neighbor_manifests_as` (no direct push).
- `create_group_key_inner`: at group creation, after wrapping keys for
every non-self member, builds one distribution post addressed to all
of them and propagates through the CDN.
- `rotate_group_key`: same pattern at epoch rotation.
- New `Node::process_group_key_distributions` — scans and applies.
`sync_all` now calls it automatically so seeds take effect right after
a pull cycle.
Removals (wire-breaking; v0.6.2 already forked):
- MessageType 0xA0 (`GroupKeyDistribute`), its payload struct, the
handler in connection.rs, and `Network::push_group_key` all deleted.
ConnectionManager's `secret_seed` (network secret) is no longer used for
group-key unwrapping — that shifted to posting secrets in the apply
pass, matching the v0.6.1+ identity split where group keys are wrapped
to posting NodeIds.
Tests: new `member_decrypts_and_applies` covers a recipient decrypting +
storing the seed and a non-recipient failing to apply. Workspace
compiles clean; 118 / 118 core tests pass on a stable run (pre-existing
flaky `relay_cooldown` test with a 1ms timing window is unrelated).
Introduces **groups** — a new many-way primitive anchored at a public root
post — reusing the existing circle encryption machinery. Circles stay
one-way (admin posts only, as before). Groups are distinguished from
circles by a single field: a non-null `canonical_root_post_id` on the
group-key record.
Type / schema changes:
- `GroupKeyRecord.canonical_root_post_id: Option<PostId>` (serde default).
When `Some`, the record represents a group rooted at that public post;
when `None`, it's a traditional circle.
- `group_keys` table gets a `canonical_root_post_id BLOB` column + an
index `idx_group_keys_root` on it. Migration added for upgraded DBs;
the CREATE TABLE statement carries the column so fresh DBs match.
Wire:
- `GroupKeyDistributePayload` gains an optional `canonical_root_post_id`
field. v0.6.1 peers will deserialize it as absent and continue treating
the record as a circle. All three sender sites (new-circle distribute,
add-member distribute, epoch-rotation distribute) pass the field through.
Storage:
- `create_group_key` / `get_group_key` / `get_group_key_by_circle` write
and read the new column. Added a shared `row_to_group_key` helper so the
two lookup functions don't drift.
- New `get_group_by_canonical_root(root_post_id)` — the inverse lookup
used by posting / retrieval flows.
Node API (new):
- `create_group_from_post(root_post_id, initial_members)` — creates a
backing circle named `group:<6-byte-hex-of-root>`, initializes the
group key with `canonical_root_post_id` set, and invites each initial
member (reusing `add_to_circle`'s wrap+distribute path so members get
the seed on the wire). Returns `(GroupId, circle_name)`.
- `post_to_group(root_post_id, content, attachments)` — any member with
the group seed can call this. Looks up the group by root, routes
through `create_post_with_visibility(Circle(name))` (which already
chooses `GroupEncrypted` when the seed is present), then stores a
`ThreadMeta` row linking the new post back to the canonical root so
retrieval can reconstruct the group.
- `list_group_posts_by_root(root_post_id)` — returns all contributions
via the ThreadMeta parent index. Callers decrypt normally; members see
full content, non-members see encrypted blobs.
Shared plumbing:
- `create_group_key_for_circle` now delegates to a shared
`create_group_key_inner(circle_name, canonical_root)` helper, keeping
one place where the record is constructed and the seed is persisted.
Notes:
- No crypto change: groups use the same `GroupEncrypted` primitive
circles already used. The "admin-only post" restriction on circles was
a UX choice, not a cryptographic limit — groups expose the many-way
path directly by letting any member with the seed call `post_to_group`.
- ThreadMeta is the clustering primitive. It already existed for split
comment threads; groups reuse it so the query pattern
("posts whose parent is root X") stays in one place.
- Frontend UI for groups is deferred — the backend surface is complete
and exercise-able via Tauri/CLI.
Tests: new storage test asserts canonical_root lookup round-trips and
that circles (no root) are invisible to the root lookup. 117 / 117 core
tests pass.
A comment can now reference a separate Post that carries the full body
(long text, attachments, rich formatting). The inline comment's `content`
becomes a short preview string; the referenced post propagates through the
normal CDN and readers fetch it lazily when rendering the expanded view.
Type change:
- `InlineComment` gains `ref_post_id: Option<PostId>` (#[serde(default)]).
When None, `content` is the full comment text (v0.6.1 shape — unchanged on
the wire). When Some, `content` is the preview.
Signature binding:
- `crypto::sign_comment` / `verify_comment_signature` now take
`ref_post_id: Option<&PostId>`. The signed digest appends
`b"ref:" || ref_post_id` only when a ref is present, so plain comments
produce the same digest as the v0.6.1 scheme and remain verifiable
without a migration. When a ref is present the signature binds it, so a
peer can't strip or swap the reference without re-signing.
Storage:
- `comments` table gets a `ref_post_id BLOB` column (nullable). Added to
both the CREATE TABLE statement and a conditional ALTER TABLE migration
so upgraded DBs pick it up automatically.
- `store_comment`, `get_comments`, `get_comments_with_tombstones` read and
write the column.
Node API:
- `comment_on_post` stays as the plain-comment entry point (calls the
inner helper with `ref_post_id = None`).
- New `comment_on_post_with_ref(post_id, preview, ref_post_id)` for rich
comments. Both share a single inner helper that signs, stores, and
propagates via BlobHeaderDiff.
connection.rs BlobHeaderDiff handler passes `comment.ref_post_id.as_ref()`
to the signature verify so forged or rewritten refs are rejected.
Tests: new crypto test asserting the signature binds ref_post_id (strip /
swap / drop all fail); new storage test asserting ref_post_id roundtrips
through live + tombstone reads. 116 / 116 core tests pass.
Client-side UX (pulling the ref post on expand, composing rich comments)
is frontend work that will land with the next UI iteration.
Display metadata (display_name, bio, avatar_cid) is no longer broadcast via
the ProfileUpdate direct push when the user edits their name. It travels as
a signed public post with VisibilityIntent::Profile, authored by the posting
identity, and propagates through the normal neighbor-manifest CDN path.
Core pieces:
- `types::ProfilePostContent` — JSON payload serialized into the post's
content field. Ed25519 signature by the posting secret over length-prefixed
display_name + bio + 32-byte avatar_cid (or zeros) + timestamp.
- `crypto::{sign,verify}_profile` with strict length prefixing to prevent
extension attacks.
- New `profile` module: `build_profile_post`, `verify_profile_post`,
`apply_profile_post_if_applicable`. Last-writer-wins by timestamp.
- `control::receive_post` now verifies Profile-intent posts upfront (same
as Control) so bogus signatures never enter storage, and applies them
after store so the `profiles` row updates atomically with the insert.
Node API:
- `Node::set_profile` rewritten: builds a signed Profile post, stores under
intent=Profile, applies it locally (upserts the profiles row keyed by the
posting identity), then propagates via `update_neighbor_manifests_as`.
Stops calling `network.push_profile` — display changes no longer trigger
a direct wire push.
- `Node::my_profile` / `has_profile` read by `default_posting_id` instead of
`node_id`, matching where the row is written now.
ProfileUpdate (0x50) and push_profile stay for now — they still carry
routing-only data (anchors, recent_peers, preferred_peers) via
`sanitized_for_network_broadcast` and are used by `set_anchors` /
`set_public_visible`. Removing the routing fields would be a broader
cleanup; scoped out of this phase.
Tests: roundtrip verify+store, wrong-author rejection (not stored), and
older-timestamp ignored. 114 / 114 core tests pass.
v0.6.2 wire fork: every persona-identifying direct push is gone. Public posts
propagate only through the CDN (pull + header-diff neighbor propagation).
Encrypted posts propagate only through pull with merged author-or-recipient
match. There is no remaining sender→recipient traffic correlation signal on
the wire for content.
Protocol (network-breaking):
- Retire MessageType 0x42 (PostNotification), 0x43 (PostPush),
0x44 (AudienceRequest), 0x45 (AudienceResponse). Their payload structs are
deleted along with the handlers and senders.
- SocialDisconnectNotice (0x71) / SocialAddressUpdate (0x70) sender
functions targeting audience are deleted; the existing handlers stay
(both already dead code on the send side).
Core removals:
- `push_to_audience`, `notify_post`, `push_delete`,
`push_disconnect_to_audience`, `push_address_update_to_audience`,
`send_audience_request`, `send_audience_response`, `send_to_audience` —
all gone from network.rs.
- `handle_post_notification` removed from connection.rs.
- `request_audience`, `approve_audience`, `deny_audience`,
`remove_audience`, `list_audience_members`, `list_audience` removed from
Node.
- `audience_pushed` step removed from post creation.
- `AudienceDirection`, `AudienceStatus`, `AudienceRecord`,
`AudienceApprovalMode` removed from types.
- Storage: `store_audience`, `list_audience`, `list_audience_members`,
`remove_audience`, `row_to_audience_record`, `audience_crud` test, the
`audience` CREATE TABLE, and the audience-dependent social route rebuild
branch all removed. Upgraded DBs retain the orphan `audience` table;
nothing touches it.
Follow-on cleanups:
- `SocialRelation::Audience` + `::Mutual` collapsed into just `Follow`.
The Display/FromStr impl accepts legacy "audience"/"mutual" strings from
pre-v0.6.2 DBs and maps them to Follow.
- Blob-eviction priority function drops the audience factor; relationship
is now own-author vs followed vs other. Tests updated accordingly.
- `CommentPermission::AudienceOnly` → `FollowersOnly`. Check uses the
author's public follows (`list_public_follows`) rather than a separate
audience table. `ModerationMode::AudienceOnly` similarly renamed.
- Follow/unfollow routines simplified: no audience downgrade logic;
unfollow removes the social route entirely.
UI:
- CLI: `audience*` commands removed.
- Tauri: `AudienceDto`, `list_audience`, `list_audience_outbound`,
`request_audience`, `approve_audience`, `remove_audience` commands
removed from invoke_handler. Frontend: audience panel and audience/mutual
badges removed; compose permission dropdown shows "Followers" instead of
"Audience"; `loadAudience` is a no-op stub that hides any leftover DOM.
Tests: 111 / 111 core tests pass.
Breaking change: v0.6.2 nodes won't interoperate with v0.6.1 for delete
propagation, visibility updates, direct post push, post notifications, or
audience requests. Upgrade both ends.
Replaces two persona-signed direct pushes with CDN-propagated control posts:
a single `VisibilityIntent::Control` post type whose content is a signed
`ControlOp` the receiver verifies and applies. Deletes and visibility updates
now flow through the same neighbor-manifest CDN path as regular content — no
direct recipient push needed for persona-signed ops.
Core pieces:
- `VisibilityIntent::Control` + `VisibilityIntent::Profile` variants.
- `ControlOp::DeletePost` / `ControlOp::UpdateVisibility` (JSON, ed25519-signed
by the target post's author over op-specific byte strings).
- `crypto::{sign,verify}_control_{delete,visibility}` signing primitives.
- `control::build_delete_control_post` + `build_visibility_control_post`
for authors to construct control posts.
- `control::receive_post` — unified incoming-post path used by all 6 receive
sites. Verifies control signatures BEFORE storing, so bogus controls never
enter storage and can't be re-propagated via neighbor-manifest diffs.
- `control::apply_control_post_if_applicable` — executes the op under the
same storage guard as the insert.
Feed filter:
- Feeds (`get_feed`, `get_feed_page`, `list_posts_page`,
`list_posts_reverse_chron`) now exclude `Control` and `Profile` posts so
they propagate + tombstone without surfacing.
- Sync/export path (`list_posts_with_visibility`) keeps its own unfiltered
query so control posts still propagate via CDN.
Wire protocol:
- `SyncPost` carries `intent: Option<VisibilityIntent>` so control posts
arrive with their intent preserved.
- `BlobDeleteNotice` (0x95) removed — orphan blobs on remote holders evict
naturally via LRU rather than via a persona-signed push. Code path,
payload, sender, tests, and `delete_blob_with_cdn_notify` all gone.
Tests: control delete roundtrip (apply + tombstone) and wrong-author
rejection (not stored, not applied). 112/112 core tests pass.
Phase 3 merged-pull was shipped when network_id == posting_id (pre-
v0.6.1), so adding our_node_id to the pull query's follows list was
enough to trigger recipient-match on DMs. v0.6.1 broke that:
- Fresh installs generate independent network + posting keys; DMs are
encrypted to posting id, but the pull query carried network id.
Recipient-match never fired. Non-follower DMs never reached.
- Upgraders rotated the network key; DMs addressed to the old key
(now the default posting id) never matched either.
Fix: pull-request builders now include every entry from
list_posting_identities() in query_list. Network id is omitted — it
is never an author and never a wrapped_key.recipient, so adding it
would only leak the boundary without ever matching.
Four call sites fixed: Network::pull_from_peer, and
ConnectionManager::{pull_from_peer, pull_from_peer_unlocked, and the
post-notification-triggered pull path}.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AndroidFs trait must be in scope for show_open_file_dialog / read
method resolution — AndroidFsExt alone isn't enough. Matches the
existing share_file pattern that does use both traits.
Caught on the v0.6.1 Android build; previous cargo check only
validated the desktop target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reset All Data:
- Sentinel now written at the app-level data_dir instead of the
active identity's subdir. On Android the subdir path was never
checked at startup, so reset silently did nothing.
- On detection, wipe EVERYTHING under the app data_dir: identity.key,
itsgoin.db + WAL + SHM, blobs, all identity subdirs. Next launch
is truly fresh — new network key, new posting key, no prior data.
First-run name:
- Display name is optional. Blank submits as anonymous.
- First-run modal + profile overlay placeholder updated to say
"Display name (optional)".
Android file picker:
- pick_file on Android now uses tauri-plugin-android-fs'
show_open_file_dialog (Storage Access Framework OPEN_DOCUMENT).
Read the picked URI's bytes, stage them in the app's private cache
as a timestamped file, return the staged path so existing
import_* code can read it as a regular filesystem path.
- Zip filter passes application/zip + application/octet-stream (some
file providers report the latter for .zip).
Android auto-backup off:
- AndroidManifest: allowBackup="false", fullBackupContent="false",
dataExtractionRules pointing at new data_extraction_rules.xml
- New data_extraction_rules.xml excludes all domains from both
cloud-backup and device-transfer. Prior default (allowBackup=true)
silently replicated identity.key to Google Drive for any user with
cloud backup on — which effectively published the root secret to
a third party without asking. Users who want off-device backup use
Settings -> Export (explicit zip they control).
Import as personas:
- New import_as_personas function in core/import.rs + new
import_as_personas_cmd Tauri IPC.
- Reads identity.key from the bundle and adds it to posting_identities
as a persona. Also reads posting_identities.json (v0.6+ bundles)
and adds each entry. Dedupes by node_id.
- Posts stay AS-AUTHORED — original post_id, original author,
original signatures, original wrapped_key recipients. No
re-encryption. Content encrypted to any of the imported keys
becomes decryptable because we now hold the secrets.
- Blobs, follows, profiles copied across.
- If current device has <=1 posting identity (the fresh-install one)
and the bundle brings more, auto-switch the default to the first
imported persona. Covers first-run-then-import flow cleanly.
Import wizard UI:
- New default option: "Restore as personas" — posts keep original
authors; source's keys become personas you can post as.
- Old "Merge with decryption key" retained as "Consolidate under
current default persona (requires source key)" for the case where
a user intentionally abandons a persona.
- "Public posts only" and "Add as separate identity" retained.
deploy.sh made executable (chmod +x tracked).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fresh installs now generate two independent ed25519 keys — one as the
network (QUIC) identity in identity.key, and a SEPARATE one as the
default posting identity in posting_identities. They share nothing.
v0.6.0 upgraders: if the default posting key equals the network key
(the state Phase 4's migration left us in), rotate identity.key to a
fresh random value. The old key stays in posting_identities as the
default persona — peers keep seeing the same author on our posts; only
the QUIC NodeId changes. A one-shot reconnect-churn on upgrade, then
back to normal.
Storage:
- Drop seed_posting_identity_from_network (v0.6.0-specific helper)
- Add count_posting_identities()
Node::open_with_bind:
- Load identity.key (network secret — network-only from now on)
- Ensure posting_identities has at least one entry; if empty, generate
an INDEPENDENT random posting key as the default
- Detect default-posting-key == network-key collision and rotate
identity.key, logging the migration
- default_posting_id / default_posting_secret resolved from storage
Decrypt:
- decrypt_posts now takes &[PostingIdentity] and tries each held
persona as a recipient candidate. Past DMs to any persona on this
device (including ones added via Import as personas) decrypt
correctly. Callers pre-load list_posting_identities() alongside
group_seeds.
- decrypt_just_created looks up the author's specific posting identity
rather than assuming the default.
Profile broadcasts (wire-level privacy):
- Profile stays keyed by network NodeId — the field is load-bearing
for N1/N2/N3 social routing (anchors/recent_peers/preferred_peers
feed build_preferred_tree_for and peer-anchor reachability lookup).
- But push_profile and InitialExchange now STRIP display_name, bio,
and avatar_cid before sending, via new
PublicProfile::sanitized_for_network_broadcast(). A name attached to
the network id would correlate the QUIC endpoint to a human. Until
v0.6.2 introduces persona-signed profile posts, peers display
authors as hex.
Auto-follow only the default posting id (network id is never an
author, following it would be dead weight).
All 111 core tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phases 1-5 of the Identity Architecture rollout are on master; Phase 6
(rotating DM IDs) is deferred. This bumps the protocol-breaking
release: v0.5 and v0.6 cannot interoperate.
Packaging:
- Cargo + tauri.conf: 0.5.3 -> 0.6.0
- deploy.sh: drop -beta filename suffix
- download.html: single v0.6.0 section with fork notice at the top;
changelog entry summarising the five phases and the schema migration;
Phase 6's rotating-DM-ID status noted as deferred
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Settings > Personas:
- List all held posting identities with display_name + truncated nodeId
- Default badge; Set-default / Delete buttons per non-default persona
- "New Persona" modal prompts for a display name and creates via IPC
Compose box:
- A #persona-select dropdown appears when 2+ personas exist
- doPost attaches postingIdHex to create_post / create_post_with_files
when a non-default persona is selected
Tauri:
- create_post and create_post_with_files take an optional
posting_id_hex; when present they route through create_post_as,
otherwise through the default create_post_with_visibility
- PostDto gains asPersona: name of the authoring posting identity if
the author matches any of our held personas
- is_me now recognises ALL our posting identities, not just the
network key (both post_to_dto and post_to_dto_batch)
Feed:
- Per-post "(you) as <PersonaName>" label on own posts authored by a
non-default persona
- Persona filter pill row above the feed (hidden for single-persona
users); pills toggle between All and each persona; matches when
post.author or post.recipients contains the selected posting id
- Applied after loadFeed initial render and after appendFeedPage so
filter survives infinite-scroll
App.js:
- personasCache + loadPersonas() loaded on startup so compose picker
is populated before the Feed tab mounts
- loadPersonas() also called when Settings tab opens
Backend was unchanged; only the UI and IPC surface expanded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Users can now hold multiple posting identities on one device and
publish content under any of them. Each persona has its own ed25519
key; peers see them as distinct authors with no link back to the
device's network identity.
Node methods:
- list_posting_identities() -> Vec<PostingIdentity>
- create_posting_identity(display_name) — generates a fresh ed25519
key, persists, auto-follows self
- delete_posting_identity(node_id) — refuses to delete the default
- set_default_posting_identity(node_id) — validates identity exists;
Node's cached default_posting_id/secret picks up on next restart
- create_post_as(posting_id, content, intent, attachments) — routes
through a shared create_post_inner that takes posting_id +
posting_secret as parameters
Post creation pipeline:
- create_post_with_visibility now delegates to create_post_inner
using default_posting_id/secret
- create_post_inner threads posting_id / posting_secret through
every content-signing, encryption, manifest, blob-header, and
CDN-manifest step — the persona is fully honored end to end
- update_neighbor_manifests now takes a posting_id param too, so
posts from persona X only update neighbor manifests for X's own
prior posts
Tauri IPC:
- list_posting_identities / create_posting_identity /
delete_posting_identity / set_default_posting_identity
- create_post_as with posting_id_hex + the same visibility params
as create_post
CLI:
- personas / create-persona <name> / delete-persona <id>
- post-as <posting_id> <text>
Smoke-tested two-persona scenario:
- A creates "Work" persona; posts from default and Work
- B follows both; pulls from A; gets all three posts
- Authors are AB84BA... (Work) and 7CD949... (default) — distinct
on the wire
Frontend UX (Settings > Personas, compose picker, filter pills,
merged feed labels) is scoped as a separate commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Decouple the signing identity from the network identity. This phase
ships the plumbing only — every device still has exactly one posting
identity, copied from the network key on first 0.6.3 launch so all
existing signed content keeps verifying. Phase 5 builds the
multi-persona UX on top.
Types:
- New PostingIdentity struct: { node_id, secret_seed, display_name,
created_at }
Storage:
- New posting_identities(node_id, secret_seed, display_name,
created_at) table
- Methods: upsert / get / list / delete posting identities;
get/set default posting id (stored in settings)
- seed_posting_identity_from_network: idempotent migration inserts
the network key as the single posting identity and sets it default
on first 0.6.3 launch
Node:
- default_posting_id + default_posting_secret fields populated on
startup via the migration
- All content signing / encryption / key wrapping now uses
default_posting_secret; the old Node.secret_seed field is gone
(iroh holds the network secret internally)
- author field on all locally-created content is now
default_posting_id (equal to node_id for upgraders until Phase 5
introduces separate personas)
- Auto-follow-self covers both network_id and default_posting_id
(same in 0.6.3, may diverge in 0.6.4+)
Export/import:
- Bundle now includes posting_identities.json in
IdentityOnly / PostsWithIdentity / Everything scopes
- restore_posting_identities(zip, storage) reads and upserts on
import
Smoke-tested:
- Fresh 0.6.3 install: posting_identities seeded from network key;
default set; new post's author = default_posting_id = network_id
- Two-node pull sync: B pulls A's post, signature verifies across
the wire
- 111 core tests pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A non-follower can now receive DMs addressed to them via a normal pull
cycle, with no distinguishable "searching for DMs" traffic pattern —
the pull query is a uniform list of NodeIds that the server matches
against both authors and wrapped-key recipients.
Schema (migrations on first 0.6.2 launch):
- New post_recipients(post_id, recipient) index table with index on
recipient column
- Seed migration scans existing encrypted posts, extracts recipients
and group members from visibility JSON, populates the index
Write path:
- store_post_with_visibility / store_post_with_intent populate
post_recipients on successful insert
- update_post_visibility rebuilds the index for the updated post
- apply_delete cascade-removes post_recipients entries
Server pull handler (should_send_post):
- Renamed semantic: requester_follows → query_list. Contains every
NodeId the client wants posts for (follows + own NodeId).
- Encrypted/GroupEncrypted posts match if ANY recipient / group
member is in query_list (previously only if == requester).
- Wire protocol unchanged — the same PullSyncRequestPayload.follows
field now carries both follow targets and own NodeId indistinguishably.
Client pull paths (all three call sites in network.rs + connection.rs):
- Always append own NodeId to the query list before sending pull sync.
Storage helper:
- get_post_ids_for_recipients(nids) — bulk IN-match using the
idx_post_recipients_recipient index, for future SQL-side filtering.
Tests:
- should_send_post's recipient tests updated to pass query_list
containing requester (matches new contract).
- Added encrypted_post_matches_via_query_list_even_for_third_party_recipient
proving the server matches on any recipient in the list, not just
the requester itself.
All 111 core tests pass. Smoke-tested end-to-end: A posts encrypted
DM to B; B connects + syncs; B decrypts and reads DM; both sides'
post_recipients correctly populated on store.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>