Diagnostics follow the new topology: network summary shows mesh count against
the cap plus temp referral count (Preferred/Local/Wide tiles are gone), the
connections list labels slots mesh vs temp referral, and peers can show an N4
reach badge. Verified against the Rust DTOs (mesh_count/mesh_cap/temp_count/
n4_distinct, MeshSlot Display -> "mesh"/"temp") and the style.css classes.
Cargo.lock picks up the 0.8.0-alpha version bump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
Version bumped in all five places (core/cli/tauri-app Cargo.toml,
tauri.conf.json, Android versionName + versionCode 8000).
download.html carries the v0.8.0-alpha release block: registry + greeting
comments as the headline, the manifest privacy fix and persona-split bug
family, comment ingest verification, and the protocol slimming. States
plainly that v0.8 cannot talk to v0.7.x and the anchor moves with it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
Topology rework. The mesh's job is width; depth now comes from knowledge.
Slots:
- Local/Wide collapse into ONE mesh pool: 20 desktop / 15 mobile
- Temp referral slots +4..+10 ABOVE the cap (10 desktop / 4 mobile), carry no
knowledge exchange, graduate into a freed mesh slot or expire (5 min TTL),
never evict an established mesh peer
- Fixes the Wide-never-fills bug: outbound paths hardcoded Local while the
growth gate counted Local only, so growth stopped at 71 and 20 slots were
inbound-only. Also closes register_connection paths that enforced no cap.
Knowledge — two-pool uniques announce (replaces N1/N2/N3 share lists):
- Pool 1 (forwardable): our N0-N2 uniques, deduped. Receiver stores shifted one
bounce deeper as N1-N3, reporter-tagged.
- Pool 2 (terminal): our N3, deduped against pool 1. Receiver stores as N4,
USED for search/resolution but NEVER re-announced. No N5 anywhere.
- Uniques merge mesh peers + social directs + CDN file authors + holder peers,
so receivers cannot tell how we know an ID
- Anchor entries carry addresses; every other entry is a bare ID. The pools
double as the anchor directory.
- Dedup at both store and announce; per-bounce caps; 2 MB payload ceiling
- Device-tiered depth: mobile holds 3 bounces and drops the terminal pool
Anchor convection (register loop retired — 0xC0 AnchorRegister deleted):
- Rolling in-memory caller window (no registration DB): a caller gets the 2
most recent viable prior callers, its address goes to the next 2, then
rotates out; still-connected callers preferred; RelayIntroduce coordinated
when both ends are live
- Request classes: entry (<2 connections) always served; top-up refused
cheaply under load, and the refusal feeds the adaptive weights
- Stochastic per-disconnect action {nothing | anchor intro | mesh intro} with
weights from local anchor density + refusal feedback. No thresholds, no
jitter timer. Recovery (mesh < 2) stays immediate and non-stochastic.
- Anchors mined from the uniques pools incl. retained pools of disconnected
peers; known_anchors demoted to a bootstrap cache
Security fixes from review: remote address poisoning (hearsay anchors could be
dialled — known_anchors/is_anchor now written only from completed handshakes),
N4 leaking back into announcements via the anchor mirror (infinite propagation),
missing payload size caps, unbounded candidate scoring, and a bulk SQLite write
held under the ConnectionManager mutex.
deploy.sh: version regex now captures pre-release suffixes (0.8.0-alpha would
have truncated to 0.8.0 and broken every artifact path), announce channel
derives from the suffix, and the anchor runs --publish-registry at genesis.
228 core tests; a3 integration 9/9; new c_topology_test.sh 33/33 (both
independently re-run). EDM corpse, session-relay gating, Iteration A/B intact.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
A1 — ID-class bug family (v0.6.1 persona split left network-NodeId comparisons
where posting IDs live). ~14 sites fixed with multi-persona semantics ("author is
one of MY posting identities", not == default):
- revoke_post_access / revoke_circle_access (revocation was broken for every post)
- group key create/add-member/rotate (add-member distribution now works)
- circle profile set/delete/get, encrypted-attachment CEK unwrap
- receipt/comment slot authorship, replication cycle, blob eviction own-tier
- orphaned GroupKeyRequest/Response (0xA1/0xA2) deleted (no send side existed)
A2 — Manifest privacy: AuthorManifest.author_addresses removed from struct AND
signature digest; CdnManifest tree-era fields dropped. Startup migrations re-sign
own manifests, purge unverifiable foreign copies, strip persona fields from legacy
network-id profile rows. Posting identities are now location-anonymous.
A3 — Discovery & first contact:
- Open-slot comments: derivable slot V_x (blake3 from author + slot_binder_nonce)
so strangers pass the CDN comment-verification gate; OpenSlotDecl in FoF gating
- Greetings: HPKE-style sealed GreetingBody (return_path + fresh reply_pubkey),
throwaway outer ID, size-bucketed; messaging-first (Reply/Dismiss, no vouch)
- Registry: frozen canonical registry post + REGISTRY_POST_ID, signed registration
comments (self-certifying deletes), newest-wins per persona, --publish-registry
- Comment TTL: expires_at_ms inside signed digest v2 (context bump), rand 30-365d
ordinary / fixed 30d registrations, expiry sweep on the eviction cycle
- Single accept_incoming_comment() gate wired into all three ingest sites, closing
a pre-existing hole where both pull paths stored comments with no verification
- UI: consent panel (visible/listed/greetings, active choice at first publish),
registry search in Discover, sealed "Say hi" compose, greetings inbox
Security fixes from review: forged-tombstone injection, delete-by-sender-trust,
remote SetPolicy poisoning, stored XSS (escapeHtml quotes, 7 pre-existing sites),
greeting off-switch now revokes prior bios' slots.
No PoW (rejected: inverted cost). Session-relay gating untouched.
201 core tests pass; CLI + desktop build; 3-node integration green.
DEPLOY GATE: wire-incompatible with v0.7.3 — ALPN bump to itsgoin/4 lands in
Iteration B. Do not build/deploy/anchor-swap before that.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
Bandwidth + bootstrap hardening on top of v0.7.2. Wire-compatible with
v0.7.0/v0.7.1/v0.7.2; no protocol changes.
EDM port scanner DISABLED
- hole_punch_with_scanning() now does only single quick punch + parallel
punch over 30s window. The EDM port-scanner branch is gone from the
live path because per-probe endpoint.connect() amplifies catastrophically:
iroh accumulates every connect() target into a per-endpoint paths set
and probes them all under QUIC NAT-traversal in the background. A
100-probes/sec / 5-min scan inserted ~30k paths; iroh probed all of
them. Observed at 22MB/s outbound from one client — DoS-grade.
- Scanner body preserved as edm_port_scan_disabled_v0_7_3() with all
supporting helpers (PortWalkIter, scanner_semaphore, role-based
scanner/puncher split, found_tx/found_rx channel pattern,
deadline + tokio::select! orchestration) marked #[allow(dead_code)].
Refactor target: replace per-probe endpoint.connect() with raw
socket.send_to() so probes don't enter iroh's path store.
Bootstrap probing batched
- New probe_anchors_batched() helper: 3 anchors in flight at a time,
2s stagger between batch dispatches, 10s per-anchor timeout, no abort
on success. First success unblocks the bootstrap flow; remaining
probes continue in background and fill peer connections naturally.
- Phase 2 (bootstrap fallback) still only fires when every discovered
anchor failed — preserves load-distribution intent. Replaces the
sequential 50s+ timeout cascade users observed with old data dirs.
Stale-anchor self-pruning
- New storage.get_known_anchor_last_seen() and storage.delete_known_anchor().
- maybe_prune_stale_anchor(): when a probe fails AND last_seen_ms > 3 days,
delete the entry from known_anchors immediately. Recoverable anchors
(failed once, succeeded recently) are preserved. Self-healing for old
data dirs whose discovered anchors point to keypairs that rotated
months ago.
Android close button kills NodeService
- New NodeService.stopFromNative() Kotlin static method called via JNI
from android_wifi::stop_node_service(). exit_app invokes it on Android
before app.exit(0). Previously the button ended the Activity but the
foreground service kept networking running.
Cosmetic
- Power-icon SVG (inline) replaces ⏻ so Android webviews lacking
U+23FB don't render a missing-image tofu box.
Docs
- design.html section 11 rewritten for portmapper (UPnP+NAT-PMP+PCP,
v0.7.2) including per-platform contract and bidirectional anchor
watcher.
- design.html section 10 marks session relay as opt-in (v0.7.2) and EDM
scanner as disabled-pending-refactor (v0.7.3).
- download.html carries v0.7.3 release notes.
- MEMORY.md updated; older v0.7.0/v0.7.1 status sections condensed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Network/reachability improvements + a relay-privacy fix. Wire-compatible
with v0.7.0/v0.7.1; no protocol changes.
- Replace hand-rolled UPnP (igd-next) with the portmapper crate. All three
protocols (UPnP-IGD / NAT-PMP / PCP) run in parallel, auto-renew
internally. PCP adds IPv6 firewall pinholes and works on iOS without
the multicast entitlement. Pulled in transitively via iroh already, no
net dep growth.
- Android: UPnP/PCP/NAT-PMP attempted on WiFi/Ethernet with a
WifiManager.MulticastLock acquired for the lifetime of the mapping.
Cellular skipped early (no UPnP/PCP gateway, avoid 3s discovery waste).
- TCP port-mapping gate removed for mobile — phones with permissive NAT
can now serve HTTP for direct browser fetches.
- Anchor reachability watcher (bidirectional): clears is_anchor after
>5min of no port mapping; restores it when the mapping comes back.
Network roams self-heal without restart. Mobile never auto-anchors.
- Session relay opt-in restored. relay.session_relay_enabled setting
defaults OFF (anchors included — servers shouldn't silently burn
bandwidth either). Gates both serving (can_accept_relay_pipe) and
using (auto-fallback in node.rs). UI toggle in Settings. Relay-style
signaling (RelayIntroduce / worm_lookup / N1-N3 shares) unaffected.
- URL Phase 1: share links now contain only the post ID
(itsgoin.net/p/<post>). Anchor handler already supported post-ID-only
URLs (author was optional); just dropped the author hex from the
generator. Older URLs with author hex continue to work.
- Quick app close button in header (with confirm) — useful for stopping
network activity between sessions on mobile.
- JNI null-pointer guards on ndk_context handles in android_wifi.rs.
MEMORY rule sharpened to distinguish session relay (byte pipe, opt-in)
from relay-style signaling/discovery (always on).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#6: UI rename — Network Identity → Device Address.
Just labels/wording, no backend changes. Tauri command names
(list_identities, create_identity, etc.) and DTO fields stay as-is;
the rename is purely user-facing. Settings labels, lightbox titles,
toasts, danger-zone text, and welcome-screen "Import an Identity"
button all updated. Distinguishes more clearly from Personas (which
ARE the posting identity peers see).
#7: Bug fix — redundancy showed 0 for new posts.
Root cause: get_redundancy_summary queried
SELECT p.id FROM posts WHERE p.author = ?1
with ?1 = self.node_id (the device's network NodeId). After the
v0.6.0 network/posting-ID split, posts are authored by posting
identities (personas), not the network NodeId — so the query
returned 0 of my posts and the redundancy panel showed all zeros.
Fix: storage::get_redundancy_summary now takes &[NodeId] and uses
WHERE author IN (?, ?, ...) over every persona on the device.
Node::get_redundancy_summary gathers them via
list_posting_identities() before delegating.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#8 from the v0.7.0 device-test feedback round, confirmed by Scott.
Promotes FoF from a comment-policy option to a top-level visibility
choice, with FoF as the new default.
- visibility-select: new option order with `fof_closed` selected by
default:
- Extended Friends (FoF) ← default
- Friends
- Public
- Circle
- comment-perm-select: removed the old `fof_closed` option (its job
is now done by the visibility picker). Kept `friends_of_friends`
for the Mode 2 combo (public body + FoF-gated comments).
- updateVisibilityUI now hides comment-perm-select when visibility is
`fof_closed` — the audience choice already implies the comment
policy, no extra picker needed. Shown again on public/friends/circle.
- Compose dispatch logic re-rooted on visibility instead of comment
policy:
- fof_closed → create_post_fof_closed (Mode 1)
- public + comment-perm=friends_of_friends → create_post_with_fof_comments (Mode 2)
- everything else → existing create_post path
- After-post reset goes to `fof_closed` (not `public`) to preserve the
privacy-by-default posture.
Tracker memory updated: #8 marked complete; #10 clarified (per-group
persona selection, scoped alongside the deferred Group UI work in #9).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three of the v0.7.0 device-testing feedback items, deferred for later
rebuild/redeploy (Scott opted to batch UI fixes).
(#1) Friend button on bio modal:
- Primary action when neither following nor vouched: [Friend] (=
follow + vouch in one click) plus secondary [Follow only].
- When following without a vouch: [Add Vouch] primary, [Unfollow]
secondary.
- When both follow + vouch (= friends): [Unfriend] (= revoke vouch
+ unfollow, with the rotation-cost confirm wording).
- The standalone [Vouch] / [Revoke Vouch] flows stay reachable from
the existing Vouches list in Settings.
(#2) Profile shows "unnamed" — bug fix:
- set_profile updated the profiles table + emitted a profile post,
but never updated posting_identities.display_name. list_posting_
identities returns from the latter, so the Personas list kept
showing "(unnamed)" forever after the first-run auto-persona was
named.
- Now set_profile also upserts the posting_identities row with the
new display_name (secret_seed + created_at preserved).
(#5) Export/import + persona-vs-device clarity:
- Settings reorder: new "Your data on this device" explainer card up
top (Personas = who you are to peers; Identities = device's
network address, rarely useful to touch).
- "Move to another device" section renamed + given a plain-English
description; primary [Export personas] / [Import from another
device] buttons.
- "Identities (advanced)" demoted below; warning text added.
- Export wizard heading: "Export your personas"; radio labels use
persona/keys language consistently.
- Import wizard heading: "Import from another device"; explainer
notes that the default action restores personas.
Tracking memory created at memory/project_v071_followups.md for
deferred items (#4 PQ vouch delivery, #6 rename, #7 redundancy,
#8/#9/#10 awaiting clarification).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symptom (mobile): on the My Posts tab the fixed header + bottom tabs
disappeared until a swipe gesture pulled them in — classic mobile-
browser-chrome auto-hide that fires when the body has unwanted
horizontal scroll.
Root cause: the new FoF compose option "Body+Comments: FoF only
(Mode 1)" pushed the unwrapped #visibility-row (5 dropdowns side by
side, no flex-wrap) past viewport width on phones. Body picked up
overflow-x → mobile browser detected horizontal scroll → fixed
positioning behavior gets weird in that mode on Chrome Android and
WebView.
Two-line fix:
- flex-wrap: wrap on #visibility-row so the row breaks to multiple
lines on narrow screens.
- max-width: 100% on the row and on the selects inside it; the SELECT
widget renders at most container-width even if its longest option
is wider, so the long FoF option no longer balloons the widget.
Verified by inspection: no other view has a similar wide-dropdown row
that hugs the right edge; this is My Posts-specific because compose
lives there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v0.7.0 release surfaced a real bug: parallel cargo invocations
all writing to target/ caused linuxdeploy to fail mid-AppImage bundle
("Error failed to bundle project failed to run linuxdeploy"). Cargo's
own target-dir locking doesn't fully serialize three concurrent
cargo-front-ends across CLI + Android-cross-compile + tauri-bundle.
Solo cargo tauri build succeeded immediately after deploy.sh aborted,
confirming the parallel invocation was the root cause.
Switching to serial builds. The extra wall time is small because
cargo's incremental cache deduplicates compilation of the shared
itsgoin-core crate across the three builds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Public-facing architecture description of the FoF post-gating system
specified in docs/fof-spec/. Sits between section 20 (Encryption) and
section 21 (Delete Propagation). All subsections marked badge-planned.
Covers the user-facing 4-level visibility model, V_me primitive,
bio-post HPKE distribution, dual-mode operation (public-body+FoF-
comments vs FoFClosed), CDN-level comment verification, bucketed
padding scheme, revocation/rotation/key-burn lifecycle, PQ-readiness,
and the five ship-able layers.
Visibility variants table updated with the new FoFClosed row.
Disambiguation note added at top of section 20a noting this "vouch"
is the cryptographic V_me primitive, distinct from the directory
vouches in section 27. Reciprocal disambiguation note added at top
of section 27 pointing the other direction.
TOC entry added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Layer 5: replace two priv_post references in author-direct fast path
with the correct per-V_x CEK + priv_x lookup. Cache/prefilter logic
unchanged.
Layer 3: replace the "partially superseded" warning banner with a
plain scope note explaining the Mode 1/Mode 2 distinction reduces to
"body encrypted vs body plaintext"; wrap-slot canonical form lives in
Layer 2.
Layer 6: mark as superseded by Layer 4. README updated to match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the decisions from the Layer 4 conversation with Scott:
Default narrowing on a single post = Layer 2 revocation (existing).
Advanced narrowing of read access = full re-issue with optional
supersedes_post_id link (network-heavy, opt-in).
V_me rotation = the persona-wide revocation primitive. Generate new
V_me, distribute to non-revoked vouchees via next bio-post batch.
Receiver-chain model: receivers append new V_me alongside old (not
overwrite). Trial-unwrap iterates the chain.
Grandfather by default: CDN is V_me-blind, so rotation does NOT
auto-cascade comment deletions. Revoked vouchee retains comment
authority on old posts unless author opts to cascade per-pub_x
revocations.
Per-post cascade is opt-in. Local-only own_post_slot_provenance
table lets author query "which pub_x's in my posts were sealed
under V_me_old?" and publish per-pub_x RevocationEntries.
New optional KeyBurnDiff primitive (signed header-diff) swaps a
V_me_old wrap_slot for a V_me_new one in-place on a specific post.
For the leaked-V_me scenario. Body CEK unchanged.
Skeleton's PostKeyRotation record removed entirely.
Layer 1 updated: rotation is append-only at receivers; pointer to
Layer 4. Multi-epoch bio-post-batch toggle hook added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolve the two remaining Layer 3 open questions:
- Access-grant ordering: append at tail. pub_x_index values in
already-stored comments stay valid; no write amplification. Tail-
is-recent positional leak is the accepted cost (vs re-shuffle which
would force comments to reference signers by 32B pub_x bytes).
- Minimum slot bucket: 8. Singleton posts pad up to 8 so brand-new
personas don't publish "no vouchees" headers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prior round misread Opus's recommendation: I wrote "rand(0..=256) above
256" for slots and "round up to nearest 256KB above 256KB" for body.
Body was right; slots were wrong. Correct rule: bucketed throughout.
Slot buckets: 8, 16, 32, 64, 128, 256 (power-of-2 sub-256), then
384, 512, 640, 768, ... (+128 steps above).
Body buckets: 1KB, 2KB, ..., 256KB (power-of-2 sub-256KB), then 512KB,
768KB, 1024KB, ... (+256KB steps above; aligns with future chunk size).
Stronger privacy than random: observer learns bucket, never position
within it. Stable across posts; no min-over-many-posts floor attack.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hybrid padding rule (slots + body, same shape):
- <=256 real units: pad to next power of 2 (8, 16, ..., 256)
- >256 real units: pad to real + rand(0..=256) / nearest 256KB
Replaces Layer 2 round 2's rand(32..=128). Small authors/posts get
strong bucket-grouping; large authors/posts get probabilistic noise
without 2x bandwidth waste of pure power-of-2 at scale.
Layer 3 resolutions:
- Custom mode deferred; v1 ships Public / Friends-only / FoF only
- Slot dedup at V_x byte level (one slot per unique key)
- Body-length padding adopted
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fold in Scott's answers:
- Per-post (pub_x, priv_x); confirmed.
- Random rand(32..=128) dummy padding replaces power-of-2 buckets;
dummy pubkeys in pub_post_set so .len() == wrap_slots.len(). Floor
count is unrecoverable across multiple posts.
- Non-FoF UX: "Comments are private" + optional "Request access via
DM" button. No count leak.
- Author's own (pub_me, priv_me) in pub_post_set; confirmed.
- Revocation is retroactive delete + forward: file-holders delete
locally-stored comments signed by revoked pub_x on diff arrival,
then propagate. Stronger than stop-forwarding.
New primitive: access-grant author comment. Author appends a
WrapSlot + pub_post_set entry for a newly-vouched persona via a
signed special comment — retroactive read widening without republish.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace single per-post priv_post with per-V_x (pub_x, priv_x). Post
header publishes pub_post_set; comments declare pub_x_index; CDN
propagation nodes verify group_sig + identity_sig against named pubkey
before forwarding. Kills bandwidth-amplification DoS from admitted-but-
malicious FoF members.
Dual-derivation wrap slot (read → CEK, sign → priv_x) with shared
structure Layer 3 inherits. Comments encrypted under CEK_comments so
Mode 2 comments are genuinely FoF-read-gated, not just FoF-sign-filtered.
Author-signed revocation diff appended to post; CDN honors per-chain
revocation. Tradeoff: pub_x_index is a per-post voucher-chain pseudonym,
re-randomized across posts. Accepted.
Layer 3 banner added noting wrap-slot structure is now superseded by
Layer 2's canonical form; full reconciliation deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace DM-wrapped VouchGrant with HPKE (RFC 9180) per-recipient
wrappers in the voucher's bio post. Recipient anonymity via HPKE key
privacy; readers trial-decrypt per persona. 48B per wrapper, one
ephemeral pubkey per batch. Scan gated to follows + manual gesture.
Bucket padding + per-publish shuffle for size/position opacity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>