diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 61c959c..21c3f73 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -27,6 +27,7 @@ async fn main() -> anyhow::Result<()> { let mut web_port: Option = None; let mut print_identity = false; let mut announce = false; + let mut publish_registry = false; let mut ann_category: Option = None; let mut ann_title: Option = None; let mut ann_body: Option = None; @@ -76,6 +77,7 @@ async fn main() -> anyhow::Result<()> { } "--print-identity" => { print_identity = true; i += 1; } "--announce" => { announce = true; i += 1; } + "--publish-registry" => { publish_registry = true; i += 1; } "--ann-category" => { ann_category = args.get(i + 1).cloned(); i += 2; } "--ann-title" => { ann_title = args.get(i + 1).cloned(); i += 2; } "--ann-body" => { ann_body = args.get(i + 1).cloned(); i += 2; } @@ -152,6 +154,29 @@ async fn main() -> anyhow::Result<()> { return Ok(()); } + // One-shot: --publish-registry — genesis publish of the network + // registry post. Refuses unless this node's default posting identity + // is the bootstrap anchor's (debug builds may override for tests via + // ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS=1). Prints the registry post ID + // for verification against the shipped constant. + if publish_registry { + let node = Node::open_with_bind(&data_dir, bind_addr, profile).await?; + match node.publish_registry_genesis().await { + Ok(post_id) => { + println!("registry_post_id: {}", hex::encode(post_id)); + println!( + "shipped_constant: {}", + hex::encode(itsgoin_core::registry::REGISTRY_POST_ID) + ); + } + Err(e) => { + eprintln!("Failed to publish registry genesis: {}", e); + std::process::exit(1); + } + } + return Ok(()); + } + println!("Starting ItsGoin node (data: {}, profile: {:?})...", data_dir, profile); let node = Arc::new(Node::open_with_bind(&data_dir, bind_addr, profile).await?); @@ -162,8 +187,9 @@ async fn main() -> anyhow::Result<()> { let addr = node.endpoint_addr(); let sockets: Vec<_> = addr.ip_addrs().collect(); - // Show our display name if set - let my_name = node.get_display_name(&node.node_id).await.unwrap_or(None); + // Show our display name if set (profiles are keyed by POSTING identity, + // not the network NodeId). + let my_name = node.get_display_name(&node.default_posting_id).await.unwrap_or(None); let name_display = my_name.as_deref().unwrap_or("(not set)"); println!(); @@ -206,6 +232,14 @@ async fn main() -> anyhow::Result<()> { println!(" connections Show mesh connections"); println!(" social-routes Show social routing cache"); println!(" name Set your display name"); + println!(" register [keywords...] Register default persona in the network registry (30d, re-run to renew)"); + println!(" unregister Remove your registry entry (signed delete)"); + println!(" search Search the registry (pulls chain, queries locally)"); + println!(" greet Send a sealed greeting to a bio post's author"); + println!(" greetings List unsealed greetings on your bio (with reply/dismiss index)"); + println!(" reply Sealed reply to a greeting's return path (fresh reply key)"); + println!(" dismiss Dismiss a greeting (local only)"); + println!(" greetings-open Toggle the greeting slot on your bio (republishes bio)"); println!(" stats Show node stats"); println!(" export-key Export identity key (KEEP SECRET)"); println!(" id Show this node's ID"); @@ -254,6 +288,10 @@ async fn main() -> anyhow::Result<()> { let stdin = io::stdin(); let reader = stdin.lock(); + // A3: cached greeting list so `reply ` / `dismiss ` indices + // refer to the last `greetings` output. + let mut last_greetings: Vec = Vec::new(); + print!("> "); io::stdout().flush()?; @@ -342,9 +380,13 @@ async fn main() -> anyhow::Result<()> { if follows.is_empty() { println!("(not following anyone)"); } + let own_ids: Vec = node + .list_posting_identities().await.unwrap_or_default() + .into_iter().map(|p| p.node_id).collect(); for nid in follows { let name = node.get_display_name(&nid).await.unwrap_or(None); - let label = if nid == node.node_id { " (you)" } else { "" }; + // Follows are posting ids — "you" = any of our personas. + let label = if own_ids.contains(&nid) { " (you)" } else { "" }; if let Some(name) = name { println!(" {} ({}){}", name, &hex::encode(nid)[..12], label); } else { @@ -697,7 +739,7 @@ async fn main() -> anyhow::Result<()> { "create-persona" => { let name = arg.unwrap_or("").to_string(); - match node.create_posting_identity(name).await { + match node.create_posting_identity(name, None).await { Ok(id) => { println!("Created posting identity: {}", hex::encode(id.node_id)); } @@ -861,6 +903,166 @@ async fn main() -> anyhow::Result<()> { } } + "register" => { + if let Some(rest) = arg { + let mut words = rest.split_whitespace(); + let name = words.next().unwrap_or("").to_string(); + let keywords: Vec = words.map(|w| w.to_string()).collect(); + if name.is_empty() { + println!("Usage: register [keywords...]"); + } else { + let pid = node.default_posting_id; + match node.register_persona(&pid, &name, &keywords).await { + Ok(()) => println!( + "Registered '{}' ({} keywords). Listed for 30 days; auto-renews while listed.", + name, keywords.len() + ), + Err(e) => println!("Error: {}", e), + } + } + } else { + println!("Usage: register [keywords...]"); + } + } + + "unregister" => { + let pid = node.default_posting_id; + match node.unregister_persona(&pid).await { + Ok(()) => println!("Registry entry removed (signed delete propagated)."), + Err(e) => println!("Error: {}", e), + } + } + + "search" => { + let query = arg.unwrap_or(""); + match node.search_registry(query).await { + Ok(hits) => { + if hits.is_empty() { + println!("(no registry matches)"); + } + for h in hits { + println!( + " {} {} [{}]", + h.name, + hex::encode(h.author), + h.keywords.join(", "), + ); + } + } + Err(e) => println!("Error: {}", e), + } + } + + "greet" => { + if let Some(rest) = arg { + let parts: Vec<&str> = rest.splitn(2, ' ').collect(); + if parts.len() < 2 { + println!("Usage: greet "); + } else { + match itsgoin_core::parse_node_id_hex(parts[0]) { + Ok(bio_post_id) => { + match node.send_greeting(bio_post_id, parts[1].to_string()).await { + Ok(()) => println!("Greeting sent (sealed — only the bio author can read it)."), + Err(e) => println!("Error: {}", e), + } + } + Err(e) => println!("Invalid post ID: {}", e), + } + } + } else { + println!("Usage: greet "); + } + } + + "greetings" => { + match node.list_greetings().await { + Ok(greetings) => { + if greetings.is_empty() { + println!("(no greetings)"); + } + for (i, g) in greetings.iter().enumerate() { + let name = if g.sender_name.is_empty() { + hex::encode(g.sender_persona)[..12].to_string() + } else { + g.sender_name.clone() + }; + println!( + " [{}] {} ({}): {}", + i, name, &hex::encode(g.sender_persona)[..12], g.text + ); + } + last_greetings = greetings; + } + Err(e) => println!("Error: {}", e), + } + } + + "reply" => { + if let Some(rest) = arg { + let parts: Vec<&str> = rest.splitn(2, ' ').collect(); + match (parts.first().and_then(|s| s.parse::().ok()), parts.get(1)) { + (Some(idx), Some(text)) => { + match last_greetings.get(idx) { + Some(g) => { + match node.reply_to_greeting( + g.comment_author, g.post_id, g.timestamp_ms, + text.to_string(), + ).await { + Ok(()) => println!("Reply sent (sealed to the greeting's fresh reply key)."), + Err(e) => println!("Error: {}", e), + } + } + None => println!("No greeting at index {} — run `greetings` first.", idx), + } + } + _ => println!("Usage: reply "), + } + } else { + println!("Usage: reply "); + } + } + + "dismiss" => { + match arg.and_then(|a| a.trim().parse::().ok()) { + Some(idx) => match last_greetings.get(idx) { + Some(g) => { + match node.dismiss_greeting(g.comment_author, g.post_id, g.timestamp_ms).await { + Ok(()) => println!("Greeting dismissed (local only)."), + Err(e) => println!("Error: {}", e), + } + } + None => println!("No greeting at index {} — run `greetings` first.", idx), + }, + None => println!("Usage: dismiss "), + } + } + + "greetings-open" => { + match arg.map(|a| a.trim()) { + Some("on") | Some("off") => { + let open = arg.map(|a| a.trim()) == Some("on"); + let pid = node.default_posting_id; + match node.set_greetings_open(&pid, open).await { + Ok(()) => println!( + "Greetings {} (bio republished).", + if open { "OPEN — strangers can send sealed greetings" } else { "CLOSED" } + ), + Err(e) => println!("Error: {}", e), + } + } + _ => { + let pid = node.default_posting_id; + match node.get_greetings_open(&pid).await { + Ok(open) => println!( + "Greetings are {}. Usage: greetings-open ", + if open { "open" } else { "closed" } + ), + Err(e) => println!("Error: {}", e), + } + } + } + } + "quit" | "exit" | "q" => { println!("Shutting down..."); break; @@ -887,7 +1089,9 @@ async fn print_post( ) { let author_hex = hex::encode(post.author); let author_short = &author_hex[..12]; - let is_me = &post.author == &node.node_id; + // Posts are authored by posting identities — "me" = any of our personas. + let is_me = node.list_posting_identities().await.unwrap_or_default() + .iter().any(|p| p.node_id == post.author); let author_name = node.get_display_name(&post.author).await.unwrap_or(None); let author_label = match (author_name, is_me) { diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 07b6633..ef1019d 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -15,8 +15,7 @@ use crate::protocol::{ AnchorReferralRequestPayload, AnchorReferralResponsePayload, AnchorRegisterPayload, BlobHeaderDiffPayload, BlobHeaderRequestPayload, BlobHeaderResponsePayload, BlobRequestPayload, BlobResponsePayload, - CircleProfileUpdatePayload, GroupKeyRequestPayload, - GroupKeyResponsePayload, InitialExchangePayload, MeshPreferPayload, + CircleProfileUpdatePayload, InitialExchangePayload, MeshPreferPayload, MessageType, NodeListUpdatePayload, PostDownstreamRegisterPayload, ProfileUpdatePayload, PullSyncRequestPayload, PullSyncResponsePayload, RefuseRedirectPayload, RelayIntroducePayload, RelayIntroduceResultPayload, SessionRelayPayload, @@ -1659,8 +1658,12 @@ impl ConnectionManager { if let Some((json, header)) = header_opt { let _ = s.store_blob_header(&header.post_id, &header.author, json, header.updated_at); for reaction in &header.reactions { let _ = s.store_reaction(reaction); } - for comment in &header.comments { let _ = s.store_comment(comment); } - let _ = s.set_comment_policy(&header.post_id, &header.policy); + // v0.8 (A3): pull-path comments go through the + // SAME accept gate as the diff path (this site + // historically stored with no verification). + let _ = ingest_header_comments(&s, header, now_ms); + // v0.8: peer-supplied header.policy is NOT applied + // (unverifiable — see the SetPolicy arm note). let _ = s.update_post_last_engagement(post_id, now_ms); updated += 1; } @@ -2261,10 +2264,12 @@ impl ConnectionManager { for reaction in &header.reactions { let _ = storage.store_reaction(reaction); } - for comment in &header.comments { - let _ = storage.store_comment(comment); - } - let _ = storage.set_comment_policy(&header.post_id, &header.policy); + // v0.8 (A3): pull-path comments go through the + // SAME accept gate as the diff path (this site + // historically stored with no verification). + let _ = ingest_header_comments(&storage, header, now_ms); + // v0.8: peer-supplied header.policy is NOT applied + // (unverifiable — see the SetPolicy arm note). let _ = storage.update_post_last_engagement(post_id, now_ms); updated += 1; } @@ -2278,10 +2283,12 @@ impl ConnectionManager { } /// Handle an incoming pull request from a peer. - /// Handle a pull sync request — no conn_mgr lock needed, only storage + our_node_id. + /// Handle a pull sync request — no conn_mgr lock needed, only storage. + /// (`_our_node_id` is the NETWORK id; own-post checks inside use posting + /// identities from storage instead.) pub async fn handle_pull_request_unlocked( storage: &StoragePool, - our_node_id: NodeId, + _our_node_id: NodeId, remote_node_id: NodeId, mut recv: iroh::endpoint::RecvStream, mut send: iroh::endpoint::SendStream, @@ -2296,11 +2303,14 @@ impl ConnectionManager { let use_since_ms = !since_ms_map.is_empty(); // Phase 1: Brief lock — load data - let (all_posts, group_members) = { + let (all_posts, group_members, own_posting_ids) = { let s = storage.get().await; let posts = s.list_posts_with_visibility()?; let members = s.get_all_group_members().unwrap_or_default(); - (posts, members) + let own_ids: Vec = s.list_posting_identities() + .unwrap_or_default() + .into_iter().map(|p| p.node_id).collect(); + (posts, members, own_ids) }; // Phase 2: Filter without lock (pure CPU) @@ -2328,10 +2338,14 @@ impl ConnectionManager { if !peer_has_post { candidates_to_send.push((id, post, visibility)); } else { - if post.author == our_node_id { + // "Own post" = authored by ANY of our posting identities; + // our_node_id is the NETWORK id and never authors posts. + // (Redundant with control-post propagation — deletion + // candidate in the v0.8 cruft purge.) + if own_posting_ids.contains(&post.author) { vis_updates_to_send.push(crate::types::VisibilityUpdate { post_id: id, - author: our_node_id, + author: post.author, visibility, }); } @@ -5068,15 +5082,20 @@ impl ConnectionManager { if !crate::crypto::verify_manifest_signature(&entry.manifest.author_manifest) { continue; } - // Only store if newer than what we have + // Only store if newer than what we have (stored rows are + // AuthorManifest JSON — the canonical stored format). let dominated = storage.get_cdn_manifest(&entry.cid).ok().flatten() - .and_then(|json| serde_json::from_str::(&json).ok()) - .map(|existing| existing.author_manifest.updated_at >= entry.manifest.author_manifest.updated_at) + .and_then(|json| serde_json::from_str::(&json).ok()) + .map(|existing| existing.updated_at >= entry.manifest.author_manifest.updated_at) .unwrap_or(false); if dominated { continue; } - let manifest_json = match serde_json::to_string(&entry.manifest) { + // Store ONLY the author-signed part. Persisting the full + // CdnManifest kept third-party host/source device metadata + // and produced dual-format rows that later readers parsed + // as AuthorManifest (silently getting updated_at = 0). + let manifest_json = match serde_json::to_string(&entry.manifest.author_manifest) { Ok(j) => j, Err(_) => continue, }; @@ -5659,10 +5678,8 @@ impl ConnectionManager { use base64::Engine; let storage = storage.get().await; let manifest: Option = storage.get_cdn_manifest(&payload.cid).ok().flatten().and_then(|json| { - if let Ok(am) = serde_json::from_str::(&json) { - let ds_count = storage.get_file_holder_count(&payload.cid).unwrap_or(0); - Some(crate::types::CdnManifest { author_manifest: am, host: our_node_id, host_addresses: vec![], source: our_node_id, source_addresses: vec![], downstream_count: ds_count }) - } else { serde_json::from_str(&json).ok() } + serde_json::from_str::(&json).ok() + .map(|am| crate::types::CdnManifest { author_manifest: am, host: our_node_id }) }); let (cdn_registered, cdn_redirect_peers) = if !payload.requester_addresses.is_empty() { let prior_count = storage.get_file_holder_count(&payload.cid).unwrap_or(0); @@ -5708,8 +5725,7 @@ impl ConnectionManager { Some(json) => { let manifest = if let Ok(am) = serde_json::from_str::(&json) { if am.updated_at > payload.current_updated_at { - let ds_count = store.get_file_holder_count(&payload.cid).unwrap_or(0); - Some(crate::types::CdnManifest { author_manifest: am, host: our_node_id, host_addresses: vec![], source: our_node_id, source_addresses: vec![], downstream_count: ds_count }) + Some(crate::types::CdnManifest { author_manifest: am, host: our_node_id }) } else { None } } else { None }; crate::protocol::ManifestRefreshResponsePayload { cid: payload.cid, updated: manifest.is_some(), manifest } @@ -5721,66 +5737,6 @@ impl ConnectionManager { send.finish()?; debug!(peer = hex::encode(remote_node_id), updated = response.updated, "Handled manifest refresh request"); } - MessageType::GroupKeyRequest => { - let payload: GroupKeyRequestPayload = read_payload(&mut recv, 4096).await?; - let cm = conn_mgr.lock().await; - let storage = cm.storage.get().await; - - let response = match storage.get_group_key(&payload.group_id)? { - Some(record) if record.admin == cm.our_node_id => { - // We're the admin — check if requester is still a circle member - let members = storage.get_circle_members(&record.circle_name)?; - if members.contains(&remote_node_id) { - // Wrap group seed for requester - let seed = storage.get_group_seed(&payload.group_id, record.epoch)?; - let member_key = if let Some(seed) = seed { - crypto::wrap_group_key_for_member( - &cm.secret_seed, - &remote_node_id, - &seed, - ).ok().map(|wrapped| crate::types::GroupMemberKey { - member: remote_node_id, - epoch: record.epoch, - wrapped_group_key: wrapped, - }) - } else { - None - }; - GroupKeyResponsePayload { - group_id: payload.group_id, - epoch: record.epoch, - group_public_key: record.group_public_key, - admin: cm.our_node_id, - member_key, - } - } else { - // Requester not a member — no key - GroupKeyResponsePayload { - group_id: payload.group_id, - epoch: record.epoch, - group_public_key: record.group_public_key, - admin: cm.our_node_id, - member_key: None, - } - } - } - _ => { - // Not admin or no record - GroupKeyResponsePayload { - group_id: payload.group_id, - epoch: 0, - group_public_key: [0u8; 32], - admin: cm.our_node_id, - member_key: None, - } - } - }; - drop(storage); - drop(cm); - write_typed_message(&mut send, MessageType::GroupKeyResponse, &response).await?; - send.finish()?; - debug!(peer = hex::encode(remote_node_id), group_id = hex::encode(payload.group_id), "Handled group key request"); - } MessageType::RelayIntroduce => { let payload: RelayIntroducePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; debug!( @@ -6251,93 +6207,75 @@ impl ConnectionManager { } } BlobHeaderDiffOp::AddComment(comment) => { - if policy.blocklist.contains(&comment.author) { - continue; - } - match policy.allow_comments { - crate::types::CommentPermission::None => continue, - crate::types::CommentPermission::FollowersOnly => { - if !followers_set.contains(&comment.author) { - continue; - } - } - crate::types::CommentPermission::Public => {} - crate::types::CommentPermission::FriendsOfFriends => { - // FoF Layer 2 CDN four-check accept rule: - // 1. parent post must carry fof_gating - // (otherwise the policy is ambient - // with no key material to verify); - // 2. pub_x_index must point at a real - // entry in pub_post_set; - // 3. group_sig must validate against - // pub_post_set[pub_x_index]; - // 4. revocation_list must not contain - // pub_post_set[pub_x_index]; - // 5. identity_sig (existing comment - // signature field) verified below. - // - // Failures drop the comment without - // forwarding — kills the bandwidth-DoS - // attack an admitted-but-malicious FoF - // member could otherwise mount. - let parent = match storage.get_post(&payload.post_id) { - Ok(Some(p)) => p, - _ => continue, - }; - let Some(gating) = parent.fof_gating.as_ref() else { continue; }; - if !crate::fof::verify_fof_group_sig(comment, gating) { - continue; - } - // Revocation check (step 4). Two - // sources: the post's snapshot - // revocation_list (rarely populated on - // publish) and the live local table - // fof_revocations (accumulated as - // revocation diffs arrive on the wire). - if let Some(idx) = comment.pub_x_index { - let pub_x = gating.pub_post_set - .get(idx as usize) - .copied(); - if let Some(pub_x) = pub_x { - if gating.revocation_list.iter() - .any(|r| r.revoked_pub_x == pub_x) { - continue; - } - if storage.is_fof_pub_x_revoked(&payload.post_id, &pub_x).unwrap_or(false) { - continue; - } - } - } - } - } - if !crate::crypto::verify_comment_signature( - &comment.author, - &payload.post_id, - &comment.content, - comment.timestamp_ms, - &comment.signature, - comment.ref_post_id.as_ref(), + // v0.8 (A3): the single shared accept gate — + // signature (digest v2 incl. expiry), TTL sanity, + // policy, FoF four-check gated on + // post.fof_gating (not the policy enum), + // open-slot size buckets + greeting cap, + // registry entry validation + newest-wins. + // Same gate runs on both pull-path merges. + let parent = storage.get_post(&payload.post_id).ok().flatten(); + if accept_incoming_comment( + &storage, + parent.as_ref(), + &policy, + &followers_set, + comment, + now_ms(), ) { - continue; // Skip forged comments - } - let _ = storage.store_comment(comment); - } - BlobHeaderDiffOp::EditComment { author, post_id, timestamp_ms, new_content } => { - // Trust-based: only the comment author can edit - if *author == sender || sender == payload.author { - let _ = storage.edit_comment(author, post_id, *timestamp_ms, new_content); + // Registry newest-wins pruning runs only after + // a SUCCESSFUL store, so a store error can't + // leave the persona with no entry (the gate + // itself is side-effect free). + if storage.store_comment(comment).is_ok() + && comment.post_id == crate::registry::REGISTRY_POST_ID + { + let _ = storage.prune_older_registry_entries( + &comment.post_id, + &comment.author, + comment.timestamp_ms, + ); + } } } - BlobHeaderDiffOp::DeleteComment { author, post_id, timestamp_ms } => { - // Trust-based: comment author or post author can delete - if *author == sender || sender == payload.author { + BlobHeaderDiffOp::EditComment { .. } => { + // v0.8: remote edits are IGNORED. The old arm + // trusted the attacker-controlled `payload.author` + // (`sender == payload.author` is trivially forged) + // and rewrote stored content without any signature + // — and post-A1 a network-id sender can never + // legitimately match a posting-id author anyway. + // Edits = the author re-sends AddComment with the + // same (author, post_id, timestamp_ms) key and a + // fresh v2 signature; the upsert replaces the row. + } + BlobHeaderDiffOp::DeleteComment { author, post_id, timestamp_ms, signature } => { + // v0.8 (A3): self-certifying delete ONLY — the + // comment author's signature verifies from the op + // alone (works on registry holders that never met + // the persona). The old parent-post-author sender + // override compared against attacker-controlled + // `payload.author` (forgeable), and could never + // fire legitimately anyway (sender is a NETWORK + // id, post authors are POSTING ids). Post-author + // moderation returns later as a SIGNED override; + // bulk moderation = the RevocationEntry off-switch. + if delete_comment_authorized(author, post_id, *timestamp_ms, signature) { let _ = storage.delete_comment(author, post_id, *timestamp_ms); } } - BlobHeaderDiffOp::SetPolicy(new_policy) => { - if sender == payload.author { - let _ = storage.set_comment_policy(&payload.post_id, new_policy); - } + BlobHeaderDiffOp::SetPolicy(_) => { + // v0.8: remote policy writes are IGNORED. The old + // `sender == payload.author` check compared against + // an attacker-controlled field, and A3's accept + // gate consumes the stored policy as its FIRST + // drop condition — one forged SetPolicy(None) + // against REGISTRY_POST_ID would poison this + // holder into rejecting ALL future registrations. + // Policy rows are written only by the author's own + // device (node.set_comment_policy / + // persist_gated_post_author_state); a signed + // policy-change op can reintroduce remote writes. } BlobHeaderDiffOp::FoFRevocation { post_id, revoked_pub_x, revoked_at_ms, reason_code, author_sig, @@ -8721,6 +8659,702 @@ fn now_ms() -> u64 { .as_millis() as u64 } +// --- v0.8 (A3): the single comment accept gate --- +// +// EVERY comment ingest path — the diff handler AND both pull-side header +// merges — must go through `accept_incoming_comment`. Any cap enforced +// only in the diff handler is void: an attacker just serves the same +// comment through a header pull instead. (Historically the two pull +// merges stored comments with NO verification at all.) + +/// Per-bio unexpired greeting cap (holder-side flood limit; round 8: +/// rate caps + size buckets ARE the flood limits, no PoW anywhere). +pub const MAX_GREETINGS_PER_BIO: u64 = 64; + +/// Max accepted TTL: a comment may not claim an expiry more than 366 +/// days out (ordinary TTLs top out at 365d). +pub const MAX_COMMENT_TTL_MS: u64 = 366 * 24 * 3600 * 1000; + +/// Generous ceiling on a sealed-greeting `encrypted_payload` relative to +/// the declared plaintext bucket: sealed blob (32B eph + 4B len + bucket +/// + 16B tag) → base64 (×4/3) → JSON payload wrapper → outer AEAD. +fn greeting_max_payload_bytes(body_bucket: u16) -> usize { + (body_bucket as usize) * 2 + 512 +} + +/// The single accept/drop decision for an incoming comment, shared by +/// all three ingest sites. `parent_post` is the locally-held parent (if +/// any); `policy` + `followers_set` are the parent's engagement policy +/// inputs. Returns `true` = store, `false` = drop without forwarding. +/// +/// Checks, in order (spec A3 §T9): +/// 1. identity signature (digest v2, expiry included); +/// 2. TTL sanity: v0.8 REQUIRES a TTL — `expires_at_ms == 0` rejected, +/// already-expired rejected (stateless re-ingest guard, the +/// comment-analog of `deleted_posts`), >366d rejected; +/// 3. policy (blocklist / None / FollowersOnly) + FoF four-check gated +/// on `post.fof_gating.is_some()` — NOT the `CommentPolicy` enum +/// (closing the pre-existing dead-gate gap: nothing ever set the +/// FriendsOfFriends policy, so the enum arm was unreachable); +/// 4. open-slot size buckets + per-bio greeting cap; +/// 5. registry-post entry validation + newest-wins upsert. +pub fn accept_incoming_comment( + storage: &crate::storage::Storage, + parent_post: Option<&crate::types::Post>, + policy: &crate::types::CommentPolicy, + followers_set: &HashSet, + comment: &crate::types::InlineComment, + now: u64, +) -> bool { + // 0. Tombstone injection guard: the identity signature does NOT + // cover `deleted_at`, so an attacker could take any valid signed + // comment off the chain, set `deleted_at`, and re-send it — an + // unsigned effective delete (store_comment's upsert propagates the + // field). Tombstones may ONLY travel on the signed DeleteComment op. + if comment.deleted_at.is_some() { + return false; + } + + // 1. Identity signature — v0.8: ALWAYS verified (empty = forged). + if !crate::crypto::verify_inline_comment_signature(comment) { + return false; + } + + // 2. TTL sanity. + if comment.expires_at_ms == 0 { + return false; // v0.8 requires a TTL + } + if comment.expires_at_ms <= now { + return false; // already expired — never (re-)ingest + } + if comment.expires_at_ms > now.saturating_add(MAX_COMMENT_TTL_MS) { + return false; // TTL beyond the allowed window + } + // Future-dated timestamps: allow only small clock skew. Without this + // a registration ~336d in the future permanently wins newest-wins + // against honest renewals and pins itself atop timestamp-sorted + // search results. + if comment.timestamp_ms > now.saturating_add(crate::registry::REGISTRATION_TTL_SLACK_MS) { + return false; + } + + // 3a. Ambient policy checks. + if policy.blocklist.contains(&comment.author) { + return false; + } + match policy.allow_comments { + crate::types::CommentPermission::None => return false, + crate::types::CommentPermission::FollowersOnly => { + if !followers_set.contains(&comment.author) { + return false; + } + } + crate::types::CommentPermission::Public + | crate::types::CommentPermission::FriendsOfFriends => {} + } + + // 3b. FoF four-check, gated on the POST carrying gating (not the + // policy enum). Comments claiming a slot (`pub_x_index`) on a post + // we don't hold can't be verified — drop them. + let gating = match parent_post { + Some(p) => p.fof_gating.as_ref(), + None => { + if comment.pub_x_index.is_some() { + return false; + } + None + } + }; + if let Some(gating) = gating { + // Gated post: every comment must sign under an admitted slot. + if !crate::fof::verify_fof_group_sig(comment, gating) { + return false; + } + // Revocation check: publish-time snapshot + live local table. + if let Some(idx) = comment.pub_x_index { + if let Some(pub_x) = gating.pub_post_set.get(idx as usize).copied() { + if gating.revocation_list.iter().any(|r| r.revoked_pub_x == pub_x) { + return false; + } + if storage + .is_fof_pub_x_revoked(&comment.post_id, &pub_x) + .unwrap_or(false) + { + return false; + } + } + } + + // 4. Open-slot size buckets + rate caps. + if let Some(decl) = gating.open_slot.as_ref() { + if comment.pub_x_index == Some(decl.slot_index) { + match decl.kind { + crate::types::OpenSlotKind::Greeting => { + // Superseded-bio guard (belt-and-suspenders for + // the consent off-switch): greetings are only + // accepted on the author's CURRENT bio post. Old + // bios never expire, and each would otherwise + // contribute its own greeting slot + 64-cap even + // after the author republished without a slot. + if let Some(parent) = parent_post { + if let Ok(Some(latest)) = + storage.get_latest_profile_post_id_by_author(&parent.author) + { + if latest != comment.post_id { + return false; + } + } + } + let Some(payload) = comment.encrypted_payload.as_ref() else { + return false; + }; + if payload.len() > greeting_max_payload_bytes(decl.body_bucket) { + return false; + } + if !comment.content.is_empty() { + return false; // greeting bodies are sealed-only + } + let live = storage + .count_unexpired_open_slot_comments(&comment.post_id, decl.slot_index) + .unwrap_or(u64::MAX); + if live >= MAX_GREETINGS_PER_BIO { + return false; + } + } + crate::types::OpenSlotKind::Registry => { + if comment.content.len() > decl.body_bucket as usize { + return false; + } + } + } + } + } + } + + // 5. Registry-post entry validation + newest-wins. + if comment.post_id == crate::registry::REGISTRY_POST_ID { + if crate::registry::parse_registration(&comment.content).is_err() { + return false; + } + if !crate::registry::registration_ttl_ok(comment.timestamp_ms, comment.expires_at_ms) { + return false; + } + // Newest-wins per persona: READ-ONLY check here — drop the + // incoming comment when a newer entry is already stored. The + // destructive half (hard-deleting the older row) happens in the + // storing callers AFTER a successful store_comment, so a store + // failure can never leave the persona with no entry at all. + match storage.registry_entry_is_newest( + &comment.post_id, + &comment.author, + comment.timestamp_ms, + ) { + Ok(true) => {} + _ => return false, + } + } + + true +} + +/// v0.8 (A3): DeleteComment acceptance — a self-certifying signature by +/// the comment author (verifiable by holders that never met the persona) +/// is REQUIRED. Empty/invalid signature = drop. There is deliberately no +/// sender-identity override: senders authenticate as NETWORK ids while +/// comment/post authors are POSTING ids, so an identity match can only +/// ever be forged (via attacker-controlled payload fields), never +/// legitimate. Post-author moderation will return as a SIGNED override. +pub fn delete_comment_authorized( + author: &NodeId, + post_id: &crate::types::PostId, + timestamp_ms: u64, + signature: &[u8], +) -> bool { + !signature.is_empty() + && crate::crypto::verify_comment_delete(author, post_id, timestamp_ms, signature) +} + +/// Pull-path merge: run every comment in a fetched `BlobHeader` through +/// the accept gate and store the survivors. Called by BOTH pull-side +/// header merges (`fetch_engagement_unlocked` and +/// `fetch_engagement_from_peer`) — the two historic zero-verification +/// bypass sites. Returns the number of comments stored. +pub fn ingest_header_comments( + storage: &crate::storage::Storage, + header: &crate::types::BlobHeader, + now: u64, +) -> usize { + let parent = storage.get_post(&header.post_id).ok().flatten(); + let policy = storage + .get_comment_policy(&header.post_id) + .ok() + .flatten() + .unwrap_or_default(); + let followers: HashSet = storage + .list_public_follows() + .unwrap_or_default() + .into_iter() + .collect(); + let mut stored = 0usize; + for comment in &header.comments { + if accept_incoming_comment(storage, parent.as_ref(), &policy, &followers, comment, now) { + if storage.store_comment(comment).is_ok() { + stored += 1; + // Registry newest-wins pruning only after a SUCCESSFUL + // store (the accept gate is side-effect free). + if comment.post_id == crate::registry::REGISTRY_POST_ID { + let _ = storage.prune_older_registry_entries( + &comment.post_id, + &comment.author, + comment.timestamp_ms, + ); + } + } + } + } + // Re-derive the stored header from vetted DB state so we never + // re-serve peer-supplied comments the gate rejected (callers store + // the peer's raw header JSON before ingesting). + let _ = storage.rebuild_blob_header_from_db(&header.post_id, &header.author, now); + stored +} + +#[cfg(test)] +mod accept_gate_tests { + //! v0.8 (A3) T9: the accept-gate matrix, exercised BOTH through the + //! gate function directly (the diff-path decision) AND through + //! `ingest_header_comments` (the pull-path merge — the two historic + //! zero-verification bypass sites share this exact code). + + use super::{accept_incoming_comment, delete_comment_authorized, ingest_header_comments, + MAX_COMMENT_TTL_MS, MAX_GREETINGS_PER_BIO}; + use crate::storage::Storage; + use crate::types::{ + BlobHeader, CommentPolicy, InlineComment, NodeId, OpenSlotKind, Post, PostId, + PostVisibility, PostingIdentity, VisibilityIntent, + }; + use ed25519_dalek::SigningKey; + use rand::RngCore; + use std::collections::HashSet; + + fn temp_storage() -> Storage { + Storage::open(":memory:").unwrap() + } + + fn make_persona(seed_byte: u8) -> (NodeId, [u8; 32]) { + let mut seed = [0u8; 32]; + seed[0] = seed_byte; + let sk = SigningKey::from_bytes(&seed); + (*sk.verifying_key().as_bytes(), seed) + } + + fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 + } + + /// Storage + a stored bio post with a Greeting open slot, owned by + /// a persona with a V_me. Returns (storage, bio_post_id, bio_post). + fn setup_greeting_bio(seed_byte: u8) -> (Storage, PostId, Post) { + let s = temp_storage(); + let (alice_id, alice_seed) = make_persona(seed_byte); + s.upsert_posting_identity(&PostingIdentity { + node_id: alice_id, + secret_seed: alice_seed, + display_name: "Alice".into(), + created_at: 1000, + }).unwrap(); + let mut v_me = [0u8; 32]; + rand::rng().fill_bytes(&mut v_me); + s.insert_own_vouch_key(&alice_id, 1, &v_me, 1000).unwrap(); + + let built = crate::fof::build_fof_comment_gating( + &s, &alice_id, Some((OpenSlotKind::Greeting, 1024)), + ).unwrap().expect("gating built"); + let post = Post { + author: alice_id, + content: String::new(), + attachments: vec![], + timestamp_ms: 3000, + fof_gating: Some(built.gating), + supersedes_post_id: None, + }; + let post_id = crate::content::compute_post_id(&post); + s.store_post_with_intent( + &post_id, &post, &PostVisibility::Public, &VisibilityIntent::Profile, + ).unwrap(); + (s, post_id, post) + } + + /// A valid sealed greeting comment from a fresh throwaway identity. + fn make_greeting(post: &Post, post_id: &PostId, seed_byte: u8, ts: u64, expires: u64) -> InlineComment { + use base64::Engine; + let (stranger_id, stranger_seed) = make_persona(seed_byte); + let unlock = crate::fof::derive_open_slot_unlock(post, &stranger_id).expect("unlock"); + let decl = post.fof_gating.as_ref().unwrap().open_slot.as_ref().unwrap(); + let recipient = crate::crypto::ed25519_pubkey_to_x25519_public(&post.author).unwrap(); + let sealed = crate::crypto::seal_greeting_body( + &recipient, post_id, b"{\"v\":1}", decl.body_bucket as usize, + ).unwrap(); + let sealed_b64 = base64::engine::general_purpose::STANDARD.encode(&sealed); + crate::fof::build_fof_comment( + post_id, + &unlock, + &post.fof_gating.as_ref().unwrap().slot_binder_nonce, + &stranger_id, + &stranger_seed, + &sealed_b64, + None, + ts, + expires, + ).unwrap() + } + + fn gate(s: &Storage, post: Option<&Post>, c: &InlineComment) -> bool { + accept_incoming_comment(s, post, &CommentPolicy::default(), &HashSet::new(), c, now()) + } + + /// Pull-path variant: wrap the comment in a BlobHeader and run the + /// shared header merge; returns whether the comment got stored. + fn gate_via_pull(s: &Storage, post_id: &PostId, author: &NodeId, c: &InlineComment) -> bool { + let header = BlobHeader { + post_id: *post_id, + author: *author, + reactions: vec![], + comments: vec![c.clone()], + policy: CommentPolicy::default(), + updated_at: now(), + thread_splits: vec![], + receipt_slots: vec![], + comment_slots: vec![], + prior_author: None, + }; + let before = s.get_comments_with_tombstones(post_id).unwrap().len(); + let stored = ingest_header_comments(s, &header, now()); + let after = s.get_comments_with_tombstones(post_id).unwrap().len(); + assert_eq!(stored > 0, after > before, "store count consistent"); + stored > 0 + } + + #[test] + fn good_greeting_accepted_both_paths() { + let (s, post_id, post) = setup_greeting_bio(10); + let c = make_greeting(&post, &post_id, 50, now(), now() + 40 * 24 * 3600 * 1000); + assert!(gate(&s, Some(&post), &c), "diff path accepts a valid greeting"); + assert!(gate_via_pull(&s, &post_id, &post.author, &c), "pull path accepts too"); + } + + #[test] + fn expired_and_ttl_violations_rejected_both_paths() { + let (s, post_id, post) = setup_greeting_bio(11); + let t = now(); + + // Already expired. + let c = make_greeting(&post, &post_id, 51, t - 1000, t - 1); + assert!(!gate(&s, Some(&post), &c)); + assert!(!gate_via_pull(&s, &post_id, &post.author, &c)); + + // Zero TTL (v0.8 requires one). + let c = make_greeting(&post, &post_id, 52, t, 0); + assert!(!gate(&s, Some(&post), &c)); + assert!(!gate_via_pull(&s, &post_id, &post.author, &c)); + + // TTL beyond the 366-day window. + let c = make_greeting(&post, &post_id, 53, t, t + MAX_COMMENT_TTL_MS + 60_000); + assert!(!gate(&s, Some(&post), &c)); + assert!(!gate_via_pull(&s, &post_id, &post.author, &c)); + } + + #[test] + fn tampered_expiry_fails_signature() { + let (s, post_id, post) = setup_greeting_bio(12); + let t = now(); + let mut c = make_greeting(&post, &post_id, 54, t, t + 40 * 24 * 3600 * 1000); + // A holder tries to silently extend the TTL → digest v2 catches it. + c.expires_at_ms += 1; + assert!(!gate(&s, Some(&post), &c)); + assert!(!gate_via_pull(&s, &post_id, &post.author, &c)); + } + + #[test] + fn oversized_greeting_rejected() { + let (s, post_id, post) = setup_greeting_bio(13); + let t = now(); + let mut c = make_greeting(&post, &post_id, 55, t, t + 40 * 24 * 3600 * 1000); + // Inflate the payload past the bucket ceiling. (Signature stays + // valid — the outer identity sig doesn't cover encrypted_payload; + // the size cap must therefore hold on its own.) group_sig breaks + // too, but check the size arm by re-signing group material is + // unnecessary: EITHER rejection means drop. + c.encrypted_payload = Some(vec![0u8; 1024 * 2 + 513]); + assert!(!gate(&s, Some(&post), &c)); + assert!(!gate_via_pull(&s, &post_id, &post.author, &c)); + } + + #[test] + fn greeting_cap_enforced() { + let (s, post_id, post) = setup_greeting_bio(14); + let t = now(); + // Fill to the cap with valid greetings from distinct throwaways. + for i in 0..MAX_GREETINGS_PER_BIO { + let c = make_greeting( + &post, &post_id, 60 + (i % 180) as u8, + t + i, t + 40 * 24 * 3600 * 1000, + ); + // distinct (author, ts) needed for distinct rows; seed_byte + // wraps but ts differs so keys are unique per row. + s.store_comment(&c).unwrap(); + } + assert_eq!( + s.count_unexpired_open_slot_comments( + &post_id, + post.fof_gating.as_ref().unwrap().open_slot.as_ref().unwrap().slot_index, + ).unwrap(), + MAX_GREETINGS_PER_BIO, + ); + // One more greeting → over cap → dropped on both paths. (ts kept + // within the future-skew allowance so the cap arm is what fires.) + let c = make_greeting(&post, &post_id, 55, t + 60_000, t + 40 * 24 * 3600 * 1000); + assert!(!gate(&s, Some(&post), &c), "cap enforced on diff path"); + assert!(!gate_via_pull(&s, &post_id, &post.author, &c), "cap enforced on pull path"); + } + + #[test] + fn revoked_open_slot_pub_x_rejected() { + let (s, post_id, post) = setup_greeting_bio(15); + let t = now(); + let c = make_greeting(&post, &post_id, 56, t, t + 40 * 24 * 3600 * 1000); + assert!(gate(&s, Some(&post), &c), "accepted pre-revocation"); + + // Author revokes the open slot's pub_x — the global off-switch. + let decl_idx = post.fof_gating.as_ref().unwrap().open_slot.as_ref().unwrap().slot_index; + let pub_x = post.fof_gating.as_ref().unwrap().pub_post_set[decl_idx as usize]; + s.add_fof_revocation(&post_id, &pub_x, t, 0, &[0u8; 64]).unwrap(); + + let c2 = make_greeting(&post, &post_id, 57, t + 1, t + 40 * 24 * 3600 * 1000); + assert!(!gate(&s, Some(&post), &c2), "revoked pub_x rejected (diff)"); + assert!(!gate_via_pull(&s, &post_id, &post.author, &c2), "revoked pub_x rejected (pull)"); + } + + #[test] + fn fof_marked_comment_without_held_parent_rejected() { + let (s, post_id, post) = setup_greeting_bio(16); + let t = now(); + let c = make_greeting(&post, &post_id, 58, t, t + 40 * 24 * 3600 * 1000); + // Parent unknown → slot claims can't be verified → drop. + assert!(!gate(&s, None, &c)); + } + + /// Registration matrix: valid entry accepted; wrong TTL rejected; + /// bad shape rejected; older-than-stored dropped — both paths. + #[test] + fn registration_matrix_both_paths() { + let s = temp_storage(); + crate::registry::materialize_registry_post(&s).unwrap(); + let registry = s.get_post(&crate::registry::REGISTRY_POST_ID).unwrap().unwrap(); + let (persona_id, persona_seed) = make_persona(70); + let t = now(); + + let build_reg = |content: &str, ts: u64, expires: u64| -> InlineComment { + let unlock = crate::fof::derive_open_slot_unlock(®istry, &persona_id).unwrap(); + let group_sig = { + use ed25519_dalek::Signer; + let signer = SigningKey::from_bytes(&unlock.priv_x_seed); + let mut to_sign = Vec::new(); + to_sign.extend_from_slice(content.as_bytes()); + to_sign.extend_from_slice(&crate::registry::REGISTRY_POST_ID); + to_sign.extend_from_slice(&unlock.slot_index.to_le_bytes()); + signer.sign(&to_sign).to_bytes().to_vec() + }; + let signature = crate::crypto::sign_comment( + &persona_seed, &persona_id, &crate::registry::REGISTRY_POST_ID, + content, ts, None, expires, + ); + InlineComment { + author: persona_id, + post_id: crate::registry::REGISTRY_POST_ID, + content: content.to_string(), + timestamp_ms: ts, + signature, + deleted_at: None, + ref_post_id: None, + pub_x_index: Some(unlock.slot_index), + group_sig: Some(group_sig), + encrypted_payload: None, + expires_at_ms: expires, + } + }; + + let good = r#"{"v":1,"name":"Alice","keywords":["rust","p2p"]}"#; + + // Wrong TTL (not fixed-30d): rejected. + let wrong_ttl = build_reg(good, t, t + crate::registry::REGISTRATION_TTL_MS / 2); + assert!(!gate(&s, Some(®istry), &wrong_ttl)); + assert!(!gate_via_pull(&s, &crate::registry::REGISTRY_POST_ID, ®istry.author, &wrong_ttl)); + + // Bad shape: rejected. + let bad_shape = build_reg( + r#"{"v":1,"name":""}"#, t, t + crate::registry::REGISTRATION_TTL_MS, + ); + assert!(!gate(&s, Some(®istry), &bad_shape)); + + // Valid: accepted via the pull path (stores it). + let valid = build_reg(good, t, t + crate::registry::REGISTRATION_TTL_MS); + assert!(gate_via_pull(&s, &crate::registry::REGISTRY_POST_ID, ®istry.author, &valid)); + + // Newer entry replaces it. (Mirror the diff-arm caller: the gate + // is side-effect free; pruning runs after a successful store.) + let newer = build_reg(good, t + 1000, t + 1000 + crate::registry::REGISTRATION_TTL_MS); + assert!(gate(&s, Some(®istry), &newer)); + s.store_comment(&newer).unwrap(); + s.prune_older_registry_entries( + &crate::registry::REGISTRY_POST_ID, &persona_id, newer.timestamp_ms, + ).unwrap(); + + // Older-than-stored: dropped on both paths (newest wins). + let older = build_reg(good, t + 500, t + 500 + crate::registry::REGISTRATION_TTL_MS); + assert!(!gate(&s, Some(®istry), &older)); + assert!(!gate_via_pull(&s, &crate::registry::REGISTRY_POST_ID, ®istry.author, &older)); + + // Exactly one live row remains, at the newest timestamp. + let rows = s.get_comments(&crate::registry::REGISTRY_POST_ID).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].timestamp_ms, t + 1000); + } + + #[test] + fn delete_comment_authorization_matrix() { + let (author_id, author_seed) = make_persona(80); + let (wrong_author, wrong_seed) = make_persona(81); + let post_id: PostId = [9u8; 32]; + let ts = 12345u64; + + let sig = crate::crypto::sign_comment_delete(&author_seed, &author_id, &post_id, ts); + + // Self-certified: accepted (regardless of sender — no sender + // identity is consulted at all). + assert!(delete_comment_authorized(&author_id, &post_id, ts, &sig)); + // Unsigned: rejected — there is NO sender override anymore (the + // old one compared against attacker-controlled payload.author). + assert!(!delete_comment_authorized(&author_id, &post_id, ts, &[])); + // Garbage signature: rejected. + assert!(!delete_comment_authorized(&author_id, &post_id, ts, &[0u8; 64])); + // Signature by a different key than the named author: rejected. + let wrong_sig = crate::crypto::sign_comment_delete(&wrong_seed, &wrong_author, &post_id, ts); + assert!(!delete_comment_authorized(&author_id, &post_id, ts, &wrong_sig)); + } + + /// Blocker regression: a valid signed comment with an injected + /// `deleted_at` (the identity signature does not cover it) must be + /// dropped on both paths, and must NOT tombstone a stored copy. + #[test] + fn injected_tombstone_rejected_both_paths() { + let (s, post_id, post) = setup_greeting_bio(20); + let t = now(); + let c = make_greeting(&post, &post_id, 90, t, t + 40 * 24 * 3600 * 1000); + + // Store the honest copy first. + assert!(gate(&s, Some(&post), &c)); + s.store_comment(&c).unwrap(); + assert_eq!(s.get_comments(&post_id).unwrap().len(), 1); + + // Attacker re-sends the same signed bytes with deleted_at set. + let mut forged = c.clone(); + forged.deleted_at = Some(t); + assert!(!gate(&s, Some(&post), &forged), "diff path drops injected tombstone"); + assert!( + !gate_via_pull(&s, &post_id, &post.author, &forged), + "pull path drops injected tombstone" + ); + + // The stored copy is still live — no unsigned delete happened. + assert_eq!(s.get_comments(&post_id).unwrap().len(), 1); + } + + /// Future-dated comments beyond the skew allowance are rejected + /// (ranking/newest-wins squatting guard). + #[test] + fn far_future_timestamp_rejected() { + let (s, post_id, post) = setup_greeting_bio(21); + let t = now(); + let day = 24 * 3600 * 1000u64; + // 30 days in the future — well past the 5-minute skew allowance. + let c = make_greeting(&post, &post_id, 91, t + 30 * day, t + 60 * day); + assert!(!gate(&s, Some(&post), &c)); + assert!(!gate_via_pull(&s, &post_id, &post.author, &c)); + // Within the skew allowance: accepted. + let c2 = make_greeting(&post, &post_id, 92, t + 60_000, t + 60 * day); + assert!(gate(&s, Some(&post), &c2)); + } + + /// Consent off-switch belt-and-suspenders: greetings on a bio that + /// has been superseded by a newer profile post are rejected. + #[test] + fn greeting_on_superseded_bio_rejected() { + let (s, old_bio_id, old_bio) = setup_greeting_bio(22); + let t = now(); + + // Author republishes the bio WITHOUT a greeting slot (consent + // withdrawn) — a newer Profile post by the same author. + let new_bio = Post { + author: old_bio.author, + content: String::new(), + attachments: vec![], + timestamp_ms: old_bio.timestamp_ms + 1000, + fof_gating: None, + supersedes_post_id: None, + }; + let new_bio_id = crate::content::compute_post_id(&new_bio); + s.store_post_with_intent( + &new_bio_id, &new_bio, &PostVisibility::Public, &VisibilityIntent::Profile, + ).unwrap(); + + // A greeting aimed at the OLD bio is now rejected on both paths. + let c = make_greeting(&old_bio, &old_bio_id, 93, t, t + 40 * 24 * 3600 * 1000); + assert!(!gate(&s, Some(&old_bio), &c), "diff path rejects superseded-bio greeting"); + assert!( + !gate_via_pull(&s, &old_bio_id, &old_bio.author, &c), + "pull path rejects superseded-bio greeting" + ); + } + + /// Stateless re-ingest guard: an expired comment served by a stale + /// holder does NOT re-seed after local expiry sweep. + #[test] + fn expired_comment_does_not_reseed_via_pull() { + let (s, post_id, post) = setup_greeting_bio(17); + let t = now(); + // Short-lived greeting, stored while valid. + let c = make_greeting(&post, &post_id, 59, t - 10_000, t + 5_000); + s.store_comment(&c).unwrap(); + assert_eq!(s.get_comments(&post_id).unwrap().len(), 1); + + // Time passes; the sweep hard-deletes it. + let later = t + 10_000; + assert_eq!(s.expire_comments(later).unwrap(), 1); + assert!(s.get_comments(&post_id).unwrap().is_empty()); + + // A stale holder re-serves the exact same signed comment via a + // header pull → the gate's expiry check drops it statelessly. + let header = BlobHeader { + post_id, + author: post.author, + reactions: vec![], + comments: vec![c], + policy: CommentPolicy::default(), + updated_at: later, + thread_splits: vec![], + receipt_slots: vec![], + comment_slots: vec![], + prior_author: None, + }; + assert_eq!(ingest_header_comments(&s, &header, later), 0); + assert!(s.get_comments_with_tombstones(&post_id).unwrap().is_empty()); + } +} + #[cfg(test)] mod tests { use super::{scanner_semaphore, PendingConnectGuard}; diff --git a/crates/core/src/crypto.rs b/crates/core/src/crypto.rs index 9130d6b..dbf3f0a 100644 --- a/crates/core/src/crypto.rs +++ b/crates/core/src/crypto.rs @@ -923,15 +923,15 @@ pub fn rotate_group_key( // --- CDN Manifest Signing --- /// Compute the canonical digest for an AuthorManifest (for signing/verification). -/// Digest = BLAKE3(post_id ‖ author ‖ created_at_le ‖ updated_at_le ‖ author_addresses_json ‖ previous_posts_json ‖ following_posts_json) +/// Digest = BLAKE3(post_id ‖ author ‖ created_at_le ‖ updated_at_le ‖ previous_posts_json ‖ following_posts_json) +/// v0.8: author_addresses removed from AuthorManifest (and from this digest) — +/// posting-identity manifests must not carry device addresses. fn manifest_digest(manifest: &crate::types::AuthorManifest) -> [u8; 32] { let mut hasher = blake3::Hasher::new(); hasher.update(&manifest.post_id); hasher.update(&manifest.author); hasher.update(&manifest.created_at.to_le_bytes()); hasher.update(&manifest.updated_at.to_le_bytes()); - let addrs_json = serde_json::to_string(&manifest.author_addresses).unwrap_or_default(); - hasher.update(addrs_json.as_bytes()); let prev_json = serde_json::to_string(&manifest.previous_posts).unwrap_or_default(); hasher.update(prev_json.as_bytes()); let next_json = serde_json::to_string(&manifest.following_posts).unwrap_or_default(); @@ -1010,8 +1010,20 @@ pub fn random_slot_noise(size: usize) -> Vec { // --- Engagement crypto --- const REACTION_WRAP_CONTEXT: &str = "itsgoin/private-reaction/v1"; -const COMMENT_SIGN_CONTEXT: &str = "itsgoin/comment-sig/v1"; +/// v0.8 (A3): digest v2 — expires_at_ms enters the signed digest so a +/// comment's TTL can never be silently extended by holders. v0.8 has +/// ZERO wire-compat obligations (zero-users ruling), so v1 is gone. +const COMMENT_SIGN_CONTEXT: &str = "itsgoin/comment-sig/v2"; const REACTION_SIGN_CONTEXT: &str = "itsgoin/reaction-sig/v1"; +/// v0.8 (A3): self-certifying comment-delete signature context. +const COMMENT_DELETE_CONTEXT: &str = "itsgoin/comment-delete/v1"; +/// v0.8 (A3): derivable open-slot V_x context (design §27). Anyone can +/// compute V_open from the post alone: author pubkey + slot_binder_nonce. +const OPEN_SLOT_VX_CONTEXT: &str = "itsgoin/open-slot-vx/v1"; +/// v0.8 (A3): greeting-body seal contexts. The bio post id is baked into +/// the derivation (same domain-separation style as vouch grants). +const GREETING_KEY_CONTEXT: &str = "itsgoin/greeting/v1/key"; +const GREETING_NONCE_CONTEXT: &str = "itsgoin/greeting/v1/nonce"; /// Encrypt a private reaction payload (only the post author can decrypt). /// Uses X25519 DH between reactor and author, then ChaCha20-Poly1305. @@ -1067,25 +1079,31 @@ pub fn decrypt_private_reaction( String::from_utf8(plaintext).map_err(|e| anyhow::anyhow!("invalid utf8: {}", e)) } -/// Sign a comment: ed25519 over BLAKE3(author || post_id || content || timestamp_ms). +/// Sign a comment: ed25519 over BLAKE3(author || post_id || content || +/// timestamp_ms [|| ref:ref_post_id] || expires:expires_at_ms). +/// +/// Digest v2 (A3): `expires_at_ms` is part of the comment's identity — +/// holders cannot extend a comment's life without invalidating the sig. fn comment_digest( author: &NodeId, post_id: &PostId, content: &str, timestamp_ms: u64, ref_post_id: Option<&PostId>, + expires_at_ms: u64, ) -> blake3::Hash { let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT); hasher.update(author); hasher.update(post_id); hasher.update(content.as_bytes()); hasher.update(×tamp_ms.to_le_bytes()); - // Domain-separated append: `None` yields the same digest as the v0.6.1 - // scheme, so plain comments keep verifying; `Some(ref)` adds the ref id. + // Domain-separated appends (same pattern as the v0.6.2 `ref:` field). if let Some(rid) = ref_post_id { hasher.update(b"ref:"); hasher.update(rid); } + hasher.update(b"expires:"); + hasher.update(&expires_at_ms.to_le_bytes()); hasher.finalize() } @@ -1096,13 +1114,14 @@ pub fn sign_comment( content: &str, timestamp_ms: u64, ref_post_id: Option<&PostId>, + expires_at_ms: u64, ) -> Vec { let signing_key = SigningKey::from_bytes(seed); - let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id); + let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id, expires_at_ms); signing_key.sign(digest.as_bytes()).to_bytes().to_vec() } -/// Verify a comment's ed25519 signature. +/// Verify a comment's ed25519 signature (digest v2, expiry included). pub fn verify_comment_signature( author: &NodeId, post_id: &PostId, @@ -1110,6 +1129,7 @@ pub fn verify_comment_signature( timestamp_ms: u64, signature: &[u8], ref_post_id: Option<&PostId>, + expires_at_ms: u64, ) -> bool { let Ok(verifying_key) = VerifyingKey::from_bytes(author) else { return false; @@ -1117,10 +1137,177 @@ pub fn verify_comment_signature( let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else { return false; }; - let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id); + let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id, expires_at_ms); verifying_key.verify(digest.as_bytes(), &sig).is_ok() } +/// Convenience: verify an `InlineComment`'s identity signature. +pub fn verify_inline_comment_signature(comment: &crate::types::InlineComment) -> bool { + verify_comment_signature( + &comment.author, + &comment.post_id, + &comment.content, + comment.timestamp_ms, + &comment.signature, + comment.ref_post_id.as_ref(), + comment.expires_at_ms, + ) +} + +// --- v0.8 (A3): self-certifying comment deletes --- + +fn comment_delete_digest(author: &NodeId, post_id: &PostId, timestamp_ms: u64) -> blake3::Hash { + let mut hasher = blake3::Hasher::new_derive_key(COMMENT_DELETE_CONTEXT); + hasher.update(author); + hasher.update(post_id); + hasher.update(×tamp_ms.to_le_bytes()); + hasher.finalize() +} + +/// Sign a comment delete: ed25519 by the comment author's posting key +/// over BLAKE3(context="itsgoin/comment-delete/v1", author || post_id || +/// timestamp_ms). Verifiable from the delete op alone — works on holders +/// that never met the persona (registry unregister path). +pub fn sign_comment_delete( + seed: &[u8; 32], + author: &NodeId, + post_id: &PostId, + timestamp_ms: u64, +) -> Vec { + let signing_key = SigningKey::from_bytes(seed); + let digest = comment_delete_digest(author, post_id, timestamp_ms); + signing_key.sign(digest.as_bytes()).to_bytes().to_vec() +} + +/// Verify a self-certifying comment-delete signature. +pub fn verify_comment_delete( + author: &NodeId, + post_id: &PostId, + timestamp_ms: u64, + signature: &[u8], +) -> bool { + let Ok(verifying_key) = VerifyingKey::from_bytes(author) else { return false; }; + let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else { return false; }; + let digest = comment_delete_digest(author, post_id, timestamp_ms); + verifying_key.verify(digest.as_bytes(), &sig).is_ok() +} + +// --- v0.8 (A3): derivable open-slot V_x (design §27) --- + +/// Derive the open slot's V_x from the post alone: +/// `V_open = blake3::derive_key("itsgoin/open-slot-vx/v1", +/// author_posting_pubkey || slot_binder_nonce)`. +/// Computable by any stranger (author is on the Post, nonce is in the +/// gating); distinct per publish. The CEK recovered through an open slot +/// is PUBLIC — the outer CEK layer is camouflage only; confidentiality +/// (greetings) comes from the inner HPKE-style seal. +pub fn derive_open_slot_vx( + author_posting_pubkey: &NodeId, + slot_binder_nonce: &[u8; 32], +) -> [u8; 32] { + let mut input = [0u8; 64]; + input[..32].copy_from_slice(author_posting_pubkey); + input[32..].copy_from_slice(slot_binder_nonce); + blake3::derive_key(OPEN_SLOT_VX_CONTEXT, &input) +} + +// --- v0.8 (A3): sealed greeting bodies (bucketed) --- + +fn derive_greeting_key_nonce( + shared_secret: &[u8; 32], + bio_post_id: &PostId, +) -> ([u8; 32], [u8; 12]) { + let key_ctx = format!("{}/{}", GREETING_KEY_CONTEXT, hex_lower(bio_post_id)); + let nonce_ctx = format!("{}/{}", GREETING_NONCE_CONTEXT, hex_lower(bio_post_id)); + let key = blake3::derive_key(&key_ctx, shared_secret); + let nonce_full = blake3::derive_key(&nonce_ctx, shared_secret); + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(&nonce_full[..12]); + (key, nonce) +} + +/// Generate a fresh x25519 keypair `(priv_scalar, pub)` for greeting +/// reply keys / ephemeral seal keys. Same ed25519→x25519 derivation path +/// the rest of the codebase uses. +pub fn generate_x25519_keypair() -> ([u8; 32], [u8; 32]) { + generate_vouch_batch_ephemeral() +} + +/// Seal a greeting body to `recipient_x25519_pub`, padded to exactly +/// `body_bucket` plaintext bytes. Output layout: +/// `eph_x25519_pub(32) || ChaCha20-Poly1305(len_u32_le || body || pad)`. +/// Total ciphertext length is `32 + 4 + body_bucket + 16` for every +/// greeting in the same bucket — all greetings on a bio are +/// size-identical ciphertexts. +/// +/// The recipient key is the bio author's posting key converted via +/// `ed25519_pubkey_to_x25519_public` for original greetings, or the raw +/// per-greeting `reply_pubkey` for replies. `bio_post_id` is the post the +/// comment is placed ON (binds the seal to that post). +pub fn seal_greeting_body( + recipient_x25519_pub: &[u8; 32], + bio_post_id: &PostId, + plaintext: &[u8], + body_bucket: usize, +) -> Result> { + if plaintext.len() > body_bucket { + bail!( + "greeting body too large: {} > bucket {}", + plaintext.len(), + body_bucket + ); + } + let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral(); + let shared = x25519_dh(&eph_priv, recipient_x25519_pub); + let (key, nonce) = derive_greeting_key_nonce(&shared, bio_post_id); + + // len prefix + body + random pad to the bucket. + let mut padded = Vec::with_capacity(4 + body_bucket); + padded.extend_from_slice(&(plaintext.len() as u32).to_le_bytes()); + padded.extend_from_slice(plaintext); + let mut pad = vec![0u8; body_bucket - plaintext.len()]; + rand::rng().fill_bytes(&mut pad); + padded.extend_from_slice(&pad); + + let cipher = ChaCha20Poly1305::new_from_slice(&key) + .map_err(|e| anyhow::anyhow!("greeting cipher init: {}", e))?; + let ciphertext = cipher + .encrypt(Nonce::from_slice(&nonce), padded.as_slice()) + .map_err(|e| anyhow::anyhow!("greeting seal: {}", e))?; + + let mut out = Vec::with_capacity(32 + ciphertext.len()); + out.extend_from_slice(&eph_pub); + out.extend_from_slice(&ciphertext); + Ok(out) +} + +/// Try to open a sealed greeting body with the recipient's x25519 +/// private scalar. Returns `None` on any shape/AEAD failure (not sealed +/// to this key, or tampered). +pub fn open_greeting_body( + recipient_x25519_priv: &[u8; 32], + bio_post_id: &PostId, + sealed: &[u8], +) -> Option> { + if sealed.len() < 32 + 4 + 16 { + return None; + } + let mut eph_pub = [0u8; 32]; + eph_pub.copy_from_slice(&sealed[..32]); + let shared = x25519_dh(recipient_x25519_priv, &eph_pub); + let (key, nonce) = derive_greeting_key_nonce(&shared, bio_post_id); + let cipher = ChaCha20Poly1305::new_from_slice(&key).ok()?; + let padded = cipher.decrypt(Nonce::from_slice(&nonce), &sealed[32..]).ok()?; + if padded.len() < 4 { + return None; + } + let real_len = u32::from_le_bytes(padded[..4].try_into().ok()?) as usize; + if 4 + real_len > padded.len() { + return None; + } + Some(padded[4..4 + real_len].to_vec()) +} + /// Sign a reaction: ed25519 over BLAKE3(reactor || post_id || emoji || timestamp_ms). pub fn sign_reaction( seed: &[u8; 32], @@ -1359,20 +1546,133 @@ mod tests { let ref_post = [2u8; 32]; let content = "preview"; let ts = 1000u64; + let exp = 5000u64; // Signature including ref_post_id. - let sig_with_ref = sign_comment(&seed, &nid, &post_id, content, ts, Some(&ref_post)); + let sig_with_ref = sign_comment(&seed, &nid, &post_id, content, ts, Some(&ref_post), exp); // Verifies only when the ref is supplied. - assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&ref_post))); + assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&ref_post), exp)); // Same signature must NOT verify when the ref is dropped (binding). - assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, None)); + assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, None, exp)); // Nor when the ref is swapped. let other_ref = [3u8; 32]; - assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&other_ref))); + assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&other_ref), exp)); - // Plain-comment signature still works (backward compat with v0.6.1). - let sig_plain = sign_comment(&seed, &nid, &post_id, content, ts, None); - assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_plain, None)); + // Plain-comment signature still works. + let sig_plain = sign_comment(&seed, &nid, &post_id, content, ts, None, exp); + assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_plain, None, exp)); + } + + /// A3: digest v2 covers expires_at_ms — mutating the expiry flips + /// verification (no silent TTL extension possible). + #[test] + fn comment_signature_binds_expiry() { + let (seed, nid) = make_keypair(8); + let post_id = [1u8; 32]; + let ts = 1000u64; + let exp = 90_000_000u64; + + let sig = sign_comment(&seed, &nid, &post_id, "hi", ts, None, exp); + assert!(verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, exp)); + // Extended expiry must fail. + assert!(!verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, exp + 1)); + // Stripped expiry (0) must fail. + assert!(!verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, 0)); + } + + /// A3: self-certifying comment-delete roundtrip + wrong-key reject. + #[test] + fn comment_delete_sign_verify() { + let (seed, nid) = make_keypair(9); + let (_mseed, mallory) = make_keypair(10); + let post_id = [4u8; 32]; + let ts = 123_456u64; + + let sig = sign_comment_delete(&seed, &nid, &post_id, ts); + assert_eq!(sig.len(), 64); + assert!(verify_comment_delete(&nid, &post_id, ts, &sig)); + // Wrong author key. + assert!(!verify_comment_delete(&mallory, &post_id, ts, &sig)); + // Wrong tuple. + assert!(!verify_comment_delete(&nid, &post_id, ts + 1, &sig)); + assert!(!verify_comment_delete(&nid, &[5u8; 32], ts, &sig)); + // Garbage signature. + assert!(!verify_comment_delete(&nid, &post_id, ts, &[0u8; 64])); + assert!(!verify_comment_delete(&nid, &post_id, ts, &[])); + } + + /// A3: derive_open_slot_vx determinism + distinctness across nonces + /// and authors. + #[test] + fn open_slot_vx_deterministic_and_distinct() { + let (_s1, a1) = make_keypair(21); + let (_s2, a2) = make_keypair(22); + let n1 = [0xAA; 32]; + let n2 = [0xBB; 32]; + + let v = derive_open_slot_vx(&a1, &n1); + assert_eq!(v, derive_open_slot_vx(&a1, &n1), "deterministic"); + assert_ne!(v, derive_open_slot_vx(&a1, &n2), "distinct per nonce"); + assert_ne!(v, derive_open_slot_vx(&a2, &n1), "distinct per author"); + } + + /// A3: seal/open_greeting_body roundtrip, exact-bucket ciphertext + /// length equality across different plaintext lengths, tamper reject, + /// wrong-key reject. + #[test] + fn greeting_body_roundtrip_and_bucketing() { + let (bob_priv, bob_pub) = make_persona_x25519(30); + let (carol_priv, _carol_pub) = make_persona_x25519(31); + let bio_post_id: PostId = [0x77; 32]; + const BUCKET: usize = 1024; + + let body = br#"{"v":1,"sender_persona":"aa","sender_name":"Al","text":"hi","return_path":"bb","reply_pubkey":"cc"}"#; + let sealed = seal_greeting_body(&bob_pub, &bio_post_id, body, BUCKET).unwrap(); + assert_eq!(sealed.len(), 32 + 4 + BUCKET + 16, "fixed-size ciphertext"); + + // Different plaintext length → same ciphertext length. + let sealed2 = seal_greeting_body(&bob_pub, &bio_post_id, b"x", BUCKET).unwrap(); + assert_eq!(sealed.len(), sealed2.len(), "bucket hides length"); + + // Recipient opens. + let opened = open_greeting_body(&bob_priv, &bio_post_id, &sealed).unwrap(); + assert_eq!(opened, body.to_vec()); + + // Non-recipient cannot. + assert!(open_greeting_body(&carol_priv, &bio_post_id, &sealed).is_none()); + + // Wrong bio post id cannot. + assert!(open_greeting_body(&bob_priv, &[0x88; 32], &sealed).is_none()); + + // Tampered ciphertext fails AEAD. + let mut tampered = sealed.clone(); + let last = tampered.len() - 1; + tampered[last] ^= 0x01; + assert!(open_greeting_body(&bob_priv, &bio_post_id, &tampered).is_none()); + + // Oversized body refused. + assert!(seal_greeting_body(&bob_pub, &bio_post_id, &vec![0u8; BUCKET + 1], BUCKET).is_err()); + } + + /// A3: a reply sealed to a fresh `reply_pubkey` opens with the stored + /// reply private key and NOT with the recipient's long-term key. + #[test] + fn greeting_reply_sealed_to_fresh_key_only() { + // Long-term persona key of the greeting's original sender. + let (longterm_priv, _longterm_pub) = make_persona_x25519(40); + // Fresh per-greeting reply keypair minted by that sender. + let (reply_priv, reply_pub) = generate_x25519_keypair(); + let return_path_post: PostId = [0x99; 32]; + + let reply_body = b"hello back"; + let sealed = seal_greeting_body(&reply_pub, &return_path_post, reply_body, 1024).unwrap(); + + // Stored fresh reply key opens it. + let opened = open_greeting_body(&reply_priv, &return_path_post, &sealed).unwrap(); + assert_eq!(opened, reply_body.to_vec()); + + // Long-term key does NOT. + assert!(open_greeting_body(&longterm_priv, &return_path_post, &sealed).is_none()); } #[test] @@ -1383,7 +1683,6 @@ mod tests { let mut manifest = AuthorManifest { post_id: [42u8; 32], author: node_id, - author_addresses: vec!["10.0.0.1:4433".to_string()], created_at: 1000, updated_at: 2000, previous_posts: vec![ManifestEntry { @@ -1400,6 +1699,97 @@ mod tests { assert!(verify_manifest_signature(&manifest)); } + /// v0.8 (A2): manifests must never carry device addresses — neither the + /// author-signed part nor the CdnManifest wire wrapper. + #[test] + fn test_manifest_carries_no_addresses() { + use crate::types::{AuthorManifest, CdnManifest, ManifestEntry}; + let (seed, node_id) = make_keypair(1); + let (_hseed, host_id) = make_keypair(2); + + let mut manifest = AuthorManifest { + post_id: [42u8; 32], + author: node_id, + created_at: 1000, + updated_at: 2000, + previous_posts: vec![ManifestEntry { + post_id: [1u8; 32], + timestamp_ms: 900, + has_attachments: true, + }], + following_posts: vec![], + signature: vec![], + }; + manifest.signature = sign_manifest(&seed, &manifest); + assert!(verify_manifest_signature(&manifest)); + + let author_json = serde_json::to_string(&manifest).unwrap(); + assert!( + !author_json.contains("addresses"), + "AuthorManifest JSON must not contain any address field: {author_json}" + ); + + let cdn = CdnManifest { + author_manifest: manifest, + host: host_id, + }; + let cdn_json = serde_json::to_string(&cdn).unwrap(); + assert!( + !cdn_json.contains("addresses"), + "CdnManifest JSON must not contain any address field: {cdn_json}" + ); + assert!(!cdn_json.contains("downstream_count")); + assert!(!cdn_json.contains("\"source\"")); + } + + /// A1: crypto pairings must use the MATCHED persona's (id, seed) tuple. + /// The historical bug paired the default posting secret with the NETWORK + /// NodeId — a pair that can never unwrap anything. + #[test] + fn test_cek_unwrap_requires_matching_persona_pair() { + let (author_seed, author_id) = make_keypair(1); + let (persona_seed, persona_id) = make_keypair(2); // recipient persona + let (network_seed, network_id) = make_keypair(9); // device network key + + let (_encrypted, wrapped_keys) = + encrypt_post("hi", &author_seed, &author_id, &[persona_id]).unwrap(); + + // Mismatched pair (persona seed + network id): recipient lookup fails. + let r = unwrap_cek_for_recipient(&persona_seed, &network_id, &author_id, &wrapped_keys).unwrap(); + assert!(r.is_none(), "network id must not appear in recipients"); + + // Network pair entirely: also fails. + let r = unwrap_cek_for_recipient(&network_seed, &network_id, &author_id, &wrapped_keys).unwrap(); + assert!(r.is_none()); + + // Matched persona pair: succeeds. + let r = unwrap_cek_for_recipient(&persona_seed, &persona_id, &author_id, &wrapped_keys).unwrap(); + assert!(r.is_some(), "matched persona (id, seed) pair must unwrap the CEK"); + } + + /// A1 (revocation): rewrap_visibility works with the AUTHOR persona's + /// (seed, id) pair — and cannot work with the network pair the old code + /// passed. + #[test] + fn test_rewrap_visibility_uses_author_persona_pair() { + let (author_seed, author_id) = make_keypair(3); // non-default persona + let (_b_seed, bob_id) = make_keypair(4); + let (_c_seed, carol_id) = make_keypair(5); + let (network_seed, network_id) = make_keypair(9); + + let (_encrypted, wrapped_keys) = + encrypt_post("secret", &author_seed, &author_id, &[bob_id, carol_id]).unwrap(); + + // Old bug shape: (default/other secret, network id) — must fail. + assert!(rewrap_visibility(&network_seed, &network_id, &wrapped_keys, &[author_id, bob_id]).is_err()); + + // Correct: the author persona's own pair — succeeds and re-wraps + // for the reduced recipient set. + let new_wrapped = rewrap_visibility(&author_seed, &author_id, &wrapped_keys, &[author_id, bob_id]).unwrap(); + assert!(new_wrapped.iter().any(|wk| wk.recipient == bob_id)); + assert!(!new_wrapped.iter().any(|wk| wk.recipient == carol_id), "revoked recipient must be gone"); + } + #[test] fn test_forged_manifest_rejected() { use crate::types::AuthorManifest; @@ -1409,7 +1799,6 @@ mod tests { let mut manifest = AuthorManifest { post_id: [42u8; 32], author: node_id, - author_addresses: vec![], created_at: 1000, updated_at: 2000, previous_posts: vec![], diff --git a/crates/core/src/export.rs b/crates/core/src/export.rs index 66768dd..3594b0e 100644 --- a/crates/core/src/export.rs +++ b/crates/core/src/export.rs @@ -91,7 +91,14 @@ pub async fn export_data( let (posts, blob_cids) = if scope == ExportScope::IdentityOnly { (vec![], vec![]) } else { - gather_posts(storage, node_id).await? + // Own posts are authored by POSTING identities (personas) — the + // network NodeId never authors posts. Export across all personas. + let author_ids: Vec = { + let s = storage.get().await; + s.list_posting_identities()? + .into_iter().map(|p| p.node_id).collect() + }; + gather_posts(storage, &author_ids).await? }; let (follows, profiles, settings) = if scope == ExportScope::Everything { @@ -245,10 +252,10 @@ pub async fn export_data( }) } -/// Gather own posts and their blob CIDs. +/// Gather own posts (authored by any of our posting identities) and their blob CIDs. async fn gather_posts( storage: &StoragePool, - node_id: &NodeId, + author_ids: &[NodeId], ) -> anyhow::Result<(Vec, Vec<[u8; 32]>)> { let s = storage.get().await; let posts_with_vis = s.list_posts_with_visibility()?; @@ -257,7 +264,7 @@ async fn gather_posts( for (id, post, vis) in &posts_with_vis { // Only export our own posts - if post.author != *node_id { + if !author_ids.contains(&post.author) { continue; } diff --git a/crates/core/src/fof.rs b/crates/core/src/fof.rs index f43cd90..1e48a7b 100644 --- a/crates/core/src/fof.rs +++ b/crates/core/src/fof.rs @@ -20,7 +20,11 @@ use rand::RngCore; use crate::crypto::{seal_wrap_slot, SealedWrapSlot}; use crate::storage::Storage; -use crate::types::{FoFCommentGating, NodeId, WrapSlot}; +use crate::types::{FoFCommentGating, NodeId, OpenSlotDecl, OpenSlotKind, WrapSlot}; + +/// v0.8 (A3): hard cap on a declared open-slot body bucket. Anything +/// larger on receive is presumed attacker-shaped. +pub const MAX_OPEN_SLOT_BODY_BUCKET: u16 = 4096; /// Build the `FoFCommentGating` block for a post about to be published /// under `CommentPermission::FriendsOfFriends`. The author's keyring @@ -33,9 +37,16 @@ use crate::types::{FoFCommentGating, NodeId, WrapSlot}; /// /// Side effect: this function is pure; no storage writes. The caller /// owns persisting the resulting Post. +/// `open_slot`: when `Some((kind, body_bucket))`, one EXTRA real slot is +/// sealed under the derivable `V_open = +/// derive_open_slot_vx(author, slot_binder_nonce)` and declared in +/// `FoFCommentGating.open_slot` (A3 — greeting/registry open slots). +/// The open slot is a REAL wrap slot sealed by the normal path; what +/// makes it open is only that its V_x is derivable by anyone. pub fn build_fof_comment_gating( storage: &Storage, author_persona_id: &NodeId, + open_slot: Option<(OpenSlotKind, u16)>, ) -> Result> { // Gather the author's keyring with provenance: (V_x, owner, epoch). // The author's own V_me appears with owner=author_persona_id. @@ -70,6 +81,9 @@ pub fn build_fof_comment_gating( // (slot_index, owner, epoch, pub_x) afterward for provenance. enum EntryKind { Real { v_x_owner: NodeId, v_x_epoch: u32 }, + /// A3: the derivable open slot (greeting/registry). Not part of + /// V_x provenance — its key is public by construction. + Open, Dummy, } let mut entries: Vec<(EntryKind, [u8; 32], WrapSlot)> = Vec::with_capacity(tagged_keys.len()); @@ -88,6 +102,25 @@ pub fn build_fof_comment_gating( entries.push((EntryKind::Real { v_x_owner: *owner, v_x_epoch: *epoch }, pub_x, slot)); } + // A3: seal the open slot (if requested) under the derivable V_open. + if open_slot.is_some() { + let v_open = crate::crypto::derive_open_slot_vx(author_persona_id, &slot_binder_nonce); + let mut seed = [0u8; 32]; + rand::rng().fill_bytes(&mut seed); + let signing_key = SigningKey::from_bytes(&seed); + let pub_x = *signing_key.verifying_key().as_bytes(); + let sealed: SealedWrapSlot = seal_wrap_slot(&v_open, &slot_binder_nonce, &cek, &seed)?; + entries.push(( + EntryKind::Open, + pub_x, + WrapSlot { + prefilter_tag: sealed.prefilter_tag, + read_ciphertext: sealed.read_ciphertext, + sign_ciphertext: sealed.sign_ciphertext, + }, + )); + } + // Pad to bucket with dummies. let bucket = crate::profile::next_vouch_batch_bucket(entries.len()); let mut rng = rand::rng(); @@ -117,24 +150,39 @@ pub fn build_fof_comment_gating( let mut pub_post_set: Vec<[u8; 32]> = Vec::with_capacity(entries.len()); let mut wrap_slots: Vec = Vec::with_capacity(entries.len()); let mut real_slot_provenance: Vec = Vec::new(); + let mut open_slot_index: Option = None; for (idx, (kind, pub_x, slot)) in entries.into_iter().enumerate() { - if let EntryKind::Real { v_x_owner, v_x_epoch } = kind { - real_slot_provenance.push(RealSlotProvenance { - slot_index: idx as u32, - v_x_owner, - v_x_epoch, - pub_x, - }); + match kind { + EntryKind::Real { v_x_owner, v_x_epoch } => { + real_slot_provenance.push(RealSlotProvenance { + slot_index: idx as u32, + v_x_owner, + v_x_epoch, + pub_x, + }); + } + EntryKind::Open => open_slot_index = Some(idx as u32), + EntryKind::Dummy => {} } pub_post_set.push(pub_x); wrap_slots.push(slot); } + let open_slot_decl = match (open_slot, open_slot_index) { + (Some((kind, body_bucket)), Some(slot_index)) => Some(OpenSlotDecl { + slot_index, + kind, + body_bucket, + }), + _ => None, + }; + let gating = FoFCommentGating { slot_binder_nonce, pub_post_set, wrap_slots, revocation_list: Vec::new(), + open_slot: open_slot_decl, }; Ok(Some(FoFCommentGatingBuilt { @@ -142,6 +190,7 @@ pub fn build_fof_comment_gating( cek, slot_binder_nonce, real_slot_provenance, + open_slot_index, })) } @@ -164,6 +213,9 @@ pub struct FoFCommentGatingBuilt { /// `own_post_slot_provenance` for later cascade revocations. /// Each entry: (slot_index, v_x_owner, v_x_epoch, pub_x). pub real_slot_provenance: Vec, + /// A3: index of the derivable open slot, when one was requested. + /// Mirrors `gating.open_slot.slot_index`. + pub open_slot_index: Option, } /// One entry per real (non-dummy) slot in a published FoF post. @@ -314,6 +366,37 @@ pub fn sweep_unreadable_on_new_v_x( Ok(unlocked) } +/// v0.8 (A3): the stranger-side derive+open helper. Derives the post's +/// open-slot `V_open` from the post alone and opens the DECLARED slot, +/// returning a `PostUnlock` usable with [`build_fof_comment`]. +/// `persona_id` is filled with the caller-provided commenter id +/// (typically a throwaway identity minted per greeting). +/// +/// Returns `None` when the post has no gating, no open-slot declaration, +/// or the declared slot doesn't open under the derived key (malformed or +/// key-burned open slot = the author's global off-switch). +pub fn derive_open_slot_unlock( + post: &crate::types::Post, + commenter_persona_id: &NodeId, +) -> Option { + let gating = post.fof_gating.as_ref()?; + let decl = gating.open_slot.as_ref()?; + let slot = gating.wrap_slots.get(decl.slot_index as usize)?; + let v_open = crate::crypto::derive_open_slot_vx(&post.author, &gating.slot_binder_nonce); + let opened = crate::crypto::open_wrap_slot( + &v_open, + &gating.slot_binder_nonce, + &slot.read_ciphertext, + &slot.sign_ciphertext, + )?; + Some(PostUnlock { + persona_id: *commenter_persona_id, + slot_index: decl.slot_index, + cek: opened.cek, + priv_x_seed: opened.priv_x_seed, + }) +} + /// Inner helper: prefilter + AEAD-open against a single V_x. fn try_unlock_with_v_x( gating: &crate::types::FoFCommentGating, @@ -376,6 +459,7 @@ pub fn build_fof_comment( body: &str, parent_comment_id: Option<[u8; 32]>, now_ms: u64, + expires_at_ms: u64, ) -> Result { use ed25519_dalek::{Signer, SigningKey}; @@ -411,6 +495,7 @@ pub fn build_fof_comment( "", now_ms, None, + expires_at_ms, ); Ok(crate::types::InlineComment { @@ -424,6 +509,7 @@ pub fn build_fof_comment( pub_x_index: Some(unlock.slot_index), group_sig: Some(group_sig), encrypted_payload: Some(encrypted), + expires_at_ms, }) } @@ -439,7 +525,14 @@ pub fn verify_fof_group_sig( use ed25519_dalek::{Signature, Verifier, VerifyingKey}; let Some(pub_x_index) = comment.pub_x_index else { return false; }; let Some(group_sig) = comment.group_sig.as_ref() else { return false; }; - let Some(encrypted_payload) = comment.encrypted_payload.as_ref() else { return false; }; + // A3: plaintext open-slot comments (registry entries) carry their + // body in `content` with no encrypted_payload — the group_sig then + // covers the content bytes instead. FoF/greeting comments always + // carry Some(encrypted_payload) as before. + let encrypted_payload: &[u8] = match comment.encrypted_payload.as_deref() { + Some(p) => p, + None => comment.content.as_bytes(), + }; let idx = pub_x_index as usize; if idx >= gating.pub_post_set.len() { return false; } if group_sig.len() != 64 { return false; } @@ -552,6 +645,24 @@ pub fn validate_fof_gating_on_receive(post: &crate::types::Post) -> Result<()> { ); } + // A3: open-slot declaration sanity. + if let Some(decl) = gating.open_slot.as_ref() { + if decl.slot_index as usize >= gating.wrap_slots.len() { + anyhow::bail!( + "open_slot.slot_index {} out of bounds ({} slots)", + decl.slot_index, + gating.wrap_slots.len(), + ); + } + if decl.body_bucket == 0 || decl.body_bucket > MAX_OPEN_SLOT_BODY_BUCKET { + anyhow::bail!( + "open_slot.body_bucket {} out of range (1..={})", + decl.body_bucket, + MAX_OPEN_SLOT_BODY_BUCKET, + ); + } + } + Ok(()) } @@ -955,7 +1066,7 @@ mod tests { rand::rng().fill_bytes(&mut v_x_bob); s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("gating built"); + let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("gating built"); // Real count = 2 (own + Bob). Bucket = 8 (minimum floor). assert_eq!(built.gating.pub_post_set.len(), 8); assert_eq!(built.gating.wrap_slots.len(), 8); @@ -1009,7 +1120,7 @@ mod tests { let s = temp_storage(); let (alice_id, _) = make_persona(5); // No V_me inserted. - let built = build_fof_comment_gating(&s, &alice_id).unwrap(); + let built = build_fof_comment_gating(&s, &alice_id, None).unwrap(); assert!(built.is_none(), "no V_me → no gating block"); } @@ -1031,7 +1142,7 @@ mod tests { s.insert_received_vouch_key(&alice_id, &bob_id, 1, &same_key, 2000, None).unwrap(); s.insert_received_vouch_key(&alice_id, &carol_id, 1, &same_key, 3000, None).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); // Unique-key count = 2 (V_me_alice + same_key). Bucket = 8. // We can't assert real count directly without exposing internals, // but we can confirm exactly two distinct successful unwraps: @@ -1088,7 +1199,7 @@ mod tests { alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap(); // Alice publishes the gating block. - let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&alice_storage, &alice_id, None).unwrap().expect("built"); let parent_post_id = [0xCC; 32]; // Bob's device: has his V_me (which is v_x_bob since he handed it out). @@ -1128,6 +1239,7 @@ mod tests { "great post alice", None, 4000, + 4_000_000_000_000, ).unwrap(); assert!(comment.content.is_empty(), "FoF comment body is encrypted, not in content"); assert!(comment.pub_x_index.is_some()); @@ -1183,7 +1295,7 @@ mod tests { rand::rng().fill_bytes(&mut v_x_bob); s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); let post_id = [0xDE; 32]; // Persist the post so apply_fof_revocation_locally can resolve @@ -1228,7 +1340,7 @@ mod tests { }; let comment = build_fof_comment( &post_id, &bob_unlock, &built.slot_binder_nonce, - &bob_id, &bob_seed, "hello", None, 4000, + &bob_id, &bob_seed, "hello", None, 4000, 4_000_000_000_000, ).unwrap(); s.store_comment(&comment).unwrap(); assert_eq!(s.get_comments(&post_id).unwrap().len(), 1, "Bob's comment stored"); @@ -1277,7 +1389,7 @@ mod tests { s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap(); // Initial gating: Alice only. - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); let post_id = [0xBC; 32]; let post = crate::types::Post { author: alice_id, content: "alice".into(), attachments: vec![], @@ -1379,7 +1491,7 @@ mod tests { rand::rng().fill_bytes(&mut v_me_old); s.insert_own_vouch_key(&alice_id, 1, &v_me_old, 1000).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); let post_id = [0xAB; 32]; let post = crate::types::Post { author: alice_id, content: "x".into(), attachments: vec![], @@ -1469,6 +1581,7 @@ mod tests { pub_post_set: (0..slot_count).map(|_| [0u8; 32]).collect(), wrap_slots: (0..slot_count).map(|_| dummy_wrap_slot()).collect(), revocation_list: vec![], + open_slot: None, } } @@ -1574,7 +1687,7 @@ mod tests { rand::rng().fill_bytes(&mut v_me_alice); s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); let post_id = [0xEE; 32]; let post = crate::types::Post { author: alice_id, content: String::new(), attachments: vec![], @@ -1706,7 +1819,7 @@ mod tests { rand::rng().fill_bytes(&mut v_me_alice); alice_storage.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 500).unwrap(); alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 600, None).unwrap(); - let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&alice_storage, &alice_id, None).unwrap().expect("built"); let post = crate::types::Post { author: alice_id, content: String::new(), attachments: vec![], timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), @@ -1750,7 +1863,7 @@ mod tests { rand::rng().fill_bytes(&mut v_x_carol); alice_storage.insert_received_vouch_key(&alice_id, &carol_id, 1, &v_x_carol, 200, None).unwrap(); - let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&alice_storage, &alice_id, None).unwrap().expect("built"); // Bob's storage: holds his own V_me only (no Carol-V_x). The post // shouldn't unlock for him yet. @@ -1826,7 +1939,7 @@ mod tests { rand::rng().fill_bytes(&mut v_x_bob); s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); let body_plaintext = "secret to the FoF set only"; let body_ct = encrypt_fof_body(body_plaintext, &built.cek, &built.slot_binder_nonce).unwrap(); @@ -1905,7 +2018,7 @@ mod tests { s.insert_received_vouch_key(&alice_id, &bob_id, 3, &v_x_bob, 2000, None).unwrap(); s.insert_received_vouch_key(&alice_id, &carol_id, 5, &v_x_carol, 3000, None).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); // 3 unique V_x's → 3 real slots, padded to bucket 8. assert_eq!(built.real_slot_provenance.len(), 3); assert_eq!(built.gating.pub_post_set.len(), 8); @@ -1962,4 +2075,105 @@ mod tests { assert!(verify_fof_revocation(&mallory_id, &post_id, &revoked_pub_x, 1000, 0, &sig)); let _ = alice_seed; } + + // --- v0.8 (A3): open-slot tests --- + + /// Gating built with an open slot: declaration present, validates on + /// receive; a stranger (no vouch keys at all) derives V_open from the + /// post alone, recovers CEK + signing seed, and a comment built with + /// the derived key passes the CDN group_sig check unmodified. + #[test] + fn open_slot_stranger_derive_and_comment() { + use crate::types::{OpenSlotKind, PostingIdentity}; + use ed25519_dalek::SigningKey; + + let s = temp_storage(); + let (alice_id, alice_seed) = make_persona(140); + s.upsert_posting_identity(&PostingIdentity { + node_id: alice_id, secret_seed: alice_seed, + display_name: "Alice".into(), created_at: 1000, + }).unwrap(); + let mut v_me_alice = [0u8; 32]; + rand::rng().fill_bytes(&mut v_me_alice); + s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap(); + + let built = build_fof_comment_gating(&s, &alice_id, Some((OpenSlotKind::Greeting, 1024))) + .unwrap().expect("built"); + let decl = built.gating.open_slot.as_ref().expect("open slot declared"); + assert_eq!(decl.kind, OpenSlotKind::Greeting); + assert_eq!(decl.body_bucket, 1024); + assert_eq!(Some(decl.slot_index), built.open_slot_index); + assert!((decl.slot_index as usize) < built.gating.wrap_slots.len()); + + let post = crate::types::Post { + author: alice_id, content: String::new(), attachments: vec![], + timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), + supersedes_post_id: None, + }; + + // Wire-shape validation passes. + validate_fof_gating_on_receive(&post).unwrap(); + + // A stranger mints a throwaway identity, derives the unlock. + let (stranger_id, stranger_seed) = make_persona(141); + let unlock = derive_open_slot_unlock(&post, &stranger_id) + .expect("stranger derives the open slot unlock"); + assert_eq!(unlock.cek, built.cek, "stranger recovers the (public) CEK"); + assert_eq!(unlock.slot_index, decl.slot_index); + + // The derived signing seed matches the published pub_x. + let derived_pub = SigningKey::from_bytes(&unlock.priv_x_seed).verifying_key().to_bytes(); + assert_eq!(built.gating.pub_post_set[decl.slot_index as usize], derived_pub); + + // Stranger authors a comment through the normal FoF path — must + // pass the CDN group_sig gate unmodified. + let comment = build_fof_comment( + &crate::content::compute_post_id(&post), &unlock, + &built.slot_binder_nonce, &stranger_id, &stranger_seed, + "hello, stranger here", None, 4000, 4_000_000_000_000, + ).unwrap(); + assert!(verify_fof_group_sig(&comment, &built.gating)); + } + + /// Open-slot declaration bounds are enforced on receive. + #[test] + fn open_slot_validation_bounds() { + use crate::types::{OpenSlotDecl, OpenSlotKind}; + + // slot_index out of bounds. + let mut g = dummy_gating(8); + g.open_slot = Some(OpenSlotDecl { slot_index: 8, kind: OpenSlotKind::Greeting, body_bucket: 1024 }); + let p = dummy_post(Some(g)); + let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string(); + assert!(err.contains("out of bounds"), "got: {}", err); + + // body_bucket too big. + let mut g = dummy_gating(8); + g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Registry, body_bucket: 4097 }); + let p = dummy_post(Some(g)); + let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string(); + assert!(err.contains("out of range"), "got: {}", err); + + // body_bucket zero. + let mut g = dummy_gating(8); + g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Greeting, body_bucket: 0 }); + let p = dummy_post(Some(g)); + assert!(validate_fof_gating_on_receive(&p).is_err()); + + // Well-formed decl passes. + let mut g = dummy_gating(8); + g.open_slot = Some(OpenSlotDecl { slot_index: 3, kind: OpenSlotKind::Greeting, body_bucket: 1024 }); + let p = dummy_post(Some(g)); + validate_fof_gating_on_receive(&p).unwrap(); + } + + /// No open slot declared → derive_open_slot_unlock returns None even + /// though the gating exists. + #[test] + fn open_slot_unlock_requires_declaration() { + let g = dummy_gating(8); + let p = dummy_post(Some(g)); + let (stranger, _) = make_persona(150); + assert!(derive_open_slot_unlock(&p, &stranger).is_none()); + } } diff --git a/crates/core/src/group_key_distribution.rs b/crates/core/src/group_key_distribution.rs index 0a753c2..dd018ed 100644 --- a/crates/core/src/group_key_distribution.rs +++ b/crates/core/src/group_key_distribution.rs @@ -297,4 +297,40 @@ mod tests { assert!(!applied2); assert!(s2.get_group_key(&group_id).unwrap().is_none()); } + + /// A1 (multi-persona): a distribution post addressed to our SECOND + /// persona must still be applied — the trial-unwrap iterates ALL held + /// posting identities, not just the default one. + #[test] + fn second_persona_member_applies_distribution() { + let s = temp_storage(); + let (admin_sec, admin_id) = make_keypair(1); + let (default_sec, default_id) = make_keypair(2); // default persona (not a member) + let (second_sec, second_id) = make_keypair(3); // second persona (the member) + + let group_id = [43u8; 32]; + let record = GroupKeyRecord { + group_id, + circle_name: "second".to_string(), + epoch: 1, + group_public_key: [8u8; 32], + admin: admin_id, + created_at: 100, + canonical_root_post_id: None, + }; + let group_seed = [11u8; 32]; + + // Addressed ONLY to the second persona. + let (_pid, post, visibility) = build_distribution_post( + &admin_id, &admin_sec, &record, &group_seed, &[second_id], + ).unwrap(); + + let personas = vec![ + mk_persona(default_sec, default_id), + mk_persona(second_sec, second_id), + ]; + let applied = try_apply_distribution_post(&s, &post, &visibility, &personas).unwrap(); + assert!(applied, "second persona must unwrap the group seed"); + assert_eq!(s.get_group_seed(&group_id, 1).unwrap().unwrap(), group_seed); + } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 3c679a0..290d93f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -17,6 +17,7 @@ pub mod network; pub mod node; pub mod profile; pub mod protocol; +pub mod registry; pub mod storage; pub mod stun; pub mod types; diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index 6466eff..b4cea44 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -2291,6 +2291,22 @@ impl Network { sent += 1; } } + // v0.8 (A3): no-known-holders fallback — seed the diff through + // up to 3 current mesh connections. Without this, a first + // engagement on a post with no recorded file_holders (e.g. a + // fresh registration on the self-materialized registry post, or + // a greeting on a bio just pulled) goes nowhere until someone + // pulls the header on the slow cadence. + if sent == 0 { + for peer in self.connected_peers().await.into_iter().take(3) { + if &peer == exclude_peer { + continue; + } + if self.send_to_peer_uni(&peer, MessageType::BlobHeaderDiff, payload).await.is_ok() { + sent += 1; + } + } + } sent } } diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index ede6096..858cbbb 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -81,6 +81,64 @@ fn generate_and_store_initial_v_me( Ok(()) } +/// v0.8 (A3): the CommentPolicy stored for every FoF-gated post at +/// creation time. Fixes the historic dead gate — nothing ever set +/// `CommentPermission::FriendsOfFriends`, so receivers' policy-based +/// arm was unreachable. (The receive gate itself keys on +/// `post.fof_gating.is_some()`; this is belt-and-suspenders.) +fn fof_comment_policy() -> crate::types::CommentPolicy { + crate::types::CommentPolicy { + allow_comments: crate::types::CommentPermission::FriendsOfFriends, + ..Default::default() + } +} + +/// v0.8 (A3): plaintext bucket for sealed greeting bodies on bio posts. +pub const GREETING_BODY_BUCKET: u16 = 1024; + +/// v0.8 (A3): FoFRevocation reason code used when a persona withdraws +/// greeting consent — the open-slot pub_x of every prior bio is revoked +/// so holders stop accepting (and purge) greetings on superseded bios. +pub const GREETING_CONSENT_REVOKE_REASON: u8 = 2; + +/// v0.8 (A3): per-persona greeting consent. PRE-CHECKED default (round +/// 8): unset = ON. The UI presents the checkbox as an active choice at +/// first profile publish; unchecking sets the key to "0" and republishes +/// the bio without a greeting slot. +pub fn greetings_open_setting_key(posting_id: &NodeId) -> String { + format!("greetings_open.{}", hex::encode(posting_id)) +} + +fn greetings_open_setting(storage: &crate::storage::Storage, posting_id: &NodeId) -> bool { + storage + .get_setting(&greetings_open_setting_key(posting_id)) + .ok() + .flatten() + .map(|v| v != "0") + .unwrap_or(true) +} + +/// v0.8 (A3): persist the author-side state of a freshly-published +/// gated post: slot provenance (cascade revocation), cached CEK +/// (author-direct decrypt), and the FriendsOfFriends comment policy. +fn persist_gated_post_author_state( + storage: &crate::storage::Storage, + author_persona_id: &NodeId, + post_id: &PostId, + built: &crate::fof::FoFCommentGatingBuilt, +) { + for entry in &built.real_slot_provenance { + let _ = storage.record_post_slot_provenance( + author_persona_id, post_id, entry.slot_index, + &entry.v_x_owner, entry.v_x_epoch, &entry.pub_x, + ); + } + let _ = storage.cache_own_fof_post_cek( + author_persona_id, post_id, &built.cek, &built.slot_binder_nonce, + ); + let _ = storage.set_comment_policy(post_id, &fof_comment_policy()); +} + /// Async wrapper used by `Node::create_posting_identity`. Acquires the /// storage handle and delegates to the sync helper. async fn ensure_initial_v_me( @@ -428,6 +486,75 @@ impl Node { budget_last_reset_ms, }; + // v0.8 one-time migrations (best-effort; never block init): + // + // (a) Legacy (pre-posting/network split) profile rows keyed by the + // NETWORK id may still carry persona fields from the unified-id + // era. Strip them in place so no code path can ever re-broadcast + // persona data bound to the device network id. Topology fields + // (anchors/recent_peers/preferred_peers) are preserved. + // + // (b) The manifest signature digest dropped author_addresses, so + // rows signed by pre-v0.8 builds no longer verify. Re-sign our + // own manifests with the matching persona key; purge cached + // foreign manifests that can never verify again (they'd sit + // silently un-propagatable otherwise). Guarded by a settings + // flag so it runs once per data dir. + { + let s = storage.get().await; + + // (a) strip persona fields from the network-id row + if let Ok(Some(p)) = s.get_profile(&node.node_id) { + if !p.display_name.is_empty() || !p.bio.is_empty() || p.avatar_cid.is_some() { + let mut stripped = p; + stripped.display_name = String::new(); + stripped.bio = String::new(); + stripped.avatar_cid = None; + if s.store_profile(&stripped).is_ok() { + info!("v0.8 migration: stripped persona fields from network-id profile row"); + } + } + } + + // (b) re-sign own / purge stale-foreign CDN manifests + if s.get_setting("v08_manifest_resign_done").ok().flatten().is_none() { + let personas: std::collections::HashMap = + s.list_posting_identities() + .unwrap_or_default() + .into_iter() + .map(|pi| (pi.node_id, pi.secret_seed)) + .collect(); + let mut resigned = 0usize; + let mut purged = 0usize; + for (cid, json) in s.list_all_cdn_manifests().unwrap_or_default() { + let Ok(mut m) = serde_json::from_str::(&json) else { + let _ = s.delete_cdn_manifest(&cid); + purged += 1; + continue; + }; + if crypto::verify_manifest_signature(&m) { + continue; // already valid under the v0.8 digest + } + if let Some(seed) = personas.get(&m.author) { + m.signature = crypto::sign_manifest(seed, &m); + if let Ok(updated_json) = serde_json::to_string(&m) { + let _ = s.store_cdn_manifest(&cid, &updated_json, &m.author, m.updated_at); + resigned += 1; + } + } else { + // Foreign manifest signed under the pre-v0.8 digest — + // can never verify again; drop it so it isn't re-served. + let _ = s.delete_cdn_manifest(&cid); + purged += 1; + } + } + let _ = s.set_setting("v08_manifest_resign_done", "1"); + if resigned > 0 || purged > 0 { + info!(resigned, purged, "v0.8 migration: manifest re-sign/purge complete"); + } + } + } + // Startup backfill: any named persona without a profile post gets // one synthesized at its own `created_at`. Makes legacy / imported // named personas Discover-able without requiring a manual rename. @@ -436,6 +563,22 @@ impl Node { warn!(error = %e, "Profile-post backfill failed; continuing init"); } + // v0.8 (A3): self-materialize the registry post (store-if-absent) + // so every node can hold/serve the registration chain — no fetch + // needed, and the existing engagement-check cadence keeps its + // comment chain refreshing. + { + let s = node.storage.get().await; + match crate::registry::materialize_registry_post(&s) { + Ok(true) => info!( + post_id = hex::encode(crate::registry::REGISTRY_POST_ID), + "Registry post self-materialized" + ), + Ok(false) => {} + Err(e) => warn!(error = %e, "Registry post materialization failed"), + } + } + Ok(node) } @@ -825,9 +968,16 @@ impl Node { /// Create a new posting identity with a fresh ed25519 key. Auto-follows /// the new identity so its own posts show in the merged feed. + /// + /// `greetings_open` is the persona's greeting-consent choice (round 8: + /// an ACTIVE pre-checked choice, never silently defaulted on the + /// wire). It is persisted BEFORE the initial bio publish so an + /// opted-out persona never ships a greeting slot at all. `None` + /// keeps the pre-checked default (ON). pub async fn create_posting_identity( &self, display_name: String, + greetings_open: Option, ) -> anyhow::Result { let key = iroh::SecretKey::generate(&mut rand::rng()); let seed: [u8; 32] = key.to_bytes(); @@ -846,6 +996,14 @@ impl Node { s.upsert_posting_identity(&identity)?; // Auto-follow this persona so its own posts reach its own feed. s.add_follow(&node_id)?; + // Record greeting consent BEFORE the initial bio publish below + // reads it — an opt-out persona must never ship a slot. + if let Some(open) = greetings_open { + s.set_setting( + &greetings_open_setting_key(&node_id), + if open { "1" } else { "0" }, + )?; + } } // If the user supplied a non-empty display name at creation time, @@ -882,11 +1040,22 @@ impl Node { ) -> anyhow::Result<()> { // FoF Layer 1: build the vouch-grant batch (if this persona has // any current vouch targets) + bump the bio_epoch. - let (vouch_grants, bio_epoch) = { + // v0.8 (A3): when the persona's `greetings_open` consent is on, + // the bio carries FoF gating with a Greeting open slot. + let (vouch_grants, bio_epoch, gating_built) = { let storage = self.storage.get().await; let batch = crate::profile::build_vouch_grant_batch(&*storage, posting_id)?; let epoch = storage.next_bio_epoch_for(posting_id)?; - (batch, epoch) + let gating = if greetings_open_setting(&storage, posting_id) { + crate::fof::build_fof_comment_gating( + &*storage, + posting_id, + Some((crate::types::OpenSlotKind::Greeting, GREETING_BODY_BUCKET)), + )? + } else { + None + }; + (batch, epoch, gating) }; let profile_post = crate::profile::build_profile_post( posting_id, @@ -896,6 +1065,7 @@ impl Node { avatar_cid, vouch_grants, bio_epoch, + gating_built.as_ref().map(|b| b.gating.clone()), ); let profile_post_id = crate::content::compute_post_id(&profile_post); let timestamp_ms = profile_post.timestamp_ms; @@ -913,6 +1083,9 @@ impl Node { &profile_post, Some(&VisibilityIntent::Profile), )?; + if let Some(built) = &gating_built { + persist_gated_post_author_state(&storage, posting_id, &profile_post_id, built); + } } self.update_neighbor_manifests_as( posting_id, @@ -1205,7 +1378,7 @@ impl Node { // Build the gating block from the default persona's keyring. let built = { let storage = self.storage.get().await; - crate::fof::build_fof_comment_gating(&*storage, &self.default_posting_id)? + crate::fof::build_fof_comment_gating(&*storage, &self.default_posting_id, None)? .ok_or_else(|| anyhow::anyhow!( "default persona has no V_me; rotate or recreate before FoF posts" ))? @@ -1242,6 +1415,10 @@ impl Node { &cek, &gating.slot_binder_nonce, ); } + // v0.8 (A3): FIX THE DEAD GATE — store the FriendsOfFriends + // policy at creation for every gated post (belt-and- + // suspenders; the receive gate keys on fof_gating presence). + let _ = storage.set_comment_policy(&post_id, &fof_comment_policy()); } Ok((post_id, post, visibility, cek)) @@ -1304,7 +1481,7 @@ impl Node { ) -> anyhow::Result<(PostId, Post, [u8; 32])> { let built = { let storage = self.storage.get().await; - crate::fof::build_fof_comment_gating(&*storage, &self.default_posting_id)? + crate::fof::build_fof_comment_gating(&*storage, &self.default_posting_id, None)? .ok_or_else(|| anyhow::anyhow!( "default persona has no V_me; rotate or recreate before FoF posts" ))? @@ -1354,6 +1531,8 @@ impl Node { let _ = storage.cache_own_fof_post_cek( &self.default_posting_id, &post_id, &cek, &slot_binder_nonce, ); + // v0.8 (A3): store FriendsOfFriends policy at creation. + let _ = storage.set_comment_policy(&post_id, &fof_comment_policy()); } self.update_neighbor_manifests_as( @@ -1562,7 +1741,6 @@ impl Node { let manifest = crate::types::AuthorManifest { post_id, author: *posting_id, - author_addresses: self.network.our_addresses(), created_at: now, updated_at: now, previous_posts: previous, @@ -1589,16 +1767,13 @@ impl Node { let storage = self.storage.get().await; storage.get_manifests_for_author_blobs(posting_id).unwrap_or_default() }; - let our_addrs = self.network.our_addresses(); for (push_cid, push_json) in &manifests_to_push { if let Ok(author_manifest) = serde_json::from_str::(push_json) { + // v0.8: no device addresses ride the manifest — receivers + // learn the holder from the QUIC-authenticated connection. let cdn_manifest = crate::types::CdnManifest { - author_manifest: author_manifest, + author_manifest, host: self.node_id, - host_addresses: our_addrs.clone(), - source: self.node_id, - source_addresses: our_addrs.clone(), - downstream_count: 0, }; self.network.push_manifest_to_downstream(push_cid, &cdn_manifest).await; } @@ -1897,11 +2072,21 @@ impl Node { // FoF Layer 1: build the vouch-grant batch (if this persona has // any current vouch targets) + bump bio_epoch. - let (vouch_grants, bio_epoch) = { + // v0.8 (A3): attach the Greeting open slot when consent is on. + let (vouch_grants, bio_epoch, gating_built) = { let storage = self.storage.get().await; let batch = crate::profile::build_vouch_grant_batch(&*storage, &posting_id)?; let epoch = storage.next_bio_epoch_for(&posting_id)?; - (batch, epoch) + let gating = if greetings_open_setting(&storage, &posting_id) { + crate::fof::build_fof_comment_gating( + &*storage, + &posting_id, + Some((crate::types::OpenSlotKind::Greeting, GREETING_BODY_BUCKET)), + )? + } else { + None + }; + (batch, epoch, gating) }; let profile_post = crate::profile::build_profile_post( &posting_id, @@ -1911,6 +2096,7 @@ impl Node { avatar_cid, vouch_grants, bio_epoch, + gating_built.as_ref().map(|b| b.gating.clone()), ); let profile_post_id = crate::content::compute_post_id(&profile_post); let timestamp_ms = profile_post.timestamp_ms; @@ -1931,6 +2117,9 @@ impl Node { &profile_post, Some(&VisibilityIntent::Profile), )?; + if let Some(built) = &gating_built { + persist_gated_post_author_state(&storage, &posting_id, &profile_post_id, built); + } if !display_name.is_empty() { if let Ok(Some(marker)) = storage.get_setting("first_run_auto_persona_id") { if marker == hex::encode(posting_id) { @@ -1994,23 +2183,24 @@ impl Node { let recent_peers = self.current_recent_peers().await; let profile = { let storage = self.storage.get().await; - let existing = storage.get_profile(&self.node_id)?; - let (display_name, bio, public_visible, avatar_cid) = match existing { - Some(p) => (p.display_name, p.bio, p.public_visible, p.avatar_cid), - None => (String::new(), String::new(), true, None), - }; let preferred_peers = storage.list_preferred_peers().unwrap_or_default(); + // v0.8: the network-id-keyed profile row is TOPOLOGY ONLY + // (anchors, recent_peers, preferred_peers). Persona fields + // (display_name, bio, avatar_cid, public_visible) live + // exclusively on posting-id-keyed rows written by profile + // posts — never copy them onto the network row, or a legacy + // unified-id row would keep re-linking persona to device. let profile = PublicProfile { node_id: self.node_id, - display_name, - bio, + display_name: String::new(), + bio: String::new(), updated_at: now, anchors, recent_peers, preferred_peers, - public_visible, - avatar_cid, + public_visible: true, + avatar_cid: None, }; storage.store_profile(&profile)?; @@ -2287,16 +2477,23 @@ impl Node { post: &Post, visibility: &PostVisibility, group_seeds: &std::collections::HashMap<([u8; 32], u64), ([u8; 32], [u8; 32])>, + personas: &[crate::types::PostingIdentity], ) -> anyhow::Result>> { match visibility { PostVisibility::Public => Ok(Some(data)), PostVisibility::Encrypted { recipients } => { - let cek = crypto::unwrap_cek_for_recipient( - &self.default_posting_secret, - &self.node_id, - &post.author, - recipients, - )?; + // Recipients are POSTING ids — try every persona's (id, seed) + // pair, same as decrypt_posts does for post bodies. + let cek = personas.iter().find_map(|pi| { + crypto::unwrap_cek_for_recipient( + &pi.secret_seed, + &pi.node_id, + &post.author, + recipients, + ) + .ok() + .flatten() + }); match cek { Some(cek) => { let plaintext = crypto::decrypt_bytes_with_cek(&data, &cek)?; @@ -2340,7 +2537,7 @@ impl Node { }; // Single lock acquisition for all DB reads - let (post, visibility, group_seeds) = { + let (post, visibility, group_seeds, personas) = { let storage = self.storage.get().await; let _ = storage.touch_blob_access(cid); match storage.get_post_with_visibility(post_id)? { @@ -2350,7 +2547,12 @@ impl Node { } else { std::collections::HashMap::new() }; - (post, vis, seeds) + let personas = if matches!(vis, PostVisibility::Encrypted { .. }) { + storage.list_posting_identities().unwrap_or_default() + } else { + Vec::new() + }; + (post, vis, seeds, personas) } None => return Ok(Some(raw_data)), // No post context — return raw } @@ -2358,7 +2560,7 @@ impl Node { // Lock released — decrypt without lock match &visibility { PostVisibility::Public => Ok(Some(raw_data)), - _ => self.decrypt_blob_for_post(raw_data, &post, &visibility, &group_seeds), + _ => self.decrypt_blob_for_post(raw_data, &post, &visibility, &group_seeds, &personas), } } @@ -2466,14 +2668,13 @@ impl Node { } } - // Record upstream source - let source_addrs: Vec = response.manifest.as_ref() - .map(|m| m.host_addresses.clone()) - .unwrap_or_default(); + // Record upstream source. v0.8: manifests carry no addresses; + // the holder's address is already known from the live connection + // (peers table) — record the holder id with no manifest addrs. let _ = storage.touch_file_holder( cid, from_peer, - &source_addrs, + &[], crate::storage::HolderDirection::Received, ); } @@ -2620,13 +2821,15 @@ impl Node { let post_to_propagate: Option<(PostId, u64, NodeId, [u8; 32])> = { let storage = self.storage.get().await; if let Ok(Some(gk)) = storage.get_group_key_by_circle(&circle_name) { - if gk.admin == self.default_posting_id { + // "Am I the admin?" = admin ∈ ALL my posting identities; use + // the MATCHED persona's (id, seed) pair for all crypto below. + if let Ok(Some(admin_persona)) = storage.get_posting_identity(&gk.admin) { if let Ok(Some(seed)) = storage.get_group_seed(&gk.group_id, gk.epoch) { // Record our own wrapped member key locally (so we // still track membership in group_member_keys for // rotation math). if let Ok(wrapped_new) = crypto::wrap_group_key_for_member( - &self.default_posting_secret, &node_id, &seed, + &admin_persona.secret_seed, &node_id, &seed, ) { let _ = storage.store_group_member_key( &gk.group_id, @@ -2639,8 +2842,8 @@ impl Node { } match crate::group_key_distribution::build_distribution_post( - &self.default_posting_id, - &self.default_posting_secret, + &admin_persona.node_id, + &admin_persona.secret_seed, &gk, &seed, &[node_id], @@ -2652,7 +2855,7 @@ impl Node { &visibility, &VisibilityIntent::GroupKeyDistribute, )?; - Some((post_id, post.timestamp_ms, self.default_posting_id, self.default_posting_secret)) + Some((post_id, post.timestamp_ms, admin_persona.node_id, admin_persona.secret_seed)) } Err(e) => { warn!(error = %e, "failed to build key-distribution post"); @@ -2715,8 +2918,15 @@ impl Node { } self.create_group_key_inner(&circle_name, Some(root_post_id)).await?; + // initial_members are posting ids — skip ALL of our own personas, + // not the network NodeId (which never appears in member lists). + let own_posting_ids: Vec = { + let storage = self.storage.get().await; + storage.list_posting_identities()? + .into_iter().map(|p| p.node_id).collect() + }; for member in initial_members { - if member == self.node_id { + if own_posting_ids.contains(&member) { continue; } if let Err(e) = self.add_to_circle(circle_name.clone(), member).await { @@ -2886,12 +3096,17 @@ impl Node { .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; + // The admin of a group/circle is a POSTING identity (persona), never + // the network NodeId: the wire format ships admin == post.author + // (a posting id) and receivers verify exactly that + // (group_key_distribution.rs). Creation currently always acts as the + // default persona. let record = crate::types::GroupKeyRecord { group_id, circle_name: circle_name.to_string(), epoch: 1, group_public_key: pubkey, - admin: self.node_id, + admin: self.default_posting_id, created_at: now, canonical_root_post_id, }; @@ -2900,10 +3115,11 @@ impl Node { storage.create_group_key(&record, Some(&seed))?; storage.store_group_seed(&group_id, 1, &seed)?; - // Wrap for ourselves - let self_wrapped = crypto::wrap_group_key_for_member(&self.default_posting_secret, &self.node_id, &seed)?; + // Wrap for ourselves (as the admin persona — member rows are keyed + // by posting ids so the wrapped key is actually unwrappable). + let self_wrapped = crypto::wrap_group_key_for_member(&self.default_posting_secret, &self.default_posting_id, &seed)?; let self_mk = crate::types::GroupMemberKey { - member: self.node_id, + member: self.default_posting_id, epoch: 1, wrapped_group_key: self_wrapped, }; @@ -2913,10 +3129,12 @@ impl Node { // via a single encrypted key-distribution post. v0.6.2 replaces the // per-member uni-stream GroupKeyDistribute push with this // CDN-propagated post (one post per epoch, recipients = all non-self - // members). + // members). Circle members are posting ids — strip ALL our personas. + let own_posting_ids: Vec = storage.list_posting_identities()? + .into_iter().map(|p| p.node_id).collect(); let other_members: Vec = storage.get_circle_members(circle_name)? .into_iter() - .filter(|m| *m != self.node_id) + .filter(|m| !own_posting_ids.contains(m)) .collect(); for member in &other_members { @@ -2970,21 +3188,29 @@ impl Node { let rotate_result = { let storage = self.storage.get().await; let gk = match storage.get_group_key_by_circle(circle_name) { - Ok(Some(gk)) if gk.admin == self.node_id => gk, + Ok(Some(gk)) => gk, + _ => return, + }; + // "Am I the admin?" = admin ∈ my posting identities. Use the + // matched persona's (id, seed) for wrapping + signing. + let admin_persona = match storage.get_posting_identity(&gk.admin) { + Ok(Some(p)) => p, _ => return, }; let remaining_members = match storage.get_circle_members(circle_name) { Ok(m) => m, Err(_) => return, }; - // Always include ourselves + // Always include ourselves — as the admin PERSONA (member sets + // hold posting ids; the network NodeId must never leak into a + // CDN-propagated key-distribution post). let mut all_members = remaining_members; - if !all_members.contains(&self.node_id) { - all_members.push(self.node_id); + if !all_members.contains(&admin_persona.node_id) { + all_members.push(admin_persona.node_id); } - match crypto::rotate_group_key(&self.default_posting_secret, gk.epoch, &all_members) { + match crypto::rotate_group_key(&admin_persona.secret_seed, gk.epoch, &all_members) { Ok((new_seed, new_pubkey, new_epoch, member_keys)) => { - Some((gk.group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name.to_string(), gk.canonical_root_post_id)) + Some((gk.group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name.to_string(), gk.canonical_root_post_id, admin_persona)) } Err(e) => { warn!(error = %e, "Failed to rotate group key"); @@ -2993,23 +3219,28 @@ impl Node { } }; - if let Some((group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name, canonical_root)) = rotate_result { + if let Some((group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name, canonical_root, admin_persona)) = rotate_result { // Update storage - { + let own_posting_ids: Vec = { let storage = self.storage.get().await; let _ = storage.update_group_epoch(&group_id, new_epoch, &new_pubkey, Some(&new_seed)); let _ = storage.store_group_seed(&group_id, new_epoch, &new_seed); for mk in &member_keys { let _ = storage.store_group_member_key(&group_id, mk); } - } + storage.list_posting_identities() + .unwrap_or_default() + .into_iter().map(|p| p.node_id).collect() + }; // v0.6.2: distribute the new seed via an encrypted // key-distribution post instead of per-member unicast pushes. + // Strip ALL our personas (never just the default one) so no + // self-addressed wrapped CEK rides a propagated post. let recipients: Vec = member_keys .iter() .map(|mk| mk.member) - .filter(|m| *m != self.default_posting_id) + .filter(|m| !own_posting_ids.contains(m)) .collect(); if !recipients.is_empty() { @@ -3018,7 +3249,7 @@ impl Node { circle_name: circle_name.clone(), epoch: new_epoch, group_public_key: new_pubkey, - admin: self.default_posting_id, + admin: admin_persona.node_id, created_at: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) @@ -3026,8 +3257,8 @@ impl Node { canonical_root_post_id: canonical_root, }; match crate::group_key_distribution::build_distribution_post( - &self.default_posting_id, - &self.default_posting_secret, + &admin_persona.node_id, + &admin_persona.secret_seed, &record, &new_seed, &recipients, @@ -3041,7 +3272,7 @@ impl Node { ); } self.update_neighbor_manifests_as( - &self.default_posting_id, &self.default_posting_secret, &post_id, ts, + &admin_persona.node_id, &admin_persona.secret_seed, &post_id, ts, ).await; } Err(e) => { @@ -3073,17 +3304,8 @@ impl Node { .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; - let cp = crate::types::CircleProfile { - author: self.default_posting_id, - circle_name: circle_name.clone(), - display_name, - bio, - avatar_cid, - updated_at: now, - }; - // Get group key for this circle - let (encrypted_payload, wrapped_cek, group_id, epoch) = { + let (cp, encrypted_payload, wrapped_cek, group_id, epoch) = { let storage = self.storage.get().await; // Verify circle exists let circles = storage.list_circles()?; @@ -3094,9 +3316,20 @@ impl Node { let gk = storage.get_group_key_by_circle(&circle_name)? .ok_or_else(|| anyhow::anyhow!("no group key for circle '{}'", circle_name))?; - if gk.admin != self.node_id { - anyhow::bail!("not admin of circle '{}'", circle_name); - } + // Admin is a posting identity — check membership across ALL our + // personas and author the profile as the MATCHED persona so the + // local row key matches what receivers store (cp.author). + let admin_persona = storage.get_posting_identity(&gk.admin)? + .ok_or_else(|| anyhow::anyhow!("not admin of circle '{}'", circle_name))?; + + let cp = crate::types::CircleProfile { + author: admin_persona.node_id, + circle_name: circle_name.clone(), + display_name, + bio, + avatar_cid, + updated_at: now, + }; let seed = storage.get_group_seed(&gk.group_id, gk.epoch)? .ok_or_else(|| anyhow::anyhow!("group seed not found for circle '{}'", circle_name))?; @@ -3105,10 +3338,11 @@ impl Node { let json = serde_json::to_string(&cp)?; let (encrypted, wrapped) = crypto::encrypt_post_for_group(&json, &seed, &gk.group_public_key)?; - // Store plaintext + encrypted form + // Store plaintext + encrypted form, both keyed by the authoring + // persona id (same key class as the pushed payload/remote rows). storage.set_circle_profile(&cp)?; storage.store_remote_circle_profile( - &self.node_id, + &admin_persona.node_id, &circle_name, &cp, &encrypted, @@ -3117,12 +3351,12 @@ impl Node { gk.epoch, )?; - (encrypted, wrapped, gk.group_id, gk.epoch) + (cp, encrypted, wrapped, gk.group_id, gk.epoch) }; // Push to all connected mesh peers let payload = crate::protocol::CircleProfileUpdatePayload { - author: self.default_posting_id, + author: cp.author, circle_name, group_id, epoch, @@ -3148,16 +3382,20 @@ impl Node { let storage = self.storage.get().await; let gk = storage.get_group_key_by_circle(&circle_name)? .ok_or_else(|| anyhow::anyhow!("no group key for circle '{}'", circle_name))?; + // Admin ∈ our posting identities; the local row is keyed by that + // persona id (matches set_circle_profile), so delete that row. + let admin_persona = storage.get_posting_identity(&gk.admin)? + .ok_or_else(|| anyhow::anyhow!("not admin of circle '{}'", circle_name))?; let seed = storage.get_group_seed(&gk.group_id, gk.epoch)? .ok_or_else(|| anyhow::anyhow!("group seed not found"))?; // Encrypt empty string as tombstone let (encrypted, wrapped) = crypto::encrypt_post_for_group("", &seed, &gk.group_public_key)?; - storage.delete_circle_profile(&self.node_id, &circle_name)?; + storage.delete_circle_profile(&admin_persona.node_id, &circle_name)?; crate::protocol::CircleProfileUpdatePayload { - author: self.default_posting_id, + author: admin_persona.node_id, circle_name, group_id: gk.group_id, epoch: gk.epoch, @@ -3171,40 +3409,41 @@ impl Node { Ok(()) } - /// Set public_visible flag and push profile update. + /// Set public_visible flag on our own persona profile. + /// + /// v0.8: public_visible is a persona-class flag (it gates persona display + /// fields), so it lives on the posting-id-keyed profile row — matching + /// publish_profile / my_profile — NOT the network-id row. No wire push: + /// the ProfileUpdate receive path blind-REPLACEs rows, so pushing a + /// sanitized (persona-free) posting-id profile would wipe receivers' + /// stored display fields for this persona. The flag propagates locally; + /// carrying it in ProfilePostContent is tracked future work. pub async fn set_public_visible(&self, visible: bool) -> anyhow::Result<()> { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; - let recent_peers = self.current_recent_peers().await; - let profile = { - let storage = self.storage.get().await; - let existing = storage.get_profile(&self.node_id)?; - let (display_name, bio, avatar_cid) = match existing { - Some(p) => (p.display_name, p.bio, p.avatar_cid), - None => (String::new(), String::new(), None), - }; - let existing_anchors = storage.get_peer_anchors(&self.node_id).unwrap_or_default(); - let preferred_peers = storage.list_preferred_peers().unwrap_or_default(); - - let profile = PublicProfile { - node_id: self.node_id, - display_name, - bio, + let storage = self.storage.get().await; + let pid = self.default_posting_id; + let profile = match storage.get_profile(&pid)? { + Some(mut p) => { + p.public_visible = visible; + p.updated_at = now; + p + } + None => PublicProfile { + node_id: pid, + display_name: String::new(), + bio: String::new(), updated_at: now, - anchors: existing_anchors, - recent_peers, - preferred_peers, + anchors: vec![], + recent_peers: vec![], + preferred_peers: vec![], public_visible: visible, - avatar_cid, - }; - - storage.store_profile(&profile)?; - profile + avatar_cid: None, + }, }; - - self.network.push_profile(&profile).await; + storage.store_profile(&profile)?; Ok(()) } @@ -3214,23 +3453,42 @@ impl Node { author: &NodeId, ) -> anyhow::Result<(String, String, Option<[u8; 32]>)> { let storage = self.storage.get().await; - storage.resolve_display_for_peer(author, &self.node_id) + // Viewer identity for circle-profile resolution = ALL our posting + // identities (circle membership is posting-class, never network id). + let viewers: Vec = storage.list_posting_identities() + .unwrap_or_default() + .into_iter().map(|p| p.node_id).collect(); + storage.resolve_display_for_peer(author, &viewers) } - /// Get our own circle profile for a given circle. + /// Get our own circle profile for a given circle. Own circle-profile rows + /// are keyed by the authoring persona (the circle's admin posting id). pub async fn get_circle_profile( &self, circle_name: &str, ) -> anyhow::Result> { let storage = self.storage.get().await; - storage.get_circle_profile(&self.node_id, circle_name) + let gk = match storage.get_group_key_by_circle(circle_name)? { + Some(gk) => gk, + None => return Ok(None), + }; + // Own circles only: the admin must be one of OUR posting identities. + // Group-key records for circles we merely belong to (received via + // key-distribution posts) store the REMOTE admin's id; returning that + // row here would surface a foreign circle profile as "our own" in the + // edit dialog. Mirrors the set/delete_circle_profile gate. + if storage.get_posting_identity(&gk.admin)?.is_none() { + return Ok(None); + } + storage.get_circle_profile(&gk.admin, circle_name) } - /// Get the public_visible setting for our own profile. + /// Get the public_visible setting for our own persona profile. + /// v0.8: keyed by the default posting id (see set_public_visible). pub async fn get_public_visible(&self) -> anyhow::Result { let storage = self.storage.get().await; Ok(storage - .get_profile(&self.node_id)? + .get_profile(&self.default_posting_id)? .map(|p| p.public_visible) .unwrap_or(true)) } @@ -3273,20 +3531,24 @@ impl Node { let staleness_ms = 3600 * 1000; - let (candidates, follows) = { + let (candidates, follows, own_ids) = { let storage = self.storage.get().await; let candidates = storage.get_eviction_candidates(staleness_ms)?; let follows = storage.list_follows().unwrap_or_default(); - (candidates, follows) + let own_ids: Vec = storage.list_posting_identities() + .unwrap_or_default() + .into_iter().map(|p| p.node_id).collect(); + (candidates, follows, own_ids) }; if candidates.is_empty() { return Ok(255); // Empty cache = max willingness to accept } - // Filter to non-elevated blobs (not pinned, not own content, not followed author) + // Filter to non-elevated blobs (not pinned, not own content, not followed + // author). Own content = authored by ANY of our posting identities. let non_elevated: Vec<_> = candidates.iter().filter(|c| { - !c.pinned && c.author != self.node_id && !follows.contains(&c.author) + !c.pinned && !own_ids.contains(&c.author) && !follows.contains(&c.author) }).collect(); if non_elevated.is_empty() { @@ -3297,7 +3559,7 @@ impl Node { let mut min_priority = f64::MAX; let mut min_created_at = u64::MAX; for c in &non_elevated { - let priority = self.compute_blob_priority(c, &follows, now); + let priority = self.compute_blob_priority(c, &own_ids, &follows, now); if priority < min_priority { min_priority = priority; min_created_at = c.created_at; @@ -3436,9 +3698,17 @@ impl Node { .ok_or_else(|| anyhow::anyhow!("post not found"))? }; - if post.author != self.node_id { - anyhow::bail!("cannot revoke: you are not the author"); - } + // Posts are authored by POSTING identities (personas), never the + // network NodeId. "Is this mine?" = author ∈ all my posting identities; + // remember the matched persona so crypto below uses its (id, seed) pair. + let author_persona = { + let storage = self.storage.get().await; + storage.get_posting_identity(&post.author)? + }; + let author_persona = match author_persona { + Some(p) => p, + None => anyhow::bail!("cannot revoke: you are not the author"), + }; let existing_recipients = match &visibility { PostVisibility::Public => anyhow::bail!("cannot revoke access on a public post"), @@ -3464,8 +3734,8 @@ impl Node { match mode { RevocationMode::SyncAccessList => { let new_wrapped = crypto::rewrap_visibility( - &self.default_posting_secret, - &self.node_id, + &author_persona.secret_seed, + &author_persona.node_id, existing_recipients, &new_recipient_ids, )?; @@ -3479,12 +3749,7 @@ impl Node { // Propagate via a signed control-visibility post rather than a // direct push. Only the target's author can make such a post. - let author_secret = { - let s = self.storage.get().await; - s.get_posting_identity(&post.author)? - .map(|pi| pi.secret_seed) - .ok_or_else(|| anyhow::anyhow!("missing posting secret for post author"))? - }; + let author_secret = author_persona.secret_seed; let control_post = crate::control::build_visibility_control_post( &post.author, &author_secret, @@ -3514,8 +3779,8 @@ impl Node { RevocationMode::ReEncrypt => { let (new_content, new_wrapped) = crypto::re_encrypt_post( &post.content, - &self.default_posting_secret, - &self.node_id, + &author_persona.secret_seed, + &author_persona.node_id, existing_recipients, &new_recipient_ids, )?; @@ -3524,7 +3789,9 @@ impl Node { }; let new_post = Post { - author: self.default_posting_id, + // Keep the ORIGINAL persona as author — the replacement + // must not migrate content to the default persona. + author: post.author, content: new_content, attachments: post.attachments.clone(), timestamp_ms: post.timestamp_ms, @@ -3558,9 +3825,15 @@ impl Node { revoked: &NodeId, mode: RevocationMode, ) -> anyhow::Result { + // Posts are authored by posting identities — query every persona, + // not the network NodeId (which never authors posts). let posts = { let storage = self.storage.get().await; - storage.find_posts_by_circle_intent(circle_name, &self.node_id)? + let mut all = Vec::new(); + for persona in storage.list_posting_identities()? { + all.extend(storage.find_posts_by_circle_intent(circle_name, &persona.node_id)?); + } + all }; let mut count = 0; @@ -4626,13 +4899,16 @@ impl Node { // ---- Blob Eviction ---- /// Compute priority score for a blob. Higher score = keep longer. + /// `own_author_ids` = ALL of this node's posting identities (blob authors + /// are posting ids, never the network NodeId). pub fn compute_blob_priority( &self, candidate: &crate::storage::EvictionCandidate, + own_author_ids: &[NodeId], follows: &[NodeId], now_ms: u64, ) -> f64 { - compute_blob_priority_standalone(candidate, &self.node_id, follows, now_ms) + compute_blob_priority_standalone(candidate, own_author_ids, follows, now_ms) } /// Delete a blob locally. BlobDeleteNotice was removed in v0.6.2; remote @@ -4666,17 +4942,20 @@ impl Node { // 1-hour staleness for replica counts let staleness_ms = 3600 * 1000; - let (candidates, follows) = { + let (candidates, follows, own_ids) = { let storage = self.storage.get().await; let candidates = storage.get_eviction_candidates(staleness_ms)?; let follows = storage.list_follows().unwrap_or_default(); - (candidates, follows) + let own_ids: Vec = storage.list_posting_identities() + .unwrap_or_default() + .into_iter().map(|p| p.node_id).collect(); + (candidates, follows, own_ids) }; // Score and sort ascending (lowest priority first) let mut scored: Vec<(f64, &crate::storage::EvictionCandidate)> = candidates .iter() - .map(|c| (self.compute_blob_priority(c, &follows, now), c)) + .map(|c| (self.compute_blob_priority(c, &own_ids, &follows, now), c)) .collect(); scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); @@ -4724,6 +5003,28 @@ impl Node { Ok(n) => info!(evicted = n, "Eviction cycle complete"), Err(e) => warn!(error = %e, "Eviction cycle failed"), } + + // v0.8 (A3): comment-TTL sweep piggybacks the storage- + // hygiene loop. Hard delete — expiry is the forgetting + // mechanism; 300s ticks are far inside 30–365d TTLs. + { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let s = node.storage.get().await; + match s.expire_comments(now) { + Ok(0) | Err(_) => {} + Ok(n) => info!(expired = n, "Expired comments swept"), + } + } + + // v0.8 (A3): registry auto-renew — while "Listed" is + // checked, re-sign a fresh 30d entry when the current one + // expires within 5 days (~every 25 days). + if let Err(e) = node.renew_registry_entries_if_due().await { + debug!(error = %e, "Registry auto-renew pass failed"); + } } }) } @@ -4880,7 +5181,13 @@ impl Node { }; // propagate_engagement_diff targets all file_holders (flat set, max 5) // which already subsumes what used to be upstream + downstream. - network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await; + network.propagate_engagement_diff( + &post_id, + &diff, + // exclude_peer is NETWORK-class (file_holders hold device + // ids) — pass the network NodeId, not a posting id. + &self.node_id, + ).await; } Ok(reaction) @@ -4909,7 +5216,13 @@ impl Node { }], timestamp_ms: now, }; - network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await; + network.propagate_engagement_diff( + &post_id, + &diff, + // exclude_peer is NETWORK-class (file_holders hold device + // ids) — pass the network NodeId, not a posting id. + &self.node_id, + ).await; } Ok(()) @@ -4942,11 +5255,14 @@ impl Node { Ok(reactions) } - /// Get reaction counts grouped by emoji for a post. + /// Get reaction counts grouped by emoji for a post. "Mine" = a reaction + /// from ANY of our posting identities. pub async fn get_reaction_counts(&self, post_id: PostId) -> anyhow::Result> { - let our_node_id = self.default_posting_id; let storage = self.storage.get().await; - let counts = storage.get_reaction_counts(&post_id, &our_node_id)?; + let our_ids: Vec = storage.list_posting_identities() + .unwrap_or_default() + .into_iter().map(|p| p.node_id).collect(); + let counts = storage.get_reaction_counts(&post_id, &our_ids)?; Ok(counts) } @@ -5002,6 +5318,8 @@ impl Node { .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; + // v0.8 (A3): TTL drawn BEFORE signing — it's inside the digest. + let expires_at_ms = crate::registry::draw_ordinary_comment_expiry(now); let signature = crate::crypto::sign_comment( &seed, &our_node_id, @@ -5009,6 +5327,7 @@ impl Node { &content, now, ref_post_id.as_ref(), + expires_at_ms, ); let comment = crate::types::InlineComment { @@ -5022,10 +5341,14 @@ impl Node { pub_x_index: None, group_sig: None, encrypted_payload: None, + expires_at_ms, }; let storage = self.storage.get().await; storage.store_comment(&comment)?; + // v0.8 (A3): refresh the aggregated header so pulls serve this + // comment without waiting for a diff roundtrip. + let _ = storage.rebuild_blob_header_from_db(&post_id, &our_node_id, now); drop(storage); // Propagate via BlobHeaderDiff to the target post's known holders. @@ -5037,7 +5360,13 @@ impl Node { ops: vec![crate::types::BlobHeaderDiffOp::AddComment(comment.clone())], timestamp_ms: now, }; - network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await; + network.propagate_engagement_diff( + &post_id, + &diff, + // exclude_peer is NETWORK-class (file_holders hold device + // ids) — pass the network NodeId, not a posting id. + &self.node_id, + ).await; } Ok(comment) @@ -5057,6 +5386,7 @@ impl Node { let storage = self.storage.get().await; storage.edit_comment(&our_node_id, &post_id, timestamp_ms, &new_content)?; + let _ = storage.rebuild_blob_header_from_db(&post_id, &our_node_id, now); drop(storage); // Propagate via BlobHeaderDiff @@ -5073,7 +5403,13 @@ impl Node { }], timestamp_ms: now, }; - network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await; + network.propagate_engagement_diff( + &post_id, + &diff, + // exclude_peer is NETWORK-class (file_holders hold device + // ids) — pass the network NodeId, not a posting id. + &self.node_id, + ).await; } Ok(()) } @@ -5091,8 +5427,18 @@ impl Node { let storage = self.storage.get().await; storage.delete_comment(&our_node_id, &post_id, timestamp_ms)?; + let _ = storage.rebuild_blob_header_from_db(&post_id, &our_node_id, now); drop(storage); + // v0.8 (A3): self-certifying delete signature — holders that + // never met this persona can verify it from the op alone. + let delete_sig = crate::crypto::sign_comment_delete( + &self.default_posting_secret, + &our_node_id, + &post_id, + timestamp_ms, + ); + // Propagate via BlobHeaderDiff { let network = &self.network; @@ -5103,10 +5449,17 @@ impl Node { author: our_node_id, post_id, timestamp_ms, + signature: delete_sig, }], timestamp_ms: now, }; - network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await; + network.propagate_engagement_diff( + &post_id, + &diff, + // exclude_peer is NETWORK-class (file_holders hold device + // ids) — pass the network NodeId, not a posting id. + &self.node_id, + ).await; } Ok(()) } @@ -5141,7 +5494,13 @@ impl Node { ops: vec![crate::types::BlobHeaderDiffOp::SetPolicy(policy)], timestamp_ms: now, }; - network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await; + network.propagate_engagement_diff( + &post_id, + &diff, + // exclude_peer is NETWORK-class (file_holders hold device + // ids) — pass the network NodeId, not a posting id. + &self.node_id, + ).await; } Ok(()) @@ -5206,7 +5565,9 @@ impl Node { }], timestamp_ms: now, }; - self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; + // exclude_peer is NETWORK-class — pass our device NodeId, not the + // posting-class author id. + self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; Ok(()) } @@ -5241,15 +5602,19 @@ impl Node { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; + // v0.8 (A3): expiry rides the outer plaintext fields so + // non-member holders can expire the comment too. + let expires_at_ms = crate::registry::draw_ordinary_comment_expiry(now); let comment = crate::fof::build_fof_comment( &post_id, &unlock, &slot_binder_nonce, - &commenter_id, &commenter_secret, &body, None, now, + &commenter_id, &commenter_secret, &body, None, now, expires_at_ms, )?; // Store locally. { let storage = self.storage.get().await; storage.store_comment(&comment)?; + let _ = storage.rebuild_blob_header_from_db(&post_id, &post_author, now); } // Propagate via engagement-diff path. @@ -5259,7 +5624,9 @@ impl Node { ops: vec![crate::types::BlobHeaderDiffOp::AddComment(comment.clone())], timestamp_ms: now, }; - self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; + // exclude_peer is NETWORK-class — pass our device NodeId, not the + // posting-class author id. + self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; Ok(comment) } @@ -5340,7 +5707,9 @@ impl Node { }], timestamp_ms: now, }; - self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; + // exclude_peer is NETWORK-class — pass our device NodeId, not the + // posting-class author id. + self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; Ok(()) } @@ -5423,7 +5792,9 @@ impl Node { }], timestamp_ms: now, }; - self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; + // exclude_peer is NETWORK-class — pass our device NodeId, not the + // posting-class author id. + self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; Ok(()) } @@ -5462,34 +5833,44 @@ impl Node { // --- Encrypted receipt/comment slot methods --- - /// Unwrap the CEK for a post we are a participant of, returning (cek, sorted_participants). + /// Unwrap the CEK for a post we are a participant of, returning + /// (cek, sorted_participants, our_participant_id) where + /// `our_participant_id` is the POSTING identity of ours that actually + /// matched the participant set (participants are posting ids — the + /// network NodeId never appears in them). /// Returns None if this is a public post or we cannot decrypt. async fn get_post_cek_and_participants( &self, post_id: &PostId, - ) -> anyhow::Result)>> { + ) -> anyhow::Result, NodeId)>> { let storage = self.storage.get().await; let (post, visibility) = match storage.get_post_with_visibility(post_id)? { Some(pv) => pv, None => return Ok(None), }; + let personas = storage.list_posting_identities().unwrap_or_default(); drop(storage); match &visibility { - PostVisibility::Public => Ok(None), PostVisibility::Encrypted { recipients } => { - let cek = crypto::unwrap_cek_for_recipient( - &self.default_posting_secret, - &self.node_id, - &post.author, - recipients, - )?; - match cek { - Some(cek) => { + // Try every persona; remember WHICH one unwrapped the CEK. + let matched = personas.iter().find_map(|pi| { + crypto::unwrap_cek_for_recipient( + &pi.secret_seed, + &pi.node_id, + &post.author, + recipients, + ) + .ok() + .flatten() + .map(|cek| (cek, pi.node_id)) + }); + match matched { + Some((cek, our_id)) => { let mut participants: Vec = recipients.iter().map(|wk| wk.recipient).collect(); participants.sort(); participants.dedup(); - Ok(Some((cek, participants))) + Ok(Some((cek, participants, our_id))) } None => Ok(None), } @@ -5514,11 +5895,19 @@ impl Node { } participants.sort(); participants.dedup(); - Ok(Some((cek, participants))) + // Our participant identity = whichever of our personas is + // in the set (admin/member), falling back to the default + // persona if none is listed. + let our_id = personas.iter() + .map(|p| p.node_id) + .find(|id| participants.contains(id)) + .unwrap_or(self.default_posting_id); + Ok(Some((cek, participants, our_id))) } else { Ok(None) } } + PostVisibility::Public => Ok(None), // FoF Layer 3: FoFClosed posts don't use the legacy // receipt/comment slot mechanism — they use the FoF gating's // CEK_comments. This helper isn't used for FoF posts; @@ -5535,13 +5924,14 @@ impl Node { state: crate::types::ReceiptState, emoji: Option, ) -> anyhow::Result<()> { - let (cek, participants) = self.get_post_cek_and_participants(&post_id).await? + let (cek, participants, our_participant_id) = self.get_post_cek_and_participants(&post_id).await? .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; let slot_key = crypto::derive_slot_key(&cek); - // Find our slot index (sorted participant position) - let our_slot = participants.iter().position(|nid| nid == &self.node_id) - .ok_or_else(|| anyhow::anyhow!("our node_id not found in participants"))?; + // Find our slot index (sorted participant position) — participants + // are posting ids, so search for the persona that matched the CEK. + let our_slot = participants.iter().position(|nid| nid == &our_participant_id) + .ok_or_else(|| anyhow::anyhow!("our posting id not found in participants"))?; // Build plaintext: [1 byte state][8 bytes timestamp_ms][23 bytes emoji+padding] let now = std::time::SystemTime::now() @@ -5623,7 +6013,7 @@ impl Node { post_id: PostId, content: String, ) -> anyhow::Result<()> { - let (cek, _participants) = self.get_post_cek_and_participants(&post_id).await? + let (cek, _participants, our_participant_id) = self.get_post_cek_and_participants(&post_id).await? .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; let slot_key = crypto::derive_slot_key(&cek); @@ -5631,9 +6021,12 @@ impl Node { .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; - // Build plaintext: [32 bytes author_node_id][8 bytes timestamp_ms][216 bytes content+padding] + // Build plaintext: [32 bytes author_posting_id][8 bytes timestamp_ms][216 bytes content+padding] + // Slot authorship is the matched POSTING identity — never the network + // NodeId (mis-attribution + a persona↔device linkage leak to all + // participants). let mut plaintext = [0u8; 256]; - plaintext[..32].copy_from_slice(&self.node_id); + plaintext[..32].copy_from_slice(&our_participant_id); plaintext[32..40].copy_from_slice(&now.to_le_bytes()); let content_bytes = content.as_bytes(); let copy_len = content_bytes.len().min(216); @@ -5736,7 +6129,7 @@ impl Node { &self, post_id: PostId, ) -> anyhow::Result> { - let (cek, participants) = self.get_post_cek_and_participants(&post_id).await? + let (cek, participants, _our_participant_id) = self.get_post_cek_and_participants(&post_id).await? .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; let slot_key = crypto::derive_slot_key(&cek); @@ -5798,7 +6191,7 @@ impl Node { &self, post_id: PostId, ) -> anyhow::Result> { - let (cek, _participants) = self.get_post_cek_and_participants(&post_id).await? + let (cek, _participants, _our_participant_id) = self.get_post_cek_and_participants(&post_id).await? .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; let slot_key = crypto::derive_slot_key(&cek); @@ -5850,6 +6243,674 @@ impl Node { } } +/// v0.8 (A3): a received greeting (or reply), unsealed for the inbox. +/// The `(comment_author, post_id, timestamp_ms)` triple is the comment +/// key used by `reply_to_greeting` / `dismiss_greeting`. +#[derive(Debug, Clone)] +pub struct GreetingRecord { + /// Throwaway outer comment identity (comment key part 1). + pub comment_author: NodeId, + /// The bio/return-path post the comment sits on (comment key part 2). + pub post_id: PostId, + /// Comment timestamp (comment key part 3). + pub timestamp_ms: u64, + /// The sender's REAL persona id (recovered from inside the seal). + pub sender_persona: NodeId, + pub sender_name: String, + pub text: String, + /// Post whose Greeting open slot a reply goes into (inside the seal). + pub return_path: PostId, + /// Fresh per-greeting x25519 pubkey replies must be sealed to. + pub reply_pubkey: [u8; 32], +} + +// --- v0.8 (A3): registry + greetings API --- +impl Node { + /// Per-persona greeting consent toggle. Republishes the persona's bio + /// so the greeting slot appears/disappears on the wire. + /// + /// Turning consent OFF also revokes the Greeting open-slot pub_x of + /// every previously published bio: old bios never expire, and each + /// carries its own valid open slot — without revocation, holders keep + /// accepting greetings on superseded bios indefinitely (each with its + /// own 64-greeting cap). `revoke_fof_commenter` is the designated + /// global off-switch (spec §1.7): the RevocationEntry propagates on + /// the standard rails and holders cascade-purge stored greetings. + pub async fn set_greetings_open(&self, posting_id: &NodeId, open: bool) -> anyhow::Result<()> { + let (secret, display_name, bio, avatar, greeting_slots) = { + let s = self.storage.get().await; + s.set_setting( + &greetings_open_setting_key(posting_id), + if open { "1" } else { "0" }, + )?; + let identity = s.get_posting_identity(posting_id)? + .ok_or_else(|| anyhow::anyhow!("persona not on this device"))?; + let profile = s.get_profile(posting_id)?; + // When closing: collect every prior post by this persona that + // declares a Greeting open slot, so its pub_x can be revoked. + let mut slots: Vec<(PostId, u32)> = Vec::new(); + if !open { + for (post_id, post) in s.list_gated_posts_by_author(posting_id)? { + if let Some(decl) = post + .fof_gating + .as_ref() + .and_then(|g| g.open_slot.as_ref()) + { + if decl.kind == crate::types::OpenSlotKind::Greeting { + slots.push((post_id, decl.slot_index)); + } + } + } + } + ( + identity.secret_seed, + profile.as_ref().map(|p| p.display_name.clone()).unwrap_or_default(), + profile.as_ref().map(|p| p.bio.clone()).unwrap_or_default(), + profile.as_ref().and_then(|p| p.avatar_cid), + slots, + ) + }; + // Republish the bio with the new consent state. + self.publish_profile_post_as(posting_id, &secret, &display_name, &bio, avatar).await?; + // Off-switch: revoke the greeting slot on every prior bio so + // holders stop accepting (and purge) greetings on them. + for (post_id, slot_index) in greeting_slots { + if let Err(e) = self + .revoke_fof_commenter(post_id, slot_index, GREETING_CONSENT_REVOKE_REASON) + .await + { + warn!( + post = hex::encode(post_id), + slot = slot_index, + error = %e, + "Failed to revoke greeting slot on prior bio" + ); + } + } + Ok(()) + } + + /// Current greeting-consent state (unset = ON, the pre-checked default). + pub async fn get_greetings_open(&self, posting_id: &NodeId) -> anyhow::Result { + let s = self.storage.get().await; + Ok(greetings_open_setting(&s, posting_id)) + } + + /// Best-effort network fetch of a post we don't hold: content-search + /// worm by post id, then PostFetch from the reported holders, stored + /// through the standard receive path. + async fn fetch_post_best_effort(&self, post_id: &PostId) -> anyhow::Result> { + let search = self + .network + .content_search(&[0u8; 32], Some(*post_id), None) + .await + .ok() + .flatten(); + if let Some(result) = search { + let holders: Vec = [result.post_holder, Some(result.node_id)] + .into_iter() + .flatten() + .collect(); + for holder in holders { + let _ = self.connect_by_node_id(holder).await; + if let Ok(Some(sp)) = self.network.post_fetch(&holder, post_id).await { + let s = self.storage.get().await; + let _ = crate::control::receive_post( + &s, &sp.id, &sp.post, &sp.visibility, sp.intent.as_ref(), + ); + return Ok(s.get_post(post_id)?); + } + } + } + Ok(None) + } + + /// Shared greeting/reply sender: seal `text` to `recipient_x25519_pub` + /// and drop it into `target_post_id`'s Greeting open slot under a + /// freshly-minted throwaway outer identity. The sealed body carries + /// the ACTING persona's identity + next-hop return path (that + /// persona's bio post) + a fresh reply key. `acting_persona` must be + /// a local posting identity — passing the wrong persona here would + /// disclose an unrelated persona's real posting key inside the seal, + /// silently linking personas the architecture keeps unlinkable. + async fn send_sealed_via_open_slot( + &self, + acting_persona: &NodeId, + target_post_id: PostId, + recipient_x25519_pub: [u8; 32], + text: &str, + ) -> anyhow::Result<()> { + if text.chars().count() > 600 { + anyhow::bail!("greeting text over 600 chars"); + } + + // Load the target post (fetch if absent), require a Greeting slot. + let target_post = { + let s = self.storage.get().await; + s.get_post(&target_post_id)? + }; + let target_post = match target_post { + Some(p) => p, + None => self + .fetch_post_best_effort(&target_post_id) + .await? + .ok_or_else(|| anyhow::anyhow!("target post not held and not fetchable"))?, + }; + let decl = target_post + .fof_gating + .as_ref() + .and_then(|g| g.open_slot.as_ref()) + .ok_or_else(|| anyhow::anyhow!("post declares no open slot"))? + .clone(); + if decl.kind != crate::types::OpenSlotKind::Greeting { + anyhow::bail!("post's open slot is not a Greeting slot"); + } + let slot_binder_nonce = target_post.fof_gating.as_ref().unwrap().slot_binder_nonce; + + // The acting persona's return path + display name + fresh reply + // keypair. Everything inside the seal is per-persona: identity, + // name, and return-path bio must all belong to `acting_persona`. + let (return_path, sender_name) = { + let s = self.storage.get().await; + // Refuse to seal anything if the persona isn't local — a + // wrong id here would misattribute the message. + s.get_posting_identity(acting_persona)? + .ok_or_else(|| anyhow::anyhow!("acting persona not on this device"))?; + let rp = s + .get_latest_profile_post_id_by_author(acting_persona)? + .ok_or_else(|| anyhow::anyhow!( + "no bio post to use as return path — set a profile first (`name `)" + ))?; + let name = s + .get_profile(acting_persona)? + .map(|p| p.display_name) + .unwrap_or_default(); + (rp, name) + }; + let (reply_priv, reply_pub) = crypto::generate_x25519_keypair(); + { + let s = self.storage.get().await; + s.store_greeting_reply_key(&reply_pub, &reply_priv, &return_path)?; + } + + // Sealed body: real (acting) persona + return path + fresh reply key. + let body = crate::types::GreetingBody { + v: 1, + sender_persona: hex::encode(acting_persona), + sender_name: sender_name.chars().take(64).collect(), + text: text.to_string(), + return_path: hex::encode(return_path), + reply_pubkey: hex::encode(reply_pub), + }; + let plaintext = serde_json::to_vec(&body)?; + let sealed = crypto::seal_greeting_body( + &recipient_x25519_pub, + &target_post_id, + &plaintext, + decl.body_bucket as usize, + )?; + let sealed_b64 = { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(&sealed) + }; + + // Throwaway outer identity — minted per greeting, never reused. + // It exists in the network only through this comment and vanishes + // when the comment expires (§20 identity hygiene). + let throwaway_key = iroh::SecretKey::generate(&mut rand::rng()); + let throwaway_seed: [u8; 32] = throwaway_key.to_bytes(); + let throwaway_id: NodeId = *throwaway_key.public().as_bytes(); + + let unlock = crate::fof::derive_open_slot_unlock(&target_post, &throwaway_id) + .ok_or_else(|| anyhow::anyhow!("open slot did not unlock (revoked or malformed)"))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let expires_at_ms = crate::registry::draw_ordinary_comment_expiry(now); + let comment = crate::fof::build_fof_comment( + &target_post_id, + &unlock, + &slot_binder_nonce, + &throwaway_id, + &throwaway_seed, + &sealed_b64, + None, + now, + expires_at_ms, + )?; + + { + let s = self.storage.get().await; + s.store_comment(&comment)?; + let _ = s.rebuild_blob_header_from_db(&target_post_id, &target_post.author, now); + } + + // Propagate on the existing engagement rail. + let diff = crate::protocol::BlobHeaderDiffPayload { + post_id: target_post_id, + author: target_post.author, + ops: vec![crate::types::BlobHeaderDiffOp::AddComment(comment)], + timestamp_ms: now, + }; + self.network.propagate_engagement_diff(&target_post_id, &diff, &self.node_id).await; + Ok(()) + } + + /// Send a sealed first-contact greeting to a bio post's author. + /// Messaging-first: no vouch is involved anywhere in this flow. + pub async fn send_greeting(&self, bio_post_id: PostId, text: String) -> anyhow::Result<()> { + // Recipient key: the bio author's posting key, converted to x25519. + let author = { + let s = self.storage.get().await; + s.get_post(&bio_post_id)?.map(|p| p.author) + }; + let author = match author { + Some(a) => a, + None => self + .fetch_post_best_effort(&bio_post_id) + .await? + .map(|p| p.author) + .ok_or_else(|| anyhow::anyhow!("bio post not held and not fetchable"))?, + }; + let recipient = crypto::ed25519_pubkey_to_x25519_public(&author)?; + // Outbound first-contact greetings act as the default persona. + let acting = self.default_posting_id; + self.send_sealed_via_open_slot(&acting, bio_post_id, recipient, &text).await + } + + /// Reply to a received greeting: sealed to the greeting's fresh + /// `reply_pubkey` (never a long-term key), dropped into the sender's + /// declared `return_path` post through its Greeting open slot, under + /// a fresh throwaway outer identity. Each reply carries OUR next-hop + /// return path + fresh reply key. + pub async fn reply_to_greeting( + &self, + comment_author: NodeId, + post_id: PostId, + timestamp_ms: u64, + text: String, + ) -> anyhow::Result<()> { + let greeting = self + .list_greetings() + .await? + .into_iter() + .find(|g| { + g.comment_author == comment_author + && g.post_id == post_id + && g.timestamp_ms == timestamp_ms + }) + .ok_or_else(|| anyhow::anyhow!("greeting not found (expired or dismissed?)"))?; + // Act as the persona the greeting was ADDRESSED TO — the author + // of the bio post it arrived on. Hardcoding the default persona + // here would leak the default persona's real posting key + bio + // into the seal, silently linking two personas (and answering as + // someone the counterparty never greeted). + let acting = { + let s = self.storage.get().await; + let bio_author = s + .get_post(&greeting.post_id)? + .map(|p| p.author) + .ok_or_else(|| anyhow::anyhow!("greeting's bio post no longer held"))?; + s.get_posting_identity(&bio_author)? + .ok_or_else(|| anyhow::anyhow!( + "persona the greeting was addressed to is not on this device" + ))? + .node_id + }; + self.send_sealed_via_open_slot(&acting, greeting.return_path, greeting.reply_pubkey, &text) + .await + } + + /// Unseal + list greetings (and replies) on all of our personas' bio + /// posts. Original greetings open with the persona's long-term key; + /// replies open with the stored per-greeting reply private keys. + /// Dismissed rows are skipped. + pub async fn list_greetings(&self) -> anyhow::Result> { + use base64::Engine; + let s = self.storage.get().await; + let personas = s.list_posting_identities()?; + let reply_keys = s.list_greeting_reply_keys()?; + let mut out: Vec = Vec::new(); + + for persona in &personas { + let persona_priv = crypto::ed25519_seed_to_x25519_private(&persona.secret_seed); + for (post_id, post) in s.list_gated_posts_by_author(&persona.node_id)? { + let Some(gating) = post.fof_gating.as_ref() else { continue }; + let Some(decl) = gating.open_slot.as_ref() else { continue }; + if decl.kind != crate::types::OpenSlotKind::Greeting { + continue; + } + for c in s.get_comments(&post_id)? { + if c.pub_x_index != Some(decl.slot_index) { + continue; + } + if s.is_greeting_dismissed(&c.author, &post_id, c.timestamp_ms)? { + continue; + } + // Outer layer: the CEK is public (derivable open slot). + let Some(unlock) = crate::fof::derive_open_slot_unlock(&post, &c.author) + else { continue }; + let Ok(payload) = crate::fof::decrypt_fof_comment_payload( + &c, &unlock.cek, &gating.slot_binder_nonce, + ) else { continue }; + let Ok(sealed) = + base64::engine::general_purpose::STANDARD.decode(payload.body.as_bytes()) + else { continue }; + // Inner seal: long-term persona key (original + // greetings) or a stored fresh reply key (replies). + let plain = crypto::open_greeting_body(&persona_priv, &post_id, &sealed) + .or_else(|| { + reply_keys.iter().find_map(|(privkey, _rp)| { + crypto::open_greeting_body(privkey, &post_id, &sealed) + }) + }); + let Some(plain) = plain else { continue }; + let Ok(body) = serde_json::from_slice::(&plain) + else { continue }; + let Ok(sender_persona) = crate::parse_node_id_hex(&body.sender_persona) + else { continue }; + let Ok(return_path) = crate::parse_node_id_hex(&body.return_path) + else { continue }; + let Ok(reply_pubkey) = crate::parse_node_id_hex(&body.reply_pubkey) + else { continue }; + out.push(GreetingRecord { + comment_author: c.author, + post_id, + timestamp_ms: c.timestamp_ms, + sender_persona, + sender_name: body.sender_name, + text: body.text, + return_path, + reply_pubkey, + }); + } + } + } + out.sort_by_key(|g| std::cmp::Reverse(g.timestamp_ms)); + Ok(out) + } + + /// Dismiss a greeting (local only — nothing propagates). + pub async fn dismiss_greeting( + &self, + comment_author: NodeId, + post_id: PostId, + timestamp_ms: u64, + ) -> anyhow::Result<()> { + let s = self.storage.get().await; + s.add_greeting_dismissal(&comment_author, &post_id, timestamp_ms) + } + + /// Register a persona in the network registry: a plaintext, + /// self-certifying entry {name, keywords} signed by the persona's + /// REAL posting key, fixed 30-day TTL, newest-wins per persona. + /// Re-running renews. Sets the "Listed" flag for auto-renew. + pub async fn register_persona( + &self, + posting_id: &NodeId, + name: &str, + keywords: &[String], + ) -> anyhow::Result<()> { + let entry = crate::registry::RegistrationEntry { + v: 1, + name: name.to_string(), + keywords: keywords.to_vec(), + }; + let entry_json = serde_json::to_string(&entry)?; + // Enforce shape limits locally before signing anything. + crate::registry::parse_registration(&entry_json)?; + + let (secret_seed, registry_post) = { + let s = self.storage.get().await; + let identity = s.get_posting_identity(posting_id)? + .ok_or_else(|| anyhow::anyhow!("persona not on this device"))?; + let _ = crate::registry::materialize_registry_post(&s); + let post = s.get_post(&crate::registry::REGISTRY_POST_ID)? + .ok_or_else(|| anyhow::anyhow!("registry post missing after materialization"))?; + (identity.secret_seed, post) + }; + + let unlock = crate::fof::derive_open_slot_unlock(®istry_post, posting_id) + .ok_or_else(|| anyhow::anyhow!("registry open slot did not unlock"))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let expires_at_ms = now + crate::registry::REGISTRATION_TTL_MS; + + // group_sig over (content_bytes || post_id || pub_x_index_le) — + // the plaintext-open-slot form of the standard scheme. + let group_sig = { + use ed25519_dalek::{Signer, SigningKey}; + let signer = SigningKey::from_bytes(&unlock.priv_x_seed); + let mut to_sign = Vec::with_capacity(entry_json.len() + 32 + 4); + to_sign.extend_from_slice(entry_json.as_bytes()); + to_sign.extend_from_slice(&crate::registry::REGISTRY_POST_ID); + to_sign.extend_from_slice(&unlock.slot_index.to_le_bytes()); + signer.sign(&to_sign).to_bytes().to_vec() + }; + + // The standard comment signature IS the self-certification: the + // entry names its persona in `author`, verified against that key. + let signature = crypto::sign_comment( + &secret_seed, + posting_id, + &crate::registry::REGISTRY_POST_ID, + &entry_json, + now, + None, + expires_at_ms, + ); + let comment = crate::types::InlineComment { + author: *posting_id, + post_id: crate::registry::REGISTRY_POST_ID, + content: entry_json, + timestamp_ms: now, + signature, + deleted_at: None, + ref_post_id: None, + pub_x_index: Some(unlock.slot_index), + group_sig: Some(group_sig), + encrypted_payload: None, + expires_at_ms, + }; + + { + let s = self.storage.get().await; + // Newest-wins locally (deletes our older entries). + let _ = s.upsert_registry_entry_newest_wins( + &crate::registry::REGISTRY_POST_ID, posting_id, now, + ); + s.store_comment(&comment)?; + let _ = s.rebuild_blob_header_from_db( + &crate::registry::REGISTRY_POST_ID, ®istry_post.author, now, + ); + let id_hex = hex::encode(posting_id); + s.set_setting(&format!("registry_listed.{}", id_hex), "1")?; + s.set_setting(&format!("registry_name.{}", id_hex), name)?; + s.set_setting(&format!("registry_keywords.{}", id_hex), &keywords.join(","))?; + } + + let diff = crate::protocol::BlobHeaderDiffPayload { + post_id: crate::registry::REGISTRY_POST_ID, + author: registry_post.author, + ops: vec![crate::types::BlobHeaderDiffOp::AddComment(comment)], + timestamp_ms: now, + }; + self.network + .propagate_engagement_diff(&crate::registry::REGISTRY_POST_ID, &diff, &self.node_id) + .await; + Ok(()) + } + + /// Remove a persona's registry entry via a self-certifying signed + /// DeleteComment (honored by holders that never met the persona). + /// Clears the "Listed" flag. + pub async fn unregister_persona(&self, posting_id: &NodeId) -> anyhow::Result<()> { + let (secret_seed, newest) = { + let s = self.storage.get().await; + let identity = s.get_posting_identity(posting_id)? + .ok_or_else(|| anyhow::anyhow!("persona not on this device"))?; + let newest = s.get_newest_registry_entry( + &crate::registry::REGISTRY_POST_ID, posting_id, + )?; + s.set_setting(&format!("registry_listed.{}", hex::encode(posting_id)), "0")?; + (identity.secret_seed, newest) + }; + let Some((entry_ts, _exp)) = newest else { + return Ok(()); // nothing listed — flag cleared, done + }; + + let delete_sig = crypto::sign_comment_delete( + &secret_seed, + posting_id, + &crate::registry::REGISTRY_POST_ID, + entry_ts, + ); + { + let s = self.storage.get().await; + let _ = s.delete_comment(posting_id, &crate::registry::REGISTRY_POST_ID, entry_ts); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let _ = s.rebuild_blob_header_from_db( + &crate::registry::REGISTRY_POST_ID, &crate::DEFAULT_ANCHOR_POSTING_ID, now_ms, + ); + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let diff = crate::protocol::BlobHeaderDiffPayload { + post_id: crate::registry::REGISTRY_POST_ID, + author: crate::DEFAULT_ANCHOR_POSTING_ID, + ops: vec![crate::types::BlobHeaderDiffOp::DeleteComment { + author: *posting_id, + post_id: crate::registry::REGISTRY_POST_ID, + timestamp_ms: entry_ts, + signature: delete_sig, + }], + timestamp_ms: now, + }; + self.network + .propagate_engagement_diff(&crate::registry::REGISTRY_POST_ID, &diff, &self.node_id) + .await; + Ok(()) + } + + /// Search the registry: refresh the chain from up to 3 connected + /// peers (BlobHeaderRequest via the existing engagement-fetch rail), + /// then query locally. Search cost lands on the searcher (design §27). + pub async fn search_registry( + &self, + query: &str, + ) -> anyhow::Result> { + // Mark the registry post due so the engagement fetch includes it. + { + let s = self.storage.get().await; + let _ = crate::registry::materialize_registry_post(&s); + let _ = s.update_post_last_check(&crate::registry::REGISTRY_POST_ID, 0); + } + let peers = self.list_connections().await; + for (peer, _slot, _ts) in peers.into_iter().take(3) { + let _ = self.network.conn_handle().fetch_engagement_from_peer(&peer).await; + } + let s = self.storage.get().await; + crate::registry::search_entries(&s, query) + } + + /// Auto-renew (round 8, DECIDED): while the "Listed" flag is set, + /// re-sign a fresh 30d entry when the current one expires within 5 + /// days (~every 25 days). Piggybacked on the eviction cycle. + pub async fn renew_registry_entries_if_due(&self) -> anyhow::Result { + const RENEW_WINDOW_MS: u64 = 5 * 24 * 3600 * 1000; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + + let due: Vec<(NodeId, String, Vec)> = { + let s = self.storage.get().await; + let mut due = Vec::new(); + for persona in s.list_posting_identities()? { + let id_hex = hex::encode(persona.node_id); + let listed = s + .get_setting(&format!("registry_listed.{}", id_hex))? + .map(|v| v == "1") + .unwrap_or(false); + if !listed { + continue; + } + let newest = s.get_newest_registry_entry( + &crate::registry::REGISTRY_POST_ID, + &persona.node_id, + )?; + let needs_renew = match newest { + Some((_ts, exp)) => exp <= now + RENEW_WINDOW_MS, + None => true, + }; + if !needs_renew { + continue; + } + let name = s + .get_setting(&format!("registry_name.{}", id_hex))? + .unwrap_or_else(|| persona.display_name.clone()); + let keywords: Vec = s + .get_setting(&format!("registry_keywords.{}", id_hex))? + .unwrap_or_default() + .split(',') + .filter(|k| !k.is_empty()) + .map(|k| k.to_string()) + .collect(); + due.push((persona.node_id, name, keywords)); + } + due + }; + + let mut renewed = 0usize; + for (persona_id, name, keywords) in due { + if self.register_persona(&persona_id, &name, &keywords).await.is_ok() { + renewed += 1; + } + } + Ok(renewed) + } + + /// One-shot genesis publish of the registry post (`--publish-registry`). + /// Refuses unless the default posting identity is the bootstrap + /// anchor's (mirrors `publish_announcement`). Debug builds may bypass + /// via `ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS=1` for multi-node tests. + pub async fn publish_registry_genesis(&self) -> anyhow::Result { + #[allow(unused_mut)] + let mut allowed = self.default_posting_id == crate::DEFAULT_ANCHOR_POSTING_ID; + #[cfg(debug_assertions)] + { + if std::env::var("ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS").as_deref() == Ok("1") { + allowed = true; + } + } + if !allowed { + anyhow::bail!( + "refusing to publish registry genesis: default posting identity is not the bootstrap anchor" + ); + } + { + let s = self.storage.get().await; + let _ = crate::registry::materialize_registry_post(&s)?; + } + self.update_neighbor_manifests_as( + &self.default_posting_id, + &self.default_posting_secret, + &crate::registry::REGISTRY_POST_ID, + crate::registry::REGISTRY_GENESIS_TIMESTAMP_MS, + ).await; + info!( + post_id = hex::encode(crate::registry::REGISTRY_POST_ID), + "Registry genesis published" + ); + Ok(crate::registry::REGISTRY_POST_ID) + } +} + pub struct NodeStats { pub post_count: usize, pub peer_count: usize, @@ -5860,7 +6921,7 @@ pub struct NodeStats { /// score = pin_boost + (relationship × heart_recency × freshness / (peer_copies + 1)) pub fn compute_blob_priority_standalone( candidate: &crate::storage::EvictionCandidate, - our_node_id: &NodeId, + own_author_ids: &[NodeId], follows: &[NodeId], now_ms: u64, ) -> f64 { @@ -5877,7 +6938,8 @@ pub fn compute_blob_priority_standalone( }; // v0.6.2: audience removed. Relationship is author-of-ours vs followed vs other. - let relationship = if candidate.author == *our_node_id { + // Authors are posting identities — check against ALL of our personas. + let relationship = if own_author_ids.contains(&candidate.author) { 5.0 } else if follows.contains(&candidate.author) { 2.0 @@ -5947,13 +7009,19 @@ impl Node { // Single lock: get under-replicated posts AND peer roles/pressure let (under_replicated, suitable_peers) = { let storage = self.storage.get().await; - let recent_ids = match storage.get_own_recent_post_ids(&self.node_id, since_ms) { - Ok(ids) => ids, - Err(e) => { - debug!(error = %e, "Replication: failed to get own recent posts"); - return; + // Own posts are authored by posting identities (personas), never + // the network NodeId — union recent posts across all personas. + let personas = storage.list_posting_identities().unwrap_or_default(); + let mut recent_ids: Vec = Vec::new(); + for persona in &personas { + match storage.get_own_recent_post_ids(&persona.node_id, since_ms) { + Ok(ids) => recent_ids.extend(ids), + Err(e) => { + debug!(error = %e, "Replication: failed to get own recent posts"); + return; + } } - }; + } // Filter to under-replicated (< 2 holders) let mut needs_replication = Vec::new(); @@ -6072,7 +7140,7 @@ mod tests { let candidate = make_candidate(our_id, true, now - 86400_000, now, 0); let score = compute_blob_priority_standalone( - &candidate, &our_id, &[], now, + &candidate, &[our_id], &[], now, ); assert!(score > 1000.0, "own pinned should score >1000, got {}", score); } @@ -6086,7 +7154,7 @@ mod tests { let follow_candidate = make_candidate(follow_id, false, now - 86400_000, now, 0); let follow_score = compute_blob_priority_standalone( - &follow_candidate, &our_id, &[follow_id], now, + &follow_candidate, &[our_id], &[follow_id], now, ); let stranger_candidate = make_candidate( @@ -6096,7 +7164,7 @@ mod tests { 5, ); let stranger_score = compute_blob_priority_standalone( - &stranger_candidate, &our_id, &[], now, + &stranger_candidate, &[our_id], &[], now, ); assert!(follow_score > stranger_score, @@ -6117,7 +7185,7 @@ mod tests { 10, ); let score = compute_blob_priority_standalone( - &candidate, &our_id, &[], now, + &candidate, &[our_id], &[], now, ); assert!(score < 0.01, "stranger stale should score near 0, got {}", score); @@ -6134,11 +7202,34 @@ mod tests { let follow = make_candidate(follow_id, false, now - 86400_000, now, 0); let stranger = make_candidate(stranger_id, false, now - 30 * 86400_000, now - 30 * 86400_000, 10); - let own_score = compute_blob_priority_standalone(&own, &our_id, &[follow_id], now); - let follow_score = compute_blob_priority_standalone(&follow, &our_id, &[follow_id], now); - let stranger_score = compute_blob_priority_standalone(&stranger, &our_id, &[follow_id], now); + let own_score = compute_blob_priority_standalone(&own, &[our_id], &[follow_id], now); + let follow_score = compute_blob_priority_standalone(&follow, &[our_id], &[follow_id], now); + let stranger_score = compute_blob_priority_standalone(&stranger, &[our_id], &[follow_id], now); assert!(own_score > follow_score, "own ({}) > follow ({})", own_score, follow_score); assert!(follow_score > stranger_score, "follow ({}) > stranger ({})", follow_score, stranger_score); } + + /// A1 (bug 4): blobs authored by ANY of our posting identities get the + /// own-content 5.0 tier — including non-default personas — and the + /// network NodeId never matches (posting authors only). + #[test] + fn second_persona_blob_scores_as_own() { + let persona1 = make_node_id(1); + let persona2 = make_node_id(2); + let network_id = make_node_id(9); + let now = 10_000_000_000u64; + + let by_second = make_candidate(persona2, false, now - 86400_000, now, 0); + let by_network = make_candidate(network_id, false, now - 86400_000, now, 0); + + let own_ids = [persona1, persona2]; + let second_score = compute_blob_priority_standalone(&by_second, &own_ids, &[], now); + let network_score = compute_blob_priority_standalone(&by_network, &own_ids, &[], now); + // Identical candidates → the only difference is the relationship + // tier: 5.0 (own persona) vs 0.1 (stranger). Ratio must reflect it. + assert!(second_score > network_score * 10.0, + "persona2 blob ({}) must be own-tier vs network-authored ({})", + second_score, network_score); + } } diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs index 7d584f8..d5f4e2f 100644 --- a/crates/core/src/profile.rs +++ b/crates/core/src/profile.rs @@ -175,6 +175,9 @@ pub fn scan_vouch_grants_for_all_personas( /// batch distributing the persona's current `V_me` to vouched personas. /// `bio_epoch` is a monotonic per-persona counter that lets receivers /// short-circuit re-scanning unchanged bios. +/// v0.8 (A3): `fof_gating` carries the bio's comment gating — including +/// the Greeting open slot when the persona's `greetings_open` consent is +/// on. `None` publishes a bio that accepts no greetings. pub fn build_profile_post( author: &NodeId, author_secret: &[u8; 32], @@ -183,6 +186,7 @@ pub fn build_profile_post( avatar_cid: Option<[u8; 32]>, vouch_grants: Option, bio_epoch: u32, + fof_gating: Option, ) -> Post { let timestamp_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -203,8 +207,8 @@ pub fn build_profile_post( content: serde_json::to_string(&content).unwrap_or_default(), attachments: vec![], timestamp_ms, - fof_gating: None, - supersedes_post_id: None, + fof_gating, + supersedes_post_id: None, } } @@ -345,7 +349,7 @@ mod tests { let s = temp_storage(); let (sec, pub_id) = make_keypair(11); - let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None, None, 0); + let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None, None, 0, None); apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap(); let stored = s.get_profile(&pub_id).unwrap().expect("profile stored"); @@ -360,7 +364,7 @@ mod tests { let (sec_b, _pub_b) = make_keypair(2); // Build a post claiming `pub_a` but signing with `sec_b`. - let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None, None, 0); + let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None, None, 0, None); let res = apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)); assert!(res.is_err()); assert!(s.get_profile(&pub_a).unwrap().is_none()); @@ -372,7 +376,7 @@ mod tests { let (sec, pub_id) = make_keypair(3); // Seed with a newer profile. - let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None, None, 0); + let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None, None, 0, None); // Hack the timestamp to make it clearly newer. let mut content: ProfilePostContent = serde_json::from_str(&newer.content).unwrap(); content.timestamp_ms = 10_000; @@ -382,7 +386,7 @@ mod tests { apply_profile_post_if_applicable(&s, &newer, Some(&VisibilityIntent::Profile)).unwrap(); // Apply an older profile — should be ignored. - let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None, None, 0); + let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None, None, 0, None); let mut content_o: ProfilePostContent = serde_json::from_str(&older.content).unwrap(); content_o.timestamp_ms = 5_000; content_o.signature = crypto::sign_profile(&sec, &content_o.display_name, &content_o.bio, &content_o.avatar_cid, content_o.timestamp_ms); diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index 0221c3c..05af5eb 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -1,11 +1,21 @@ use serde::{Deserialize, Serialize}; use crate::types::{ - BlobHeaderDiffOp, CdnManifest, DeleteRecord, GroupEpoch, GroupId, GroupMemberKey, NodeId, + BlobHeaderDiffOp, CdnManifest, DeleteRecord, GroupEpoch, GroupId, NodeId, PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, VisibilityUpdate, WormId, }; /// Single ALPN for Discovery Protocol v3 (N1/N2/N3 architecture) +/// +/// DEPLOY GATE (v0.8): the manifest wire format + signature digest changed +/// incompatibly in this tree (AuthorManifest.author_addresses removed from +/// struct AND digest; CdnManifest reduced to 2 fields; GroupKeyRequest +/// 0xA1/0xA2 removed). Mixed-version nodes half-interop: old-signed +/// manifests fail the new digest check, new CdnManifest JSON fails old +/// deserialization. Scott ruled ALPN -> b"itsgoin/4" for v0.8 (2026-07-29, +/// scheduled Iteration B). Bump it BEFORE any build/deploy of this change +/// set so mixed generations refuse cleanly at handshake — ask Scott first +/// (feedback_ask_before_release). pub const ALPN_V2: &[u8] = b"itsgoin/3"; /// A post bundled with its visibility metadata for sync @@ -52,8 +62,6 @@ pub enum MessageType { // 0x95 (BlobDeleteNotice) retired in v0.6.2 — remote holders evict via LRU. // 0xA0 (GroupKeyDistribute) retired in v0.6.2 — group seeds now travel // as encrypted posts via the CDN. See `group_key_distribution` module. - GroupKeyRequest = 0xA1, - GroupKeyResponse = 0xA2, RelayIntroduce = 0xB0, RelayIntroduceResult = 0xB1, SessionRelay = 0xB2, @@ -103,8 +111,6 @@ impl MessageType { 0x92 => Some(Self::ManifestRefreshRequest), 0x93 => Some(Self::ManifestRefreshResponse), 0x94 => Some(Self::ManifestPush), - 0xA1 => Some(Self::GroupKeyRequest), - 0xA2 => Some(Self::GroupKeyResponse), 0xB0 => Some(Self::RelayIntroduce), 0xB1 => Some(Self::RelayIntroduceResult), 0xB2 => Some(Self::SessionRelay), @@ -390,23 +396,6 @@ pub struct ManifestPushEntry { // encrypted posts (`VisibilityIntent::GroupKeyDistribute`). See // `crate::group_key_distribution` and `types::GroupKeyDistributionContent`. -/// Member requests current group key (bi-stream request) -#[derive(Debug, Serialize, Deserialize)] -pub struct GroupKeyRequestPayload { - pub group_id: GroupId, - pub known_epoch: GroupEpoch, -} - -/// Admin responds with wrapped key (bi-stream response) -#[derive(Debug, Serialize, Deserialize)] -pub struct GroupKeyResponsePayload { - pub group_id: GroupId, - pub epoch: GroupEpoch, - pub group_public_key: [u8; 32], - pub admin: NodeId, - pub member_key: Option, -} - // --- Relay introduction payloads --- /// Relay introduction identifier for deduplication @@ -745,8 +734,6 @@ mod tests { MessageType::ManifestRefreshRequest, MessageType::ManifestRefreshResponse, MessageType::ManifestPush, - MessageType::GroupKeyRequest, - MessageType::GroupKeyResponse, MessageType::RelayIntroduce, MessageType::RelayIntroduceResult, MessageType::SessionRelay, diff --git a/crates/core/src/registry.rs b/crates/core/src/registry.rs new file mode 100644 index 0000000..d5557f9 --- /dev/null +++ b/crates/core/src/registry.rs @@ -0,0 +1,474 @@ +//! v0.8 (A3): the network registry post — a well-known "registrations +//! here" post whose comment chain is the opt-in, searchable linkage of a +//! public persona name/keywords to a posting-identity author ID +//! (design.html §27 `id="discovery"`). +//! +//! - The post's canonical bytes are FROZEN in [`REGISTRY_POST_JSON`]; +//! its id is [`REGISTRY_POST_ID`] = BLAKE3(bytes). The constant is the +//! authority — never re-serialize the `Post` struct at runtime to +//! derive the id (serde field order = declaration order; any future +//! field would silently change the bytes). +//! - Every v0.8 node self-materializes the post at startup +//! ([`materialize_registry_post`]); only the comment-chain pull is +//! network work. +//! - Registration = the SAME open-slot comment implementation as +//! greetings (round-6 ruling), aimed at this post: plaintext JSON +//! entry, self-certified by the standard comment signature under the +//! persona's REAL posting key, fixed 30-day TTL, one active entry per +//! persona (newest wins). +//! - Sharding: `shard(seed, k)` derivation exists from day one via the +//! content string embedding the seed + k. k=1 forever in A3; adding +//! shard k+1 is a new content string = threshold change, not a +//! migration. + +use anyhow::{bail, Result}; +use serde::{Deserialize, Serialize}; + +use crate::storage::Storage; +use crate::types::{NodeId, Post, PostId}; + +/// Shard seed + index (design ruling: derivable shard identity). +pub const REGISTRY_SHARD_SEED: &str = "itsgoin-registry-v1"; +pub const REGISTRY_SHARD_K: u32 = 1; + +/// Fixed genesis timestamp baked into the canonical bytes +/// (2026-07-30T00:00:00Z). +pub const REGISTRY_GENESIS_TIMESTAMP_MS: u64 = 1_785_369_600_000; + +/// Single padded bucket for registration entries (plaintext JSON). +pub const REGISTRY_BODY_BUCKET: u16 = 512; + +/// Registrations live exactly 30 days; re-sign to stay listed. +pub const REGISTRATION_TTL_MS: u64 = 30 * 24 * 3600 * 1000; +/// Holder-side slack when checking the fixed TTL (clock skew). +pub const REGISTRATION_TTL_SLACK_MS: u64 = 5 * 60 * 1000; + +/// Entry shape limits (holder-enforced). +pub const MAX_REGISTRY_NAME_CHARS: usize = 64; +pub const MAX_REGISTRY_KEYWORDS: usize = 8; +pub const MAX_REGISTRY_KEYWORD_CHARS: usize = 32; + +/// Ordinary comment TTL window: rand(30..=365 days), drawn BEFORE +/// signing (the expiry is inside the signed digest). +pub const ORDINARY_TTL_MIN_MS: u64 = 30 * 24 * 3600 * 1000; +pub const ORDINARY_TTL_MAX_MS: u64 = 365 * 24 * 3600 * 1000; + +/// Draw the expiry for an ordinary comment: `now + rand(30..=365 days)`. +/// The randomness serves identity hygiene — throwaway commenter IDs +/// vanish at unpredictable times. +/// +/// Debug/test override: `ITSGOIN_TEST_TTL_SECS=` makes every ordinary +/// comment expire in n seconds (integration-test hook; debug builds only). +pub fn draw_ordinary_comment_expiry(now_ms: u64) -> u64 { + #[cfg(debug_assertions)] + { + if let Ok(secs) = std::env::var("ITSGOIN_TEST_TTL_SECS") { + if let Ok(secs) = secs.parse::() { + return now_ms + secs * 1000; + } + } + } + use rand::Rng; + let ttl = rand::rng().random_range(ORDINARY_TTL_MIN_MS..=ORDINARY_TTL_MAX_MS); + now_ms + ttl +} + +/// The frozen canonical serialized bytes of the registry post. Generated +/// once (see `build_canonical_registry_post` + the `frozen_bytes_*` +/// tests) and pasted here as a literal. DO NOT regenerate at runtime. +pub const REGISTRY_POST_JSON: &str = include_str!("registry_post_v1.json"); + +/// `BLAKE3(REGISTRY_POST_JSON)` — the registry post's id. Asserted +/// against the frozen bytes in CI (`frozen_bytes_triple_assertion`). +pub const REGISTRY_POST_ID: PostId = [ + 0x95, 0x28, 0x55, 0x78, 0x0d, 0xaa, 0x7c, 0xcc, + 0x1c, 0xe1, 0x2d, 0x18, 0x10, 0x3f, 0x77, 0x1d, + 0x6b, 0xe0, 0xd8, 0x81, 0x33, 0x18, 0x7b, 0xbf, + 0xec, 0x64, 0x60, 0xda, 0x24, 0x21, 0x90, 0xb4, +]; + +/// Deterministic builder for the canonical registry post. All seal +/// inputs are blake3-derived from the shard seed + k, so the output +/// bytes are reproducible bit-for-bit — this is what generated (and what +/// CI re-checks against) [`REGISTRY_POST_JSON`]. +pub fn build_canonical_registry_post() -> Result { + use crate::types::{FoFCommentGating, OpenSlotDecl, OpenSlotKind, WrapSlot}; + use ed25519_dalek::SigningKey; + + let shard_input = format!("{}/k={}", REGISTRY_SHARD_SEED, REGISTRY_SHARD_K); + let derive = |ctx: &str| -> [u8; 32] { blake3::derive_key(ctx, shard_input.as_bytes()) }; + + let slot_binder_nonce = derive("itsgoin/registry/v1/slot-binder"); + let cek = derive("itsgoin/registry/v1/cek"); + let priv_x_seed = derive("itsgoin/registry/v1/priv-x-seed"); + let pub_x = SigningKey::from_bytes(&priv_x_seed).verifying_key().to_bytes(); + + let v_open = crate::crypto::derive_open_slot_vx( + &crate::DEFAULT_ANCHOR_POSTING_ID, + &slot_binder_nonce, + ); + let sealed = crate::crypto::seal_wrap_slot(&v_open, &slot_binder_nonce, &cek, &priv_x_seed)?; + + let gating = FoFCommentGating { + slot_binder_nonce, + pub_post_set: vec![pub_x], + wrap_slots: vec![WrapSlot { + prefilter_tag: sealed.prefilter_tag, + read_ciphertext: sealed.read_ciphertext, + sign_ciphertext: sealed.sign_ciphertext, + }], + revocation_list: vec![], + open_slot: Some(OpenSlotDecl { + slot_index: 0, + kind: OpenSlotKind::Registry, + body_bucket: REGISTRY_BODY_BUCKET, + }), + }; + + Ok(Post { + author: crate::DEFAULT_ANCHOR_POSTING_ID, + content: format!( + "ItsGoin Network Registry — shard({}, k={}). Register here with a signed \ + public comment {{name, keywords}} authored by your persona's posting key. \ + Entries are self-certifying, expire after 30 days, and are newest-wins per \ + persona. Delete = signed DeleteComment.", + REGISTRY_SHARD_SEED, REGISTRY_SHARD_K, + ), + attachments: vec![], + timestamp_ms: REGISTRY_GENESIS_TIMESTAMP_MS, + fof_gating: Some(gating), + supersedes_post_id: None, + }) +} + +/// Parse the frozen constant into a `Post`. `verify_post_id` passes by +/// construction everywhere. +pub fn registry_post() -> Post { + serde_json::from_str(REGISTRY_POST_JSON) + .expect("frozen REGISTRY_POST_JSON must deserialize — build is broken otherwise") +} + +/// Self-materialize the registry post (store-if-absent, Public). Every +/// v0.8 node can therefore hold/serve the chain; no fetch needed. +/// Returns `true` if the post was newly stored. +pub fn materialize_registry_post(storage: &Storage) -> Result { + let post = registry_post(); + debug_assert!(crate::content::verify_post_id(®ISTRY_POST_ID, &post)); + storage.store_post_with_intent( + ®ISTRY_POST_ID, + &post, + &crate::types::PostVisibility::Public, + &crate::types::VisibilityIntent::Public, + ) +} + +/// A registration entry — plaintext JSON in the comment `content`. +/// Public by intent: no encryption, no anonymity. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RegistrationEntry { + pub v: u32, + pub name: String, + #[serde(default)] + pub keywords: Vec, +} + +/// Parse + shape-validate a registration entry from comment content. +pub fn parse_registration(content: &str) -> Result { + if content.len() > REGISTRY_BODY_BUCKET as usize { + bail!("registration entry exceeds body bucket ({} bytes)", content.len()); + } + let entry: RegistrationEntry = serde_json::from_str(content) + .map_err(|e| anyhow::anyhow!("registration entry is not valid JSON: {}", e))?; + if entry.v != 1 { + bail!("unsupported registration version {}", entry.v); + } + if entry.name.is_empty() || entry.name.chars().count() > MAX_REGISTRY_NAME_CHARS { + bail!("registration name empty or over {} chars", MAX_REGISTRY_NAME_CHARS); + } + if entry.keywords.len() > MAX_REGISTRY_KEYWORDS { + bail!("more than {} keywords", MAX_REGISTRY_KEYWORDS); + } + for kw in &entry.keywords { + if kw.is_empty() || kw.chars().count() > MAX_REGISTRY_KEYWORD_CHARS { + bail!("keyword empty or over {} chars", MAX_REGISTRY_KEYWORD_CHARS); + } + } + Ok(entry) +} + +/// Fixed-30d TTL check for registrations: `expires_at_ms − timestamp_ms` +/// must equal 30 days within ±5 minutes of slack. +pub fn registration_ttl_ok(timestamp_ms: u64, expires_at_ms: u64) -> bool { + let Some(ttl) = expires_at_ms.checked_sub(timestamp_ms) else { return false; }; + ttl.abs_diff(REGISTRATION_TTL_MS) <= REGISTRATION_TTL_SLACK_MS +} + +/// One search hit from the local registry chain. +#[derive(Debug, Clone)] +pub struct RegistryMatch { + pub author: NodeId, + pub name: String, + pub keywords: Vec, + pub timestamp_ms: u64, + pub expires_at_ms: u64, +} + +/// Case-insensitive substring search over name + keywords of the +/// locally-held, unexpired, newest-wins registry entries. Search cost +/// lands on the searcher (design §27) — LOCAL query only; chain refresh +/// is the caller's job (`Node::search_registry`). +/// +/// An empty query returns every live entry (browse mode). +pub fn search_entries(storage: &Storage, query: &str) -> Result> { + let comments = storage.get_comments(®ISTRY_POST_ID)?; + let needle = query.trim().to_lowercase(); + + // Newest-wins per author (storage enforces at ingest; dedupe + // defensively for locally-authored duplicates). + let mut newest: std::collections::HashMap = + std::collections::HashMap::new(); + for c in &comments { + let Ok(entry) = parse_registration(&c.content) else { continue; }; + let m = RegistryMatch { + author: c.author, + name: entry.name, + keywords: entry.keywords, + timestamp_ms: c.timestamp_ms, + expires_at_ms: c.expires_at_ms, + }; + match newest.get(&c.author) { + Some(existing) if existing.timestamp_ms >= m.timestamp_ms => {} + _ => { + newest.insert(c.author, m); + } + } + } + + let mut hits: Vec = newest + .into_values() + .filter(|m| { + if needle.is_empty() { + return true; + } + m.name.to_lowercase().contains(&needle) + || m.keywords.iter().any(|k| k.to_lowercase().contains(&needle)) + }) + .collect(); + hits.sort_by(|a, b| b.timestamp_ms.cmp(&a.timestamp_ms)); + Ok(hits) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::InlineComment; + + fn temp_storage() -> Storage { + Storage::open(":memory:").unwrap() + } + + /// Generator (dev-time): rebuild the canonical bytes and print them. + /// Run with `cargo test -p itsgoin-core registry_generator -- --ignored --nocapture` + /// when (and only when) the canonical definition intentionally changes, + /// then paste the output into `registry_post_v1.json` + REGISTRY_POST_ID. + #[test] + #[ignore] + fn registry_generator() { + let post = build_canonical_registry_post().unwrap(); + let json = serde_json::to_string(&post).unwrap(); + let id = blake3::hash(json.as_bytes()); + println!("JSON:\n{}", json); + println!("ID: {:?}", id.as_bytes()); + println!("ID hex: {}", hex::encode(id.as_bytes())); + } + + /// CI: the frozen literal, its BLAKE3, the shipped constant, and the + /// deterministic builder must all agree — forever. + #[test] + fn frozen_bytes_triple_assertion() { + // 1. blake3(frozen bytes) == REGISTRY_POST_ID. + let hash = blake3::hash(REGISTRY_POST_JSON.as_bytes()); + assert_eq!( + hash.as_bytes(), + ®ISTRY_POST_ID, + "REGISTRY_POST_ID must equal blake3 of the frozen bytes", + ); + + // 2. compute_post_id(parse(frozen bytes)) == REGISTRY_POST_ID — + // i.e., re-serialization is currently byte-identical, so + // verify_post_id passes everywhere. + let post = registry_post(); + assert_eq!( + crate::content::compute_post_id(&post), + REGISTRY_POST_ID, + "round-trip serialization must reproduce the frozen bytes", + ); + + // 3. The deterministic builder still reproduces the constant + // (all seal inputs derived — no drift in crypto derivations). + let rebuilt = build_canonical_registry_post().unwrap(); + assert_eq!( + serde_json::to_string(&rebuilt).unwrap(), + REGISTRY_POST_JSON, + "deterministic builder must reproduce the frozen bytes", + ); + + // Sanity: canonical fields. + assert_eq!(post.author, crate::DEFAULT_ANCHOR_POSTING_ID); + assert_eq!(post.timestamp_ms, REGISTRY_GENESIS_TIMESTAMP_MS); + let gating = post.fof_gating.as_ref().expect("gating"); + let decl = gating.open_slot.as_ref().expect("open slot"); + assert_eq!(decl.slot_index, 0); + assert_eq!(decl.kind, crate::types::OpenSlotKind::Registry); + assert_eq!(decl.body_bucket, REGISTRY_BODY_BUCKET); + assert_eq!(gating.wrap_slots.len(), 1); + } + + /// The registry open slot must actually open under the derivable key + /// and yield the signing seed matching pub_post_set[0]. + #[test] + fn registry_open_slot_derivable() { + use ed25519_dalek::SigningKey; + let post = registry_post(); + let unlock = crate::fof::derive_open_slot_unlock(&post, &[0u8; 32]) + .expect("registry open slot must open under the derived key"); + assert_eq!(unlock.slot_index, 0); + let derived_pub = SigningKey::from_bytes(&unlock.priv_x_seed).verifying_key().to_bytes(); + assert_eq!(post.fof_gating.unwrap().pub_post_set[0], derived_pub); + } + + #[test] + fn materialize_is_idempotent() { + let s = temp_storage(); + assert!(materialize_registry_post(&s).unwrap(), "first store inserts"); + assert!(!materialize_registry_post(&s).unwrap(), "second store no-ops"); + let (post, vis) = s.get_post_with_visibility(®ISTRY_POST_ID).unwrap().unwrap(); + assert_eq!(post.author, crate::DEFAULT_ANCHOR_POSTING_ID); + assert!(matches!(vis, crate::types::PostVisibility::Public)); + } + + #[test] + fn registration_shape_limits() { + // Good. + parse_registration(r#"{"v":1,"name":"Alice","keywords":["rust","p2p"]}"#).unwrap(); + // No keywords is fine. + parse_registration(r#"{"v":1,"name":"Bob"}"#).unwrap(); + // Wrong version. + assert!(parse_registration(r#"{"v":2,"name":"X"}"#).is_err()); + // Empty name. + assert!(parse_registration(r#"{"v":1,"name":""}"#).is_err()); + // Name too long. + let long_name = "x".repeat(65); + assert!(parse_registration(&format!(r#"{{"v":1,"name":"{}"}}"#, long_name)).is_err()); + // Too many keywords. + let kws: Vec = (0..9).map(|i| format!("k{}", i)).collect(); + let json = serde_json::json!({"v":1,"name":"A","keywords":kws}).to_string(); + assert!(parse_registration(&json).is_err()); + // Keyword too long. + let json = serde_json::json!({"v":1,"name":"A","keywords":["x".repeat(33)]}).to_string(); + assert!(parse_registration(&json).is_err()); + // Not JSON. + assert!(parse_registration("hello").is_err()); + } + + #[test] + fn registration_ttl_fixed_30d() { + let ts = 1_000_000u64; + // Exact 30d. + assert!(registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS)); + // Inside slack. + assert!(registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS + REGISTRATION_TTL_SLACK_MS)); + assert!(registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS - REGISTRATION_TTL_SLACK_MS)); + // Outside slack. + assert!(!registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS + REGISTRATION_TTL_SLACK_MS + 1)); + assert!(!registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS - REGISTRATION_TTL_SLACK_MS - 1)); + // Degenerate. + assert!(!registration_ttl_ok(ts, ts)); + assert!(!registration_ttl_ok(ts, 0)); + } + + fn reg_comment(author: NodeId, ts: u64, name: &str, kws: &[&str]) -> InlineComment { + InlineComment { + author, + post_id: REGISTRY_POST_ID, + content: serde_json::json!({"v":1,"name":name,"keywords":kws}).to_string(), + timestamp_ms: ts, + signature: vec![0u8; 64], + deleted_at: None, + ref_post_id: None, + pub_x_index: Some(0), + group_sig: None, + encrypted_payload: None, + expires_at_ms: ts + REGISTRATION_TTL_MS, + } + } + + fn test_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 + } + + #[test] + fn newest_wins_upsert() { + let s = temp_storage(); + let author = [7u8; 32]; + let t0 = test_now(); + + // First entry at t0. + assert!(s.upsert_registry_entry_newest_wins(®ISTRY_POST_ID, &author, t0).unwrap()); + s.store_comment(®_comment(author, t0, "Old", &["a"])).unwrap(); + + // Newer entry at t0+1000: accepted, older row hard-deleted. + assert!(s.upsert_registry_entry_newest_wins(®ISTRY_POST_ID, &author, t0 + 1000).unwrap()); + s.store_comment(®_comment(author, t0 + 1000, "New", &["b"])).unwrap(); + let comments = s.get_comments(®ISTRY_POST_ID).unwrap(); + assert_eq!(comments.len(), 1, "older row hard-deleted"); + assert_eq!(comments[0].timestamp_ms, t0 + 1000); + + // Older-than-stored entry: rejected, nothing changes. + assert!(!s.upsert_registry_entry_newest_wins(®ISTRY_POST_ID, &author, t0 + 500).unwrap()); + assert_eq!(s.get_comments(®ISTRY_POST_ID).unwrap().len(), 1); + + // Same-timestamp re-ingest: idempotent accept. + assert!(s.upsert_registry_entry_newest_wins(®ISTRY_POST_ID, &author, t0 + 1000).unwrap()); + } + + #[test] + fn search_matches_name_and_keywords() { + let s = temp_storage(); + let alice = [1u8; 32]; + let bob = [2u8; 32]; + let t0 = test_now(); + s.store_comment(®_comment(alice, t0, "Alice", &["rust", "p2p"])).unwrap(); + s.store_comment(®_comment(bob, t0 + 1, "Bob", &["gardening"])).unwrap(); + + // Keyword hit (case-insensitive). + let hits = search_entries(&s, "RUST").unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].author, alice); + assert_eq!(hits[0].name, "Alice"); + + // Name substring hit. + let hits = search_entries(&s, "bo").unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].author, bob); + + // Empty query = browse all. + assert_eq!(search_entries(&s, "").unwrap().len(), 2); + + // No hit. + assert!(search_entries(&s, "zzz").unwrap().is_empty()); + } + + #[test] + fn search_skips_expired_entries() { + let s = temp_storage(); + let alice = [1u8; 32]; + let mut c = reg_comment(alice, 1000, "Alice", &["rust"]); + c.expires_at_ms = 2000; // long past + s.store_comment(&c).unwrap(); + assert!(search_entries(&s, "rust").unwrap().is_empty(), "expired entries filtered"); + } +} diff --git a/crates/core/src/registry_post_v1.json b/crates/core/src/registry_post_v1.json new file mode 100644 index 0000000..f0b1e96 --- /dev/null +++ b/crates/core/src/registry_post_v1.json @@ -0,0 +1 @@ +{"author":[23,175,20,25,86,174,11,80,220,28,185,36,140,173,245,252,163,113,234,45,133,49,172,154,221,60,3,202,255,198,20,65],"content":"ItsGoin Network Registry — shard(itsgoin-registry-v1, k=1). Register here with a signed public comment {name, keywords} authored by your persona's posting key. Entries are self-certifying, expire after 30 days, and are newest-wins per persona. Delete = signed DeleteComment.","attachments":[],"timestamp_ms":1785369600000,"fof_gating":{"slot_binder_nonce":[194,138,144,176,11,137,185,140,117,108,61,163,60,83,166,44,35,228,74,53,76,175,67,116,175,175,49,54,140,206,82,147],"pub_post_set":[[247,219,138,10,17,157,246,133,230,232,228,97,55,195,255,125,108,212,226,71,102,202,26,31,78,233,76,47,236,66,71,144]],"wrap_slots":[{"prefilter_tag":[95,128],"read_ciphertext":[216,233,169,245,192,77,2,116,19,155,45,0,222,219,59,237,100,86,247,42,2,101,141,2,131,40,39,236,29,158,172,66,8,45,135,137,219,4,159,255,148,101,61,120,3,253,1,220],"sign_ciphertext":[42,203,235,211,8,208,10,246,77,37,158,207,8,214,152,49,15,110,220,5,190,177,105,164,146,117,190,211,12,13,32,36,109,105,156,33,42,133,118,213,248,33,74,182,178,235,176,204]}],"revocation_list":[],"open_slot":{"slot_index":0,"kind":"Registry","body_bucket":512}}} \ No newline at end of file diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index 9cd95cb..f01229c 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -366,9 +366,28 @@ impl Storage { group_sig BLOB, encrypted_payload BLOB, deleted_at INTEGER, + -- v0.8 (A3): absolute expiry (ms). NULL/0 = legacy no-TTL. + expires_at INTEGER, PRIMARY KEY (author, post_id, timestamp_ms) ); CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id); + -- v0.8 (A3): local-only greeting inbox dismissals. + CREATE TABLE IF NOT EXISTS greeting_dismissals ( + comment_author BLOB NOT NULL, + post_id BLOB NOT NULL, + timestamp_ms INTEGER NOT NULL, + dismissed_at INTEGER NOT NULL, + PRIMARY KEY (comment_author, post_id, timestamp_ms) + ); + -- v0.8 (A3): private halves of per-greeting reply x25519 keys, + -- keyed by the reply pubkey we told the recipient to seal to. + -- return_post_id = our return-path post (own bio) at mint time. + CREATE TABLE IF NOT EXISTS greeting_reply_keys ( + reply_pubkey BLOB PRIMARY KEY, + reply_privkey BLOB NOT NULL, + return_post_id BLOB NOT NULL, + created_at INTEGER NOT NULL + ); CREATE TABLE IF NOT EXISTS comment_policies ( post_id BLOB PRIMARY KEY, policy_json TEXT NOT NULL @@ -800,6 +819,17 @@ impl Storage { )?; } + // v0.8 (A3): add expires_at for comment TTL (rand 30–365d ordinary, + // fixed 30d registry entries). NULL/0 = legacy comment without TTL. + let has_expires_at = self.conn.prepare( + "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='expires_at'" + )?.query_row([], |row| row.get::<_, i64>(0))?; + if has_expires_at == 0 { + self.conn.execute_batch( + "ALTER TABLE comments ADD COLUMN expires_at INTEGER DEFAULT NULL;" + )?; + } + // v0.6.2: add canonical_root_post_id to group_keys. When set, the // record is a group (many-way, anchored at a public root post); // when NULL it's a traditional circle (one-way, admin-only). @@ -1249,20 +1279,29 @@ impl Storage { } /// Batch: reaction counts for multiple posts at once - pub fn get_reaction_counts_batch(&self, post_ids: &[PostId], our_node_id: &NodeId) -> anyhow::Result>> { + /// `our_ids` = ALL of the local node's posting identities (reactors are + /// posting ids — the network NodeId never reacts). + pub fn get_reaction_counts_batch(&self, post_ids: &[PostId], our_ids: &[NodeId]) -> anyhow::Result>> { use std::collections::HashMap; let mut result: HashMap> = HashMap::new(); if post_ids.is_empty() { return Ok(result); } let placeholders: String = (0..post_ids.len()).map(|i| format!("?{}", i + 1)).collect::>().join(","); + let mine_placeholders: String = if our_ids.is_empty() { + "NULL".to_string() + } else { + (0..our_ids.len()).map(|i| format!("?{}", post_ids.len() + 1 + i)).collect::>().join(",") + }; let sql = format!( - "SELECT post_id, emoji, COUNT(*) as cnt, SUM(CASE WHEN reactor = ?{} THEN 1 ELSE 0 END) as my_count + "SELECT post_id, emoji, COUNT(*) as cnt, SUM(CASE WHEN reactor IN ({}) THEN 1 ELSE 0 END) as my_count FROM reactions WHERE post_id IN ({}) AND deleted_at IS NULL GROUP BY post_id, emoji ORDER BY cnt DESC", - post_ids.len() + 1, placeholders + mine_placeholders, placeholders ); let mut stmt = self.conn.prepare(&sql)?; let mut params: Vec> = post_ids.iter().map(|id| Box::new(id.to_vec()) as Box).collect(); - params.push(Box::new(our_node_id.to_vec())); + for oid in our_ids { + params.push(Box::new(oid.to_vec())); + } let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let rows = stmt.query_map(param_refs.as_slice(), |row| { let pid: Vec = row.get(0)?; @@ -2557,15 +2596,18 @@ impl Storage { } } - /// Resolve display info for a peer: check circle profiles the viewer belongs to, - /// then fall back to public profile. + /// Resolve display info for a peer: check circle profiles any of the + /// viewer's posting identities belongs to, then fall back to public + /// profile. `viewers` = ALL of the local node's posting identities + /// (circle_members holds posting ids, never the network NodeId). /// Returns (display_name, bio, avatar_cid). pub fn resolve_display_for_peer( &self, author: &NodeId, - viewer: &NodeId, + viewers: &[NodeId], ) -> anyhow::Result<(String, String, Option<[u8; 32]>)> { - // Find circles where viewer is a member and author has a circle profile + // Find circles where a viewer persona is a member and author has a + // circle profile; take the freshest match across all personas. let mut stmt = self.conn.prepare( "SELECT cp.display_name, cp.bio, cp.avatar_cid, cp.updated_at FROM circle_profiles cp @@ -2574,17 +2616,26 @@ impl Storage { ORDER BY cp.updated_at DESC LIMIT 1", )?; - let mut rows = stmt.query(params![author.as_slice(), viewer.as_slice()])?; - if let Some(row) = rows.next()? { - let dn: String = row.get(0)?; - // Only use circle profile if it has actual content - if !dn.is_empty() { - let bio: String = row.get(1)?; - let avatar_cid = row.get::<_, Option>>(2).unwrap_or(None) - .and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok()); - return Ok((dn, bio, avatar_cid)); + let mut best: Option<(String, String, Option<[u8; 32]>, u64)> = None; + for viewer in viewers { + let mut rows = stmt.query(params![author.as_slice(), viewer.as_slice()])?; + if let Some(row) = rows.next()? { + let dn: String = row.get(0)?; + // Only use circle profile if it has actual content + if !dn.is_empty() { + let bio: String = row.get(1)?; + let avatar_cid = row.get::<_, Option>>(2).unwrap_or(None) + .and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok()); + let updated_at = row.get::<_, i64>(3).unwrap_or(0) as u64; + if best.as_ref().map(|b| updated_at > b.3).unwrap_or(true) { + best = Some((dn, bio, avatar_cid, updated_at)); + } + } } } + if let Some((dn, bio, avatar_cid, _)) = best { + return Ok((dn, bio, avatar_cid)); + } // Fall back to public profile if let Some(profile) = self.get_profile(author)? { @@ -4508,6 +4559,36 @@ impl Storage { Ok(result) } + /// List ALL stored CDN manifests: (cid, manifest_json). Used by the v0.8 + /// startup migration to re-sign own manifests / purge stale foreign ones. + pub fn list_all_cdn_manifests(&self) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT cid, manifest_json FROM cdn_manifests" + )?; + let rows = stmt.query_map([], |row| { + let cid_bytes: Vec = row.get(0)?; + let json: String = row.get(1)?; + Ok((cid_bytes, json)) + })?; + let mut result = Vec::new(); + for row in rows { + let (cid_bytes, json) = row?; + let cid: [u8; 32] = cid_bytes.try_into() + .map_err(|_| anyhow::anyhow!("invalid cid in cdn_manifests"))?; + result.push((cid, json)); + } + Ok(result) + } + + /// Delete a single CDN manifest row by CID (file_holders untouched). + pub fn delete_cdn_manifest(&self, cid: &[u8; 32]) -> anyhow::Result<()> { + self.conn.execute( + "DELETE FROM cdn_manifests WHERE cid = ?1", + params![cid.as_slice()], + )?; + Ok(()) + } + /// Get CIDs of manifests older than a cutoff. Callers look up holders /// via file_holders to pick a refresh source. pub fn get_stale_manifest_cids(&self, older_than_ms: u64) -> anyhow::Result> { @@ -5908,13 +5989,27 @@ impl Storage { } /// Get reaction counts grouped by emoji for a post (excludes tombstoned reactions). - pub fn get_reaction_counts(&self, post_id: &PostId, my_node_id: &NodeId) -> anyhow::Result> { - let mut stmt = self.conn.prepare( + /// `my_ids` = ALL of the local node's posting identities (reactors are + /// posting ids — the network NodeId never reacts). + pub fn get_reaction_counts(&self, post_id: &PostId, my_ids: &[NodeId]) -> anyhow::Result> { + let mine_placeholders: String = if my_ids.is_empty() { + "NULL".to_string() + } else { + (0..my_ids.len()).map(|i| format!("?{}", i + 2)).collect::>().join(",") + }; + let sql = format!( "SELECT emoji, COUNT(*) as cnt, - SUM(CASE WHEN reactor = ?2 THEN 1 ELSE 0 END) as my_count - FROM reactions WHERE post_id = ?1 AND deleted_at IS NULL GROUP BY emoji ORDER BY cnt DESC" - )?; - let rows = stmt.query_map(params![post_id.as_slice(), my_node_id.as_slice()], |row| { + SUM(CASE WHEN reactor IN ({}) THEN 1 ELSE 0 END) as my_count + FROM reactions WHERE post_id = ?1 AND deleted_at IS NULL GROUP BY emoji ORDER BY cnt DESC", + mine_placeholders + ); + let mut stmt = self.conn.prepare(&sql)?; + let mut params: Vec> = vec![Box::new(post_id.to_vec())]; + for id in my_ids { + params.push(Box::new(id.to_vec())); + } + let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let rows = stmt.query_map(param_refs.as_slice(), |row| { let emoji: String = row.get(0)?; let count: i64 = row.get(1)?; let my_count: i64 = row.get(2)?; @@ -5935,15 +6030,18 @@ impl Storage { self.conn.execute( "INSERT INTO comments (author, post_id, content, timestamp_ms, signature, deleted_at, - ref_post_id, pub_x_index, group_sig, encrypted_payload) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ref_post_id, pub_x_index, group_sig, encrypted_payload, expires_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) ON CONFLICT(author, post_id, timestamp_ms) DO UPDATE SET content = CASE WHEN excluded.deleted_at IS NOT NULL THEN content ELSE excluded.content END, + signature = CASE WHEN excluded.deleted_at IS NOT NULL THEN signature ELSE excluded.signature END, deleted_at = CASE WHEN excluded.deleted_at IS NOT NULL THEN excluded.deleted_at ELSE deleted_at END, ref_post_id = COALESCE(excluded.ref_post_id, ref_post_id), pub_x_index = COALESCE(excluded.pub_x_index, pub_x_index), group_sig = COALESCE(excluded.group_sig, group_sig), - encrypted_payload = COALESCE(excluded.encrypted_payload, encrypted_payload)", + encrypted_payload = COALESCE(excluded.encrypted_payload, encrypted_payload), + expires_at = CASE WHEN excluded.expires_at IS NOT NULL AND excluded.expires_at > 0 + THEN excluded.expires_at ELSE expires_at END", params![ comment.author.as_slice(), comment.post_id.as_slice(), @@ -5955,6 +6053,7 @@ impl Storage { comment.pub_x_index.map(|i| i as i64), comment.group_sig.as_ref().map(|b| b.as_slice()), comment.encrypted_payload.as_ref().map(|b| b.as_slice()), + if comment.expires_at_ms > 0 { Some(comment.expires_at_ms as i64) } else { None }, ], )?; Ok(()) @@ -5978,14 +6077,18 @@ impl Storage { Ok(updated > 0) } - /// Get live (non-tombstoned) comments for a post. Used for UI display. + /// Get live (non-tombstoned, unexpired) comments for a post. Used for + /// UI display. The expiry filter is belt-and-suspenders between + /// `expire_comments` sweeps. pub fn get_comments(&self, post_id: &PostId) -> anyhow::Result> { let mut stmt = self.conn.prepare( "SELECT author, post_id, content, timestamp_ms, signature, ref_post_id, - pub_x_index, group_sig, encrypted_payload - FROM comments WHERE post_id = ?1 AND deleted_at IS NULL ORDER BY timestamp_ms ASC" + pub_x_index, group_sig, encrypted_payload, expires_at + FROM comments WHERE post_id = ?1 AND deleted_at IS NULL + AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?2) + ORDER BY timestamp_ms ASC" )?; - let rows = stmt.query_map(params![post_id.as_slice()], |row| { + let rows = stmt.query_map(params![post_id.as_slice(), now_ms()], |row| { let author: Vec = row.get(0)?; let pid: Vec = row.get(1)?; let content: String = row.get(2)?; @@ -5995,11 +6098,12 @@ impl Storage { let pxi: Option = row.get(6)?; let gsig: Option> = row.get(7)?; let epl: Option> = row.get(8)?; - Ok((author, pid, content, ts, sig, ref_post, pxi, gsig, epl)) + let exp: Option = row.get(9)?; + Ok((author, pid, content, ts, sig, ref_post, pxi, gsig, epl, exp)) })?; let mut result = Vec::new(); for row in rows { - let (author_bytes, pid_bytes, content, ts, sig, ref_post, pxi, gsig, epl) = row?; + let (author_bytes, pid_bytes, content, ts, sig, ref_post, pxi, gsig, epl, exp) = row?; let author = blob_to_nodeid(author_bytes)?; let post_id = blob_to_postid(pid_bytes)?; let ref_post_id = match ref_post { @@ -6017,19 +6121,30 @@ impl Storage { pub_x_index: pxi.map(|v| v as u32), group_sig: gsig, encrypted_payload: epl, + expires_at_ms: exp.unwrap_or(0) as u64, }); } Ok(result) } - /// Get ALL comments for a post, including tombstoned ones. Used for header rebuild - /// so tombstones propagate through pull-based sync. + /// Get ALL comments for a post, including tombstoned ones (but never + /// expired ones — expiry is the forgetting mechanism and expired + /// comments must not be re-served). Used for header rebuild so + /// tombstones propagate through pull-based sync. + /// + /// v0.8 (A3): also carries the FoF fields (pub_x_index / group_sig / + /// encrypted_payload) + expires_at — without them, FoF/greeting + /// comments served via full-header sync would lose their group_sig + /// and be dropped by the receive gate. pub fn get_comments_with_tombstones(&self, post_id: &PostId) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id - FROM comments WHERE post_id = ?1 ORDER BY timestamp_ms ASC" + "SELECT author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id, + pub_x_index, group_sig, encrypted_payload, expires_at + FROM comments WHERE post_id = ?1 + AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?2) + ORDER BY timestamp_ms ASC" )?; - let rows = stmt.query_map(params![post_id.as_slice()], |row| { + let rows = stmt.query_map(params![post_id.as_slice(), now_ms()], |row| { let author: Vec = row.get(0)?; let pid: Vec = row.get(1)?; let content: String = row.get(2)?; @@ -6037,11 +6152,15 @@ impl Storage { let sig: Vec = row.get(4)?; let del: Option = row.get(5)?; let ref_post: Option> = row.get(6)?; - Ok((author, pid, content, ts, sig, del, ref_post)) + let pxi: Option = row.get(7)?; + let gsig: Option> = row.get(8)?; + let epl: Option> = row.get(9)?; + let exp: Option = row.get(10)?; + Ok((author, pid, content, ts, sig, del, ref_post, pxi, gsig, epl, exp)) })?; let mut result = Vec::new(); for row in rows { - let (author_bytes, pid_bytes, content, ts, sig, del, ref_post) = row?; + let (author_bytes, pid_bytes, content, ts, sig, del, ref_post, pxi, gsig, epl, exp) = row?; let author = blob_to_nodeid(author_bytes)?; let post_id = blob_to_postid(pid_bytes)?; let ref_post_id = match ref_post { @@ -6056,22 +6175,319 @@ impl Storage { signature: sig, deleted_at: del.map(|v| v as u64), ref_post_id, - pub_x_index: None, - group_sig: None, - encrypted_payload: None, + pub_x_index: pxi.map(|v| v as u32), + group_sig: gsig, + encrypted_payload: epl, + expires_at_ms: exp.unwrap_or(0) as u64, }); } Ok(result) } - /// Get comment count for a post (excludes tombstoned comments). + /// Get comment count for a post (excludes tombstoned + expired comments). pub fn get_comment_count(&self, post_id: &PostId) -> anyhow::Result { let count: i64 = self.conn.prepare( - "SELECT COUNT(*) FROM comments WHERE post_id = ?1 AND deleted_at IS NULL" - )?.query_row(params![post_id.as_slice()], |row| row.get(0))?; + "SELECT COUNT(*) FROM comments WHERE post_id = ?1 AND deleted_at IS NULL + AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?2)" + )?.query_row(params![post_id.as_slice(), now_ms()], |row| row.get(0))?; Ok(count as u64) } + /// v0.8 (A3): rebuild the post's aggregated blob header from vetted + /// DB state (reactions + comments + policy), preserving slot fields + /// from any previously-stored header JSON. Used after local comment + /// creation (so pulls serve our own engagement) and after pull-path + /// ingestion (so we never re-serve peer-supplied comments our accept + /// gate rejected). + pub fn rebuild_blob_header_from_db( + &self, + post_id: &PostId, + fallback_author: &NodeId, + now: u64, + ) -> anyhow::Result<()> { + let reactions = self.get_reactions_with_tombstones(post_id).unwrap_or_default(); + let comments = self.get_comments_with_tombstones(post_id).unwrap_or_default(); + let policy = self.get_comment_policy(post_id).ok().flatten().unwrap_or_default(); + let (existing_json, _) = self.get_blob_header(post_id).ok().flatten() + .unwrap_or((String::new(), 0)); + let mut header: crate::types::BlobHeader = serde_json::from_str(&existing_json) + .unwrap_or_else(|_| crate::types::BlobHeader { + post_id: *post_id, + author: *fallback_author, + reactions: vec![], + comments: vec![], + policy: CommentPolicy::default(), + updated_at: 0, + thread_splits: vec![], + receipt_slots: vec![], + comment_slots: vec![], + prior_author: None, + }); + header.post_id = *post_id; + header.reactions = reactions; + header.comments = comments; + header.policy = policy; + header.updated_at = now; + // Trust the locally-stored post for authorship when available. + let author = self.get_post(post_id).ok().flatten() + .map(|p| p.author) + .unwrap_or(*fallback_author); + header.author = author; + let json = serde_json::to_string(&header)?; + self.store_blob_header(post_id, &author, &json, now) + } + + // --- v0.8 (A3): comment TTL sweep + open-slot helpers --- + + /// Hard-DELETE (not tombstone) all comments whose expiry has passed. + /// Expiry is the forgetting mechanism — tombstones would keep + /// throwaway IDs alive. Returns the number of rows deleted. + pub fn expire_comments(&self, now_ms_val: u64) -> anyhow::Result { + let deleted = self.conn.execute( + "DELETE FROM comments + WHERE expires_at IS NOT NULL AND expires_at > 0 AND expires_at <= ?1", + params![now_ms_val as i64], + )?; + Ok(deleted) + } + + /// Newest-wins registry rule: one active entry per persona (`author`) + /// on the registry post. Returns `true` if the incoming row (at + /// `incoming_ts`) is the newest for this author — in which case any + /// strictly-older rows are HARD-deleted — and `false` when a newer + /// entry is already stored (caller drops the incoming one). + pub fn upsert_registry_entry_newest_wins( + &self, + post_id: &PostId, + author: &NodeId, + incoming_ts: u64, + ) -> anyhow::Result { + if !self.registry_entry_is_newest(post_id, author, incoming_ts)? { + return Ok(false); + } + self.prune_older_registry_entries(post_id, author, incoming_ts)?; + Ok(true) + } + + /// READ-ONLY newest-wins check: is `incoming_ts` at least as new as + /// every stored entry for this persona? Used by the accept gate, + /// which must stay side-effect free — the destructive prune happens + /// in the storing callers AFTER a successful store_comment. + pub fn registry_entry_is_newest( + &self, + post_id: &PostId, + author: &NodeId, + incoming_ts: u64, + ) -> anyhow::Result { + let newest: Option = self.conn.query_row( + "SELECT MAX(timestamp_ms) FROM comments WHERE post_id = ?1 AND author = ?2", + params![post_id.as_slice(), author.as_slice()], + |row| row.get(0), + )?; + if let Some(newest) = newest { + if (newest as u64) > incoming_ts { + return Ok(false); + } + } + Ok(true) + } + + /// Hard-delete this persona's registry entries older than `newest_ts`. + /// Call only after the newest entry has been successfully stored, so + /// a store failure never leaves the persona entry-less. + pub fn prune_older_registry_entries( + &self, + post_id: &PostId, + author: &NodeId, + newest_ts: u64, + ) -> anyhow::Result { + let n = self.conn.execute( + "DELETE FROM comments WHERE post_id = ?1 AND author = ?2 AND timestamp_ms < ?3", + params![post_id.as_slice(), author.as_slice(), newest_ts as i64], + )?; + Ok(n) + } + + /// Count live (unexpired, non-tombstoned) comments on a post's open + /// slot — the per-bio greeting flood cap input. + pub fn count_unexpired_open_slot_comments( + &self, + post_id: &PostId, + pub_x_index: u32, + ) -> anyhow::Result { + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM comments + WHERE post_id = ?1 AND pub_x_index = ?2 AND deleted_at IS NULL + AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?3)", + params![post_id.as_slice(), pub_x_index as i64, now_ms()], + |row| row.get(0), + )?; + Ok(count as u64) + } + + /// Local-only: mark a greeting as dismissed in the inbox. + pub fn add_greeting_dismissal( + &self, + comment_author: &NodeId, + post_id: &PostId, + timestamp_ms: u64, + ) -> anyhow::Result<()> { + self.conn.execute( + "INSERT OR REPLACE INTO greeting_dismissals + (comment_author, post_id, timestamp_ms, dismissed_at) + VALUES (?1, ?2, ?3, ?4)", + params![comment_author.as_slice(), post_id.as_slice(), timestamp_ms as i64, now_ms()], + )?; + Ok(()) + } + + /// Local-only: was this greeting dismissed? + pub fn is_greeting_dismissed( + &self, + comment_author: &NodeId, + post_id: &PostId, + timestamp_ms: u64, + ) -> anyhow::Result { + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM greeting_dismissals + WHERE comment_author = ?1 AND post_id = ?2 AND timestamp_ms = ?3", + params![comment_author.as_slice(), post_id.as_slice(), timestamp_ms as i64], + |row| row.get(0), + )?; + Ok(count > 0) + } + + /// Persist the private half of a per-greeting reply x25519 keypair. + pub fn store_greeting_reply_key( + &self, + reply_pubkey: &[u8; 32], + reply_privkey: &[u8; 32], + return_post_id: &PostId, + ) -> anyhow::Result<()> { + self.conn.execute( + "INSERT OR REPLACE INTO greeting_reply_keys + (reply_pubkey, reply_privkey, return_post_id, created_at) + VALUES (?1, ?2, ?3, ?4)", + params![ + reply_pubkey.as_slice(), + reply_privkey.as_slice(), + return_post_id.as_slice(), + now_ms(), + ], + )?; + Ok(()) + } + + /// List all stored reply private keys (privkey, return_post_id). + /// Used to unseal replies arriving on our return-path posts. + pub fn list_greeting_reply_keys(&self) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT reply_privkey, return_post_id FROM greeting_reply_keys ORDER BY created_at DESC" + )?; + let rows = stmt.query_map([], |row| { + let privkey: Vec = row.get(0)?; + let post: Vec = row.get(1)?; + Ok((privkey, post)) + })?; + let mut result = Vec::new(); + for row in rows { + let (privkey, post) = row?; + let pk: [u8; 32] = privkey.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid reply privkey length"))?; + result.push((pk, blob_to_postid(post)?)); + } + Ok(result) + } + + /// All FoF-gated posts authored by `author` (fof_gating_json set), + /// reconstructed with their gating. Used by the greeting inbox to + /// scan own bio posts (current + previous revisions) for open-slot + /// comments. + pub fn list_gated_posts_by_author( + &self, + author: &NodeId, + ) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT id, author, content, attachments, timestamp_ms, fof_gating_json + FROM posts WHERE author = ?1 AND fof_gating_json IS NOT NULL + ORDER BY timestamp_ms DESC", + )?; + let rows = stmt.query_map(params![author.as_slice()], |row| { + let id: Vec = row.get(0)?; + let author: Vec = row.get(1)?; + let content: String = row.get(2)?; + let attachments: String = row.get(3)?; + let ts: i64 = row.get(4)?; + let fof_json: Option = row.get(5)?; + Ok((id, author, content, attachments, ts, fof_json)) + })?; + let mut result = Vec::new(); + for row in rows { + let (id, author_bytes, content, attachments_json, ts, fof_json) = row?; + let attachments: Vec = + serde_json::from_str(&attachments_json).unwrap_or_default(); + let fof_gating = fof_json + .and_then(|s| serde_json::from_str::(&s).ok()); + result.push(( + blob_to_postid(id)?, + Post { + author: blob_to_nodeid(author_bytes)?, + content, + attachments, + timestamp_ms: ts as u64, + fof_gating, + supersedes_post_id: None, + }, + )); + } + Ok(result) + } + + /// Newest live registry entry for `author`: + /// `(timestamp_ms, expires_at_ms)`. Used by unregister + auto-renew. + pub fn get_newest_registry_entry( + &self, + registry_post_id: &PostId, + author: &NodeId, + ) -> anyhow::Result> { + let result = self.conn.query_row( + "SELECT timestamp_ms, expires_at FROM comments + WHERE post_id = ?1 AND author = ?2 AND deleted_at IS NULL + ORDER BY timestamp_ms DESC LIMIT 1", + params![registry_post_id.as_slice(), author.as_slice()], + |row| { + let ts: i64 = row.get(0)?; + let exp: Option = row.get(1)?; + Ok((ts as u64, exp.unwrap_or(0) as u64)) + }, + ); + match result { + Ok(pair) => Ok(Some(pair)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// Latest `VisibilityIntent::Profile` post authored by `author` — + /// the persona's current bio post (the v1 greeting return path). + pub fn get_latest_profile_post_id_by_author( + &self, + author: &NodeId, + ) -> anyhow::Result> { + let result = self.conn.query_row( + "SELECT id FROM posts + WHERE author = ?1 AND visibility_intent = '\"Profile\"' + ORDER BY timestamp_ms DESC LIMIT 1", + params![author.as_slice()], + |row| row.get::<_, Vec>(0), + ); + match result { + Ok(bytes) => Ok(Some(blob_to_postid(bytes)?)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + // --- Engagement: comment policies --- /// Store or update a comment policy for a post. @@ -7293,13 +7709,13 @@ mod tests { s.set_circle_profile(&cp).unwrap(); // Viewer in circle sees circle profile - let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &viewer).unwrap(); + let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &[viewer]).unwrap(); assert_eq!(dn, "Alice (CF)"); assert_eq!(bio, "Circle bio"); assert_eq!(avatar, Some([99u8; 32])); // Stranger sees public profile - let (dn2, bio2, avatar2) = s.resolve_display_for_peer(&author, &stranger).unwrap(); + let (dn2, bio2, avatar2) = s.resolve_display_for_peer(&author, &[stranger]).unwrap(); assert_eq!(dn2, "Alice Public"); assert_eq!(bio2, "Public bio"); assert!(avatar2.is_none()); @@ -7325,7 +7741,7 @@ mod tests { }).unwrap(); // Stranger sees nothing - let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &stranger).unwrap(); + let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &[stranger]).unwrap(); assert!(dn.is_empty()); assert!(bio.is_empty()); assert!(avatar.is_none()); @@ -7488,7 +7904,7 @@ mod tests { let reactions = s.get_reactions(&post_id).unwrap(); assert_eq!(reactions.len(), 3); - let counts = s.get_reaction_counts(&post_id, &me).unwrap(); + let counts = s.get_reaction_counts(&post_id, &[me]).unwrap(); assert_eq!(counts.len(), 2); // 👍 and ❤️ // 👍 has 2 reactions, one from me let thumbs = counts.iter().find(|(e, _, _)| e == "👍").unwrap(); @@ -7520,6 +7936,7 @@ mod tests { pub_x_index: None, group_sig: None, encrypted_payload: None, + expires_at_ms: 0, }).unwrap(); s.store_comment(&InlineComment { @@ -7533,6 +7950,7 @@ mod tests { pub_x_index: None, group_sig: None, encrypted_payload: None, + expires_at_ms: 0, }).unwrap(); let comments = s.get_comments(&post_id).unwrap(); @@ -7561,6 +7979,7 @@ mod tests { pub_x_index: None, group_sig: None, encrypted_payload: None, + expires_at_ms: 0, }).unwrap(); let live = s.get_comments(&post_id).unwrap(); @@ -7649,4 +8068,287 @@ mod tests { assert!(s.get_thread_parent(&parent).unwrap().is_none()); } + + // --- A1 ID-class fixes: multi-persona coverage --- + + fn make_post(author: NodeId, ts: u64) -> Post { + Post { + author, + content: format!("post at {ts}"), + attachments: vec![], + timestamp_ms: ts, + fof_gating: None, + supersedes_post_id: None, + } + } + + fn add_persona(s: &Storage, byte: u8) -> NodeId { + let id = make_node_id(byte); + s.upsert_posting_identity(&PostingIdentity { + node_id: id, + secret_seed: [byte; 32], + display_name: String::new(), + created_at: byte as u64, + }).unwrap(); + id + } + + /// Replication (bug A1-3): own posts are found by unioning over ALL + /// posting identities; a network-NodeId query finds nothing. + #[test] + fn own_recent_posts_found_across_personas_not_network_id() { + let s = temp_storage(); + let network_id = make_node_id(9); + let persona1 = add_persona(&s, 1); + let persona2 = add_persona(&s, 2); + + s.store_post(&make_post_id(11), &make_post(persona1, 1000)).unwrap(); + s.store_post(&make_post_id(12), &make_post(persona2, 2000)).unwrap(); + + // The old bug: querying by the network id → always empty. + assert!(s.get_own_recent_post_ids(&network_id, 0).unwrap().is_empty()); + + // The fix pattern: union across all posting identities. + let mut found = Vec::new(); + for pi in s.list_posting_identities().unwrap() { + found.extend(s.get_own_recent_post_ids(&pi.node_id, 0).unwrap()); + } + assert_eq!(found.len(), 2, "posts from BOTH personas must be found"); + } + + /// Reaction "mine" flag (caller-side bug): reactors are posting ids; + /// the flag must light up for a reaction from ANY persona and never for + /// the network id. + #[test] + fn reaction_mine_flag_multi_persona() { + let s = temp_storage(); + let network_id = make_node_id(9); + let persona1 = make_node_id(1); + let persona2 = make_node_id(2); + let post_id = make_post_id(21); + + s.store_reaction(&Reaction { + reactor: persona2, + emoji: "👍".to_string(), + post_id, + timestamp_ms: 1000, + encrypted_payload: None, + deleted_at: None, + signature: vec![], + }).unwrap(); + + // Old bug shape: network id as "me" → never mine. + let counts = s.get_reaction_counts(&post_id, &[network_id]).unwrap(); + assert!(!counts[0].2, "network id must never own a reaction"); + + // Fix: all personas → reaction by the SECOND persona is mine. + let counts = s.get_reaction_counts(&post_id, &[persona1, persona2]).unwrap(); + assert!(counts[0].2, "reaction by any persona must count as mine"); + + // Batch variant behaves identically. + let batch = s.get_reaction_counts_batch(&[post_id], &[persona1, persona2]).unwrap(); + assert!(batch.get(&post_id).unwrap()[0].2); + let batch = s.get_reaction_counts_batch(&[post_id], &[network_id]).unwrap(); + assert!(!batch.get(&post_id).unwrap()[0].2); + } + + /// Circle display resolution (bug A1-11): the viewer join is against + /// posting-class circle members — a second persona's membership must + /// resolve, the network id must not. + #[test] + fn resolve_display_for_second_persona_viewer() { + let s = temp_storage(); + let network_id = make_node_id(9); + let persona1 = make_node_id(1); + let persona2 = make_node_id(2); + let author = make_node_id(5); + + s.create_circle("friends").unwrap(); + // Only our SECOND persona is a member of the author's circle mirror. + s.add_circle_member("friends", &persona2).unwrap(); + s.set_circle_profile(&CircleProfile { + author, + circle_name: "friends".to_string(), + display_name: "Circle Name".to_string(), + bio: "circle bio".to_string(), + avatar_cid: None, + updated_at: 1000, + }).unwrap(); + + // Old bug shape: network id as viewer → no circle match. + let (dn, _, _) = s.resolve_display_for_peer(&author, &[network_id]).unwrap(); + assert_eq!(dn, "", "network-id viewer must not resolve circle profiles"); + + // Fix: all personas as viewers → second persona matches. + let (dn, bio, _) = s.resolve_display_for_peer(&author, &[persona1, persona2]).unwrap(); + assert_eq!(dn, "Circle Name"); + assert_eq!(bio, "circle bio"); + } + + /// Circle revocation (bug A1-9): posts stored under per-persona authors + /// are found by per-persona circle-intent queries, not by the network id. + #[test] + fn circle_intent_posts_found_per_persona() { + let s = temp_storage(); + let network_id = make_node_id(9); + let persona1 = add_persona(&s, 1); + let persona2 = add_persona(&s, 2); + + let vis = PostVisibility::Encrypted { recipients: vec![] }; + s.store_post_with_intent( + &make_post_id(31), + &make_post(persona1, 1000), + &vis, + &VisibilityIntent::Circle("crew".to_string()), + ).unwrap(); + s.store_post_with_intent( + &make_post_id(32), + &make_post(persona2, 2000), + &vis, + &VisibilityIntent::Circle("crew".to_string()), + ).unwrap(); + + // Old bug shape: query by network id → nothing. + assert!(s.find_posts_by_circle_intent("crew", &network_id).unwrap().is_empty()); + + // Fix pattern: union across personas finds both posts. + let mut found = 0; + for pi in s.list_posting_identities().unwrap() { + found += s.find_posts_by_circle_intent("crew", &pi.node_id).unwrap().len(); + } + assert_eq!(found, 2); + } + + // --- v0.8 (A3): comment TTL + greeting storage --- + + fn ttl_comment(author: NodeId, post_id: PostId, ts: u64, expires: u64) -> InlineComment { + InlineComment { + author, + post_id, + content: "hello".to_string(), + timestamp_ms: ts, + signature: vec![0u8; 64], + deleted_at: None, + ref_post_id: None, + pub_x_index: None, + group_sig: None, + encrypted_payload: None, + expires_at_ms: expires, + } + } + + fn test_now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 + } + + /// The expires_at migration must be idempotent across re-opens of a + /// file-backed DB (pragma-guarded ALTER). + #[test] + fn expires_at_migration_idempotent() { + let dir = std::env::temp_dir().join(format!("itsgoin-a3-mig-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let db_path = dir.join("test.db"); + let db_str = db_path.to_str().unwrap().to_string(); + { + let s = Storage::open(&db_str).unwrap(); + let now = test_now_ms(); + s.store_comment(&ttl_comment(make_node_id(1), make_post_id(1), now, now + 60_000)).unwrap(); + } + // Re-open: migration runs again, must not fail or lose data. + { + let s = Storage::open(&db_str).unwrap(); + let comments = s.get_comments(&make_post_id(1)).unwrap(); + assert_eq!(comments.len(), 1); + assert!(comments[0].expires_at_ms > 0, "expiry survived re-open"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// expire_comments hard-deletes only due rows; legacy (NULL/0) rows + /// are never touched. + #[test] + fn expire_comments_deletes_only_due_rows() { + let s = temp_storage(); + let post_id = make_post_id(2); + let now = test_now_ms(); + + s.store_comment(&ttl_comment(make_node_id(1), post_id, 1000, now - 1)).unwrap(); // due + s.store_comment(&ttl_comment(make_node_id(2), post_id, 1001, now + 60_000)).unwrap(); // live + s.store_comment(&ttl_comment(make_node_id(3), post_id, 1002, 0)).unwrap(); // legacy no-TTL + + assert_eq!(s.expire_comments(now).unwrap(), 1, "only the due row deleted"); + // Hard delete: gone even from the with-tombstones view. + let all = s.get_comments_with_tombstones(&post_id).unwrap(); + assert_eq!(all.len(), 2); + assert!(all.iter().all(|c| c.author != make_node_id(1))); + // Idempotent. + assert_eq!(s.expire_comments(now).unwrap(), 0); + } + + /// Read filters exclude expired-but-unswept rows (belt-and-suspenders + /// between sweeps). + #[test] + fn read_filters_exclude_expired_unswept() { + let s = temp_storage(); + let post_id = make_post_id(3); + let now = test_now_ms(); + + s.store_comment(&ttl_comment(make_node_id(1), post_id, 1000, now - 1)).unwrap(); // expired, unswept + s.store_comment(&ttl_comment(make_node_id(2), post_id, 1001, now + 60_000)).unwrap(); + + assert_eq!(s.get_comments(&post_id).unwrap().len(), 1); + assert_eq!(s.get_comments_with_tombstones(&post_id).unwrap().len(), 1); + assert_eq!(s.get_comment_count(&post_id).unwrap(), 1); + } + + #[test] + fn open_slot_comment_count_helper() { + let s = temp_storage(); + let post_id = make_post_id(4); + let now = test_now_ms(); + + for i in 0..3u8 { + let mut c = ttl_comment(make_node_id(10 + i), post_id, 1000 + i as u64, now + 60_000); + c.pub_x_index = Some(5); + s.store_comment(&c).unwrap(); + } + // One on a different slot; one expired on slot 5. + let mut other = ttl_comment(make_node_id(20), post_id, 2000, now + 60_000); + other.pub_x_index = Some(6); + s.store_comment(&other).unwrap(); + let mut expired = ttl_comment(make_node_id(21), post_id, 2001, now - 1); + expired.pub_x_index = Some(5); + s.store_comment(&expired).unwrap(); + + assert_eq!(s.count_unexpired_open_slot_comments(&post_id, 5).unwrap(), 3); + assert_eq!(s.count_unexpired_open_slot_comments(&post_id, 6).unwrap(), 1); + assert_eq!(s.count_unexpired_open_slot_comments(&post_id, 7).unwrap(), 0); + } + + #[test] + fn greeting_dismissals_and_reply_keys() { + let s = temp_storage(); + let author = make_node_id(1); + let post_id = make_post_id(5); + + assert!(!s.is_greeting_dismissed(&author, &post_id, 1000).unwrap()); + s.add_greeting_dismissal(&author, &post_id, 1000).unwrap(); + assert!(s.is_greeting_dismissed(&author, &post_id, 1000).unwrap()); + // Different timestamp = different greeting, not dismissed. + assert!(!s.is_greeting_dismissed(&author, &post_id, 1001).unwrap()); + // Idempotent re-dismiss. + s.add_greeting_dismissal(&author, &post_id, 1000).unwrap(); + + // Reply keys roundtrip. + let privkey = [7u8; 32]; + let pubkey = [8u8; 32]; + s.store_greeting_reply_key(&pubkey, &privkey, &post_id).unwrap(); + let keys = s.list_greeting_reply_keys().unwrap(); + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].0, privkey); + assert_eq!(keys[0].1, post_id); + } } diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index aca1ea1..9b951e3 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -68,7 +68,22 @@ pub struct Attachment { pub size_bytes: u64, } -/// Public profile — plaintext, synced to all peers +/// Public profile — plaintext, synced to all peers. +/// +/// v0.8 keying (dual-purpose struct — the `node_id` decides which class): +/// - NETWORK-id rows carry TOPOLOGY ONLY (anchors, recent_peers, +/// preferred_peers); persona fields stay empty. They travel via +/// ProfileUpdate / InitialExchange, always through +/// `sanitized_for_network_broadcast()`. +/// - POSTING-id rows carry PERSONA fields (display_name, bio, avatar_cid, +/// public_visible); topology fields stay empty. They travel ONLY as +/// signed profile posts (`ProfilePostContent`), which have no topology +/// fields at all — so posting identities never advertise device +/// neighborhoods on the wire. +/// The topology NodeIds on network-id rows are the designed 11-needle +/// findability mechanism (device-to-device, no posting-id linkage) — an +/// accepted tradeoff, not a leak; see design.html v0.8 location-anonymity +/// ruling before reflagging in audits. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PublicProfile { pub node_id: NodeId, @@ -856,8 +871,6 @@ pub struct AuthorManifest { /// The post this manifest is anchored to pub post_id: PostId, pub author: NodeId, - /// Author's N+10:A (ip:port strings) - pub author_addresses: Vec, /// Original post creation time (ms) pub created_at: u64, /// When manifest was last updated (ms) @@ -870,20 +883,16 @@ pub struct AuthorManifest { pub signature: Vec, } -/// CDN manifest traveling with blobs (author-signed part + host metadata) +/// CDN manifest traveling with blobs (author-signed part + serving host). +/// v0.8: ALL device-address fields removed (host_addresses, source, +/// source_addresses, downstream_count) — posting-identity manifests must not +/// carry device addresses (location anonymity). Holder resolution goes via +/// file_holders / worm lookup / redirect peers instead. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CdnManifest { pub author_manifest: AuthorManifest, - /// Serving host's NodeId + /// Serving host's NodeId (the QUIC-authenticated device serving the blob) pub host: NodeId, - /// Serving host's N+10:A - pub host_addresses: Vec, - /// Who the host got it from - pub source: NodeId, - /// Source's N+10:A - pub source_addresses: Vec, - /// How many downstream this host has - pub downstream_count: u32, } /// Cached routing info for a social contact @@ -978,6 +987,38 @@ pub struct InlineComment { /// Non-FoF observers see only ciphertext + sigs — body is opaque. #[serde(default, skip_serializing_if = "Option::is_none")] pub encrypted_payload: Option>, + /// v0.8 (A3): absolute expiry, ms since epoch. Fixed at creation and + /// covered by the comment signature (digest v2) — no silent extension + /// is possible. Ordinary comments: created_at + rand(30..=365 days). + /// Registry entries: created_at + exactly 30 days. `0` means "no TTL" + /// (pre-v0.8 comment); v0.8 receivers REJECT `0` at ingest. + #[serde(default)] + pub expires_at_ms: u64, +} + +/// v0.8 (A3): plaintext JSON inside a sealed greeting blob (see +/// `crypto::seal_greeting_body`). Only the recipient (bio author for an +/// original greeting; holder of the per-greeting `reply_pubkey` private +/// half for a reply) can read this. +/// +/// Messaging-first (rounds 8–9): `return_path` names the post whose +/// Greeting open slot the recipient replies into (v1: the sender's own +/// bio post), and `reply_pubkey` is a FRESH per-greeting x25519 key — +/// replies are sealed to it, never to a long-term key. No vouch is +/// involved anywhere in this flow. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GreetingBody { + pub v: u32, + /// hex32 — the sender's REAL posting pubkey (inside the seal only). + pub sender_persona: String, + /// <= 64 chars. + pub sender_name: String, + /// <= 600 chars. + pub text: String, + /// hex32 post id — where the sender watches for a reply. + pub return_path: String, + /// hex32 fresh x25519 pubkey — seal replies to THIS. + pub reply_pubkey: String, } /// Permission level for comments on a post @@ -1076,6 +1117,34 @@ pub struct RevocationEntry { pub author_sig: Vec, } +/// v0.8 (A3): what an open slot on a post is FOR. One mechanism — a wrap +/// slot whose V_x is derivable by anyone — two features aimed at +/// different parent posts (round-6 ruling). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum OpenSlotKind { + /// Sealed first-contact greetings on a bio post. + Greeting, + /// Plaintext self-certifying registration entries on the registry post. + Registry, +} + +/// v0.8 (A3): author-signed declaration that one of the post's wrap +/// slots is OPEN — its V_x is derivable from the post alone via +/// `crypto::derive_open_slot_vx(author, slot_binder_nonce)`. Covered by +/// the PostId hash (lives inside `FoFCommentGating`), so it is immutable +/// per publish; a bio republish (new slot_binder_nonce) is the only way +/// to change it. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OpenSlotDecl { + /// Index into `wrap_slots`; its pub_x is a normal member of + /// `pub_post_set`. Observable-by-design: greetings are identifiable + /// as a class, never by sender. + pub slot_index: u32, + pub kind: OpenSlotKind, + /// Padded plaintext bucket size in bytes (single bucket, design §27). + pub body_bucket: u16, +} + /// FoF Layer 2: the author-published gating block embedded in a /// FoF-comment-policy post. Carries the wrap slots + the matching /// pub_post_set + the slot_binder_nonce. The `revocation_list` is @@ -1097,6 +1166,10 @@ pub struct FoFCommentGating { /// arrive; the on-wire t=0 snapshot is empty. #[serde(default)] pub revocation_list: Vec, + /// v0.8 (A3): optional open-slot declaration (greeting / registry). + /// `None` on ordinary FoF-gated posts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_slot: Option, } /// Author-controlled engagement policy for a post @@ -1128,7 +1201,19 @@ pub enum BlobHeaderDiffOp { RemoveReaction { reactor: NodeId, emoji: String, post_id: PostId }, AddComment(InlineComment), EditComment { author: NodeId, post_id: PostId, timestamp_ms: u64, new_content: String }, - DeleteComment { author: NodeId, post_id: PostId, timestamp_ms: u64 }, + /// v0.8 (A3): self-certifying delete. `signature` is + /// `crypto::sign_comment_delete` by the comment author's posting key + /// over (author || post_id || timestamp_ms). Receivers accept when the + /// signature verifies OR when the diff sender is the parent post's + /// author (moderation override). Empty signature = legacy shape, + /// only the sender-override path can accept it. + DeleteComment { + author: NodeId, + post_id: PostId, + timestamp_ms: u64, + #[serde(default)] + signature: Vec, + }, SetPolicy(CommentPolicy), ThreadSplit { new_post_id: PostId }, /// Write an encrypted receipt slot (64 bytes encrypted data) diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 5d9dfdd..87bbc13 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -145,6 +145,8 @@ struct BadgeCountsDto { unread_messages: usize, new_reacts: usize, new_comments: usize, + /// v0.8 (A3): undismissed greetings in the inbox (Messages badge adds this). + greetings: usize, } #[derive(Serialize)] @@ -279,7 +281,11 @@ async fn post_to_dto( // Engagement data let reaction_counts = { let storage = node.storage.get().await; - storage.get_reaction_counts(id, &node.node_id).unwrap_or_default() + // Reactors are posting ids — "reacted_by_me" = any of our personas. + let our_ids: Vec = storage.list_posting_identities() + .unwrap_or_default() + .into_iter().map(|p| p.node_id).collect(); + storage.get_reaction_counts(id, &our_ids).unwrap_or_default() .into_iter() .map(|(emoji, count, reacted_by_me)| ReactionCountDto { emoji, count, reacted_by_me }) .collect() @@ -555,9 +561,13 @@ async fn list_posting_identities( async fn create_posting_identity( state: State<'_, AppNode>, display_name: String, + greetings_open: Option, ) -> Result { let node = get_node(&state).await; - let id = node.create_posting_identity(display_name).await.map_err(|e| e.to_string())?; + let id = node + .create_posting_identity(display_name, greetings_open) + .await + .map_err(|e| e.to_string())?; Ok(PostingIdentityDto { node_id: hex::encode(id.node_id), display_name: id.display_name, @@ -863,10 +873,12 @@ async fn post_to_dto_batch( // Batch queries — 3 queries total instead of 4 × N let (reaction_map, comment_map, intent_map, posting_identities) = { let storage = node.storage.get().await; - let reactions = storage.get_reaction_counts_batch(&post_ids, &node.node_id).unwrap_or_default(); + let identities = storage.list_posting_identities().unwrap_or_default(); + // Reactors are posting ids — "reacted_by_me" = any of our personas. + let our_ids: Vec = identities.iter().map(|p| p.node_id).collect(); + let reactions = storage.get_reaction_counts_batch(&post_ids, &our_ids).unwrap_or_default(); let comments = storage.get_comment_counts_batch(&post_ids).unwrap_or_default(); let intents = storage.get_post_intents_batch(&post_ids).unwrap_or_default(); - let identities = storage.list_posting_identities().unwrap_or_default(); (reactions, comments, intents, identities) }; // Map posting-id -> display-name so we can tag author=persona posts. @@ -1729,6 +1741,264 @@ async fn set_session_relay_enabled(state: State<'_, AppNode>, enabled: bool) -> Ok(()) } +// --- v0.8 (A3): registry search + greetings --- + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RegistryEntryDto { + author_id: String, + display_name: String, + keywords: Vec, + updated_at_ms: u64, + expires_at_ms: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RegistryStatusDto { + listed: bool, + keywords: Vec, + expires_at_ms: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GreetingDto { + /// Comment key part 1 (throwaway outer identity) — opaque to the UI, + /// passed back verbatim for reply/dismiss. + comment_author: String, + /// Comment key part 2. + post_id: String, + /// Comment key part 3. + timestamp_ms: u64, + /// The sender's REAL persona (recovered from inside the seal). + sender_persona_id: String, + sender_name: String, + content: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GreetingSlotDto { + bio_post_id: String, + accepts_greetings: bool, +} + +/// Register the persona in the network registry (30d entry, auto-renews +/// while listed — the core eviction cycle re-signs ~every 25 days). +#[tauri::command] +async fn register_persona( + state: State<'_, AppNode>, + posting_id_hex: String, + name: String, + keywords: Vec, +) -> Result<(), String> { + let node = get_node(&state).await; + let posting_id = parse_node_id(&posting_id_hex)?; + node.register_persona(&posting_id, &name, &keywords) + .await + .map_err(|e| e.to_string()) +} + +/// Remove the persona's registry entry (self-certifying signed delete). +#[tauri::command] +async fn unregister_persona( + state: State<'_, AppNode>, + posting_id_hex: String, +) -> Result<(), String> { + let node = get_node(&state).await; + let posting_id = parse_node_id(&posting_id_hex)?; + node.unregister_persona(&posting_id).await.map_err(|e| e.to_string()) +} + +/// Current "Listed" state for the consent panel: listed flag + the +/// keywords used at last registration + entry expiry (0 if none held). +#[tauri::command] +async fn get_registry_status( + state: State<'_, AppNode>, + posting_id_hex: String, +) -> Result { + let node = get_node(&state).await; + let posting_id = parse_node_id(&posting_id_hex)?; + let storage = node.storage.get().await; + let listed = storage + .get_setting(&format!("registry_listed.{}", posting_id_hex)) + .ok() + .flatten() + .map(|v| v == "1") + .unwrap_or(false); + let keywords = storage + .get_setting(&format!("registry_keywords.{}", posting_id_hex)) + .ok() + .flatten() + .map(|v| { + v.split(',') + .map(|k| k.trim().to_string()) + .filter(|k| !k.is_empty()) + .collect() + }) + .unwrap_or_default(); + let expires_at_ms = storage + .get_newest_registry_entry(&itsgoin_core::registry::REGISTRY_POST_ID, &posting_id) + .ok() + .flatten() + .map(|(_ts, exp)| exp) + .unwrap_or(0); + Ok(RegistryStatusDto { listed, keywords, expires_at_ms }) +} + +/// Search the registry: refreshes the comment chain from peers, then +/// queries locally (search cost lands on the searcher, design §27). +#[tauri::command] +async fn search_registry( + state: State<'_, AppNode>, + query: String, +) -> Result, String> { + let node = get_node(&state).await; + let matches = node.search_registry(&query).await.map_err(|e| e.to_string())?; + Ok(matches + .into_iter() + .map(|m| RegistryEntryDto { + author_id: hex::encode(m.author), + display_name: m.name, + keywords: m.keywords, + updated_at_ms: m.timestamp_ms, + expires_at_ms: m.expires_at_ms, + }) + .collect()) +} + +/// Locally-held greeting-slot info for an author's latest bio post. +async fn greeting_slot_local(node: &Node, author: &NodeId) -> Option { + let storage = node.storage.get().await; + let bio_post_id = storage + .get_latest_profile_post_id_by_author(author) + .ok() + .flatten()?; + let post = storage.get_post(&bio_post_id).ok().flatten()?; + let accepts = post + .fof_gating + .as_ref() + .and_then(|g| g.open_slot.as_ref()) + .map(|d| d.kind == itsgoin_core::types::OpenSlotKind::Greeting) + .unwrap_or(false); + Some(GreetingSlotDto { + bio_post_id: hex::encode(bio_post_id), + accepts_greetings: accepts, + }) +} + +/// Does this author's bio declare a Greeting open slot? Drives the +/// "Say hi" button in the bio modal. On-demand (A3 §3.2): if the bio +/// isn't local yet (e.g. a registry search result), worm-locate the +/// author and sync their posts once before answering. +#[tauri::command] +async fn get_greeting_slot( + state: State<'_, AppNode>, + node_id_hex: String, +) -> Result, String> { + let node = get_node(&state).await; + let nid = parse_node_id(&node_id_hex)?; + if let Some(dto) = greeting_slot_local(&node, &nid).await { + return Ok(Some(dto)); + } + // Fetch-on-demand: existing worm lookup + one-shot sync, then retry. + if let Ok(Some(wr)) = node.worm_lookup(&nid).await { + let _ = node.sync_with(wr.node_id).await; + return Ok(greeting_slot_local(&node, &nid).await); + } + Ok(None) +} + +/// Send a sealed first-contact greeting to a bio post's author. +/// Messaging-first: no vouch is involved anywhere in this flow. +#[tauri::command] +async fn send_greeting( + state: State<'_, AppNode>, + bio_post_id_hex: String, + content: String, +) -> Result<(), String> { + let node = get_node(&state).await; + let bio_post_id = hex_to_postid(&bio_post_id_hex)?; + node.send_greeting(bio_post_id, content).await.map_err(|e| e.to_string()) +} + +/// Reply to a received greeting — sealed to the greeting's fresh reply +/// key and dropped into its declared return path (never a long-term key). +#[tauri::command] +async fn reply_to_greeting( + state: State<'_, AppNode>, + comment_author_hex: String, + post_id_hex: String, + timestamp_ms: u64, + content: String, +) -> Result<(), String> { + let node = get_node(&state).await; + let comment_author = parse_node_id(&comment_author_hex)?; + let post_id = hex_to_postid(&post_id_hex)?; + node.reply_to_greeting(comment_author, post_id, timestamp_ms, content) + .await + .map_err(|e| e.to_string()) +} + +/// Unsealed greetings (and replies) across all personas' bio posts, +/// newest first. Return-path/reply-key internals stay inside core. +#[tauri::command] +async fn list_greetings(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; + let records = node.list_greetings().await.map_err(|e| e.to_string())?; + Ok(records + .into_iter() + .map(|g| GreetingDto { + comment_author: hex::encode(g.comment_author), + post_id: hex::encode(g.post_id), + timestamp_ms: g.timestamp_ms, + sender_persona_id: hex::encode(g.sender_persona), + sender_name: g.sender_name, + content: g.text, + }) + .collect()) +} + +/// Dismiss a greeting (local only — nothing propagates). +#[tauri::command] +async fn dismiss_greeting( + state: State<'_, AppNode>, + comment_author_hex: String, + post_id_hex: String, + timestamp_ms: u64, +) -> Result<(), String> { + let node = get_node(&state).await; + let comment_author = parse_node_id(&comment_author_hex)?; + let post_id = hex_to_postid(&post_id_hex)?; + node.dismiss_greeting(comment_author, post_id, timestamp_ms) + .await + .map_err(|e| e.to_string()) +} + +/// Per-persona greeting consent (republishes the bio on change). +#[tauri::command] +async fn set_greetings_open( + state: State<'_, AppNode>, + posting_id_hex: String, + open: bool, +) -> Result<(), String> { + let node = get_node(&state).await; + let posting_id = parse_node_id(&posting_id_hex)?; + node.set_greetings_open(&posting_id, open).await.map_err(|e| e.to_string()) +} + +/// Current greeting-consent state (unset = ON, the pre-checked default). +#[tauri::command] +async fn get_greetings_open( + state: State<'_, AppNode>, + posting_id_hex: String, +) -> Result { + let node = get_node(&state).await; + let posting_id = parse_node_id(&posting_id_hex)?; + node.get_greetings_open(&posting_id).await.map_err(|e| e.to_string()) +} + /// Open a URL in the user's default system browser. /// Desktop: spawns the platform opener (xdg-open / open / cmd start). /// Only https:// URLs are accepted to avoid being a generic command exec. @@ -2063,6 +2333,28 @@ async fn get_seen_engagement( })) } +/// Comment count for the engagement badges, EXCLUDING greeting open-slot +/// comments: greetings have their own dedicated badge/inbox, and counting +/// them here would double-count every greeting into "My Posts" while +/// pointing the user at a bio post where the comment renders as sealed +/// ciphertext instead of at the Greetings inbox. +fn non_greeting_comment_count( + storage: &itsgoin_core::storage::Storage, + post_id: &itsgoin_core::types::PostId, + post: &itsgoin_core::types::Post, +) -> u64 { + let mut count = storage.get_comment_count(post_id).unwrap_or(0); + if let Some(decl) = post.fof_gating.as_ref().and_then(|g| g.open_slot.as_ref()) { + if matches!(decl.kind, itsgoin_core::types::OpenSlotKind::Greeting) { + let greetings = storage + .count_unexpired_open_slot_comments(post_id, decl.slot_index) + .unwrap_or(0); + count = count.saturating_sub(greetings); + } + } + count +} + #[tauri::command] async fn get_badge_counts( state: State<'_, AppNode>, @@ -2071,11 +2363,17 @@ async fn get_badge_counts( let node = get_node(&state).await; let storage = node.storage.get().await; + // "Own post" checks below are posting-class: posts are authored by our + // posting identities (personas), never the network NodeId. + let own_ids: Vec = storage.list_posting_identities() + .unwrap_or_default() + .into_iter().map(|p| p.node_id).collect(); + // Feed badge: count non-DM posts from others newer than last_feed_view_ms let feed_posts = storage.get_feed().map_err(|e| e.to_string())?; let new_feed = feed_posts.iter() .filter(|(id, p, _vis)| { - p.author != node.node_id + !own_ids.contains(&p.author) && p.timestamp_ms > last_feed_view_ms && !matches!( storage.get_post_intent(id).ok().flatten(), @@ -2088,18 +2386,20 @@ async fn get_badge_counts( let all_posts = storage.list_posts_reverse_chron().map_err(|e| e.to_string())?; let mut new_engagement = 0usize; for (id, post, _vis) in &all_posts { - if post.author != node.node_id { continue; } + if !own_ids.contains(&post.author) { continue; } // Skip DMs if matches!( storage.get_post_intent(id).ok().flatten(), Some(VisibilityIntent::Direct(_)) ) { continue; } - let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id) + let total_reacts: u64 = storage.get_reaction_counts(id, &own_ids) .unwrap_or_default() .iter() .map(|(_, count, _)| *count) .sum(); - let total_comments = storage.get_comment_count(id).unwrap_or(0); + // Greeting open-slot comments are excluded — they have their own + // dedicated Greetings badge (see non_greeting_comment_count). + let total_comments = non_greeting_comment_count(&storage, id, post); if total_reacts > 0 || total_comments > 0 { let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0)); if total_reacts > seen_r as u64 || total_comments > seen_c as u64 { @@ -2114,14 +2414,14 @@ async fn get_badge_counts( matches!( storage.get_post_intent(id).ok().flatten(), Some(VisibilityIntent::Direct(_)) - ) || (p.author != node.node_id && matches!( + ) || (!own_ids.contains(&p.author) && matches!( storage.get_post_with_visibility(id).ok().flatten(), Some((_, PostVisibility::Encrypted { .. })) )) }); let mut seen_partners = std::collections::HashSet::new(); for (_id, post, _vis) in dm_posts { - let partner = if post.author == node.node_id { + let partner = if own_ids.contains(&post.author) { // sent DM — skip for unread count continue; } else { @@ -2139,16 +2439,22 @@ async fn get_badge_counts( let mut new_reacts = 0usize; let mut new_comments = 0usize; for (id, post, _vis) in &all_posts { - if post.author != node.node_id { continue; } - let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id) + if !own_ids.contains(&post.author) { continue; } + let total_reacts: u64 = storage.get_reaction_counts(id, &own_ids) .unwrap_or_default().iter().map(|(_, c, _)| *c).sum(); - let total_comments = storage.get_comment_count(id).unwrap_or(0); + // Greeting open-slot comments excluded here too (dedicated badge). + let total_comments = non_greeting_comment_count(&storage, id, post); let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0)); if total_reacts > seen_r as u64 { new_reacts += (total_reacts - seen_r as u64) as usize; } if total_comments > seen_c as u64 { new_comments += (total_comments - seen_c as u64) as usize; } } - Ok(BadgeCountsDto { new_feed, new_engagement, unread_messages, new_reacts, new_comments }) + // v0.8 (A3): greetings inbox size. Release the storage slot first — + // list_greetings takes its own guard internally. + drop(storage); + let greetings = node.list_greetings().await.map(|g| g.len()).unwrap_or(0); + + Ok(BadgeCountsDto { new_feed, new_engagement, unread_messages, new_reacts, new_comments, greetings }) } #[tauri::command] @@ -2994,6 +3300,8 @@ async fn export_data( } } }; + // node_id here is only used for the export file name; the post filter + // inside export_data now iterates all posting identities itself. let result = itsgoin_core::export::export_data( &node.data_dir, &node.storage, @@ -3083,11 +3391,13 @@ async fn import_public_posts( zip_path: String, ) -> Result { let node = get_node(&state).await; + // Imported posts are re-authored as US — that must be a POSTING identity + // (the network NodeId never authors posts). let result = itsgoin_core::import::import_public_posts( std::path::Path::new(&zip_path), &node.storage, &node.blob_store, - &node.node_id, + &node.default_posting_id, ).await.map_err(|e| e.to_string())?; Ok(result.message) } @@ -3141,12 +3451,14 @@ async fn import_merge_with_key( key_hex: String, ) -> Result { let node = get_node(&state).await; + // Merged content is authored under a POSTING identity, never the + // network NodeId. let result = itsgoin_core::import::merge_with_key( std::path::Path::new(&zip_path), &key_hex, &node.storage, &node.blob_store, - &node.node_id, + &node.default_posting_id, &node.secret_seed(), ).await.map_err(|e| e.to_string())?; Ok(result.message) @@ -3414,6 +3726,17 @@ pub fn run() { exit_app, get_session_relay_enabled, set_session_relay_enabled, + register_persona, + unregister_persona, + get_registry_status, + search_registry, + get_greeting_slot, + send_greeting, + reply_to_greeting, + list_greetings, + dismiss_greeting, + set_greetings_open, + get_greetings_open, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/frontend/app.js b/frontend/app.js index 7db32cb..dd893ca 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1090,7 +1090,117 @@ function setupMyPostsMediaObserver() { mutObs.observe(myPostsList, { childList: true }); } +// v0.8 (A3): greetings inbox. Rows offer [Reply] (primary) and [Dismiss] +// ONLY — vouching is People-tab relationship management and must never be +// reachable from a Messages surface (round 9). The sender's name opens +// the ordinary bio modal, where the existing Friend flow lives. +let _greetingsCount = 0; +let _lastDmUnread = 0; +async function loadGreetings() { + const container = document.getElementById('greetings-list'); + if (!container) return; + try { + const greetings = await invoke('list_greetings'); + _greetingsCount = greetings.length; + + // Notify once per greeting (localStorage-backed seen set, tag + // prefix `greet-` mirroring `msg-`). + try { + let notified = []; + try { notified = JSON.parse(localStorage.getItem('greetNotified') || '[]'); } catch (_) {} + const notifSet = new Set(notified); + const keys = []; + for (const g of greetings) { + const key = `greet-${g.commentAuthor}-${g.timestampMs}`; + keys.push(key); + if (_notifReady && !notifSet.has(key)) { + const who = g.senderName || g.senderPersonaId.slice(0, 8); + maybeNotify(`Greeting from ${who}`, (g.content || '').slice(0, 100), key); + } + } + // Keep only keys still in the inbox (dismissed/expired drop out). + localStorage.setItem('greetNotified', JSON.stringify(keys)); + } catch (_) {} + + if (greetings.length === 0) { + container.innerHTML = `

No greetings yet

`; + } else { + container.innerHTML = greetings.map(g => { + const icon = generateIdenticon(g.senderPersonaId, 18); + const label = escapeHtml(g.senderName || g.senderPersonaId.slice(0, 12)); + return `
+
${icon} ${label} ${formatTimeAgo(g.timestampMs)}
+
${escapeHtml(g.content)}
+
+ + +
+ +
`; + }).join(''); + + container.querySelectorAll('.greet-bio-link').forEach(el => { + el.addEventListener('click', (e) => { + e.preventDefault(); + openBioModal(el.dataset.nodeId, el.dataset.name); + }); + }); + container.querySelectorAll('.greet-reply-btn').forEach(btn => { + btn.addEventListener('click', () => { + const box = btn.closest('.greeting-item').querySelector('.greet-reply-box'); + box.classList.toggle('hidden'); + if (!box.classList.contains('hidden')) box.querySelector('textarea').focus(); + }); + }); + container.querySelectorAll('.greet-reply-send').forEach(btn => { + btn.addEventListener('click', async () => { + const item = btn.closest('.greeting-item'); + const input = item.querySelector('.greet-reply-box textarea'); + const content = input.value.trim(); + if (!content) return; + btn.disabled = true; + try { + await invoke('reply_to_greeting', { + commentAuthorHex: item.dataset.commentAuthor, + postIdHex: item.dataset.postId, + timestampMs: Number(item.dataset.ts), + content, + }); + toast('Reply sent (sealed)'); + input.value = ''; + item.querySelector('.greet-reply-box').classList.add('hidden'); + } catch (e) { toast('Error: ' + e); } + finally { btn.disabled = false; } + }); + }); + container.querySelectorAll('.greet-dismiss-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const item = btn.closest('.greeting-item'); + btn.disabled = true; + try { + await invoke('dismiss_greeting', { + commentAuthorHex: item.dataset.commentAuthor, + postIdHex: item.dataset.postId, + timestampMs: Number(item.dataset.ts), + }); + loadGreetings(); + } catch (e) { toast('Error: ' + e); btn.disabled = false; } + }); + }); + } + updateTabBadge('messages', _lastDmUnread + _greetingsCount); + } catch (e) { + container.innerHTML = `

Error: ${e}

`; + } +} + async function loadMessages(force) { + // v0.8 (A3): refresh the greetings inbox alongside DMs (own IPC call, + // fire-and-forget — it updates the shared Messages badge itself). + loadGreetings().catch(() => {}); try { const [posts, follows] = await Promise.all([ invoke('get_all_posts'), @@ -1336,7 +1446,9 @@ async function loadMessages(force) { const hasUnread = thread.posts.some(p => !p.isMe && p.timestampMs > lastReadMs); if (hasUnread) unreadCount++; } - updateTabBadge('messages', unreadCount); + // v0.8 (A3): the Messages badge = unread DMs + undismissed greetings. + _lastDmUnread = unreadCount; + updateTabBadge('messages', unreadCount + _greetingsCount); } catch (e) { conversationsList.innerHTML = `

Error: ${e}

`; } @@ -1669,7 +1781,7 @@ async function openBioModal(nodeId, preloadedName) { overlay.classList.remove('hidden'); try { - // `resolve_display` returns {name, bio, avatarCid} for any NodeId. + // `resolve_display` returns {displayName, bio, avatarCid} for any NodeId. const resolved = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null); const follows = await invoke('list_follows').catch(() => []); const ignored = await invoke('list_ignored_peers').catch(() => []); @@ -1677,7 +1789,7 @@ async function openBioModal(nodeId, preloadedName) { const following = follows.some(f => f.nodeId === nodeId); const isIgnored = ignored.some(i => i.nodeId === nodeId); const isVouched = vouches.some(v => v.nodeId === nodeId); - const name = (resolved && resolved.name) || preloadedName || nodeId.slice(0, 12); + const name = (resolved && resolved.displayName) || preloadedName || nodeId.slice(0, 12); const bio = (resolved && resolved.bio) || ''; const icon = generateIdenticon(nodeId, 48); @@ -1686,12 +1798,12 @@ async function openBioModal(nodeId, preloadedName) {
${icon}
-
${escapeHtml(name)}
+
${escapeHtml(name)}
${nodeId}
- ${bio ? `

${escapeHtml(bio)}

` : '

No bio.

'} -
+ ${bio ? `

${escapeHtml(bio)}

` : '

No bio.

'} +
${(following && isVouched) ? `` @@ -1785,11 +1897,90 @@ async function openBioModal(nodeId, preloadedName) { } catch (e) { toast('Error: ' + e); } finally { unfriend.disabled = false; } }; + + // v0.8 (A3): greeting affordance for strangers only (no existing + // relationship). The slot check may fetch the bio on-demand, so + // the button arrives asynchronously. Established peers keep the + // ordinary Message button. + if (!following && !isVouched) { + invoke('get_greeting_slot', { nodeIdHex: nodeId }).then(async slot => { + const row = document.getElementById('bio-actions'); + if (!row || !bodyEl.contains(row)) return; // modal replaced/closed + // The slot check may have pulled the author's posts on-demand + // (registry search results start non-local) — if the first + // resolve came back empty, patch name/bio in place now. + if (!resolved || (!resolved.displayName && !resolved.bio)) { + const again = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null); + if (again && bodyEl.contains(row)) { + if (again.displayName) { + titleEl.textContent = again.displayName; + const nm = document.getElementById('bio-modal-name'); + if (nm) nm.textContent = again.displayName; + } + if (again.bio) { + const bp = document.getElementById('bio-modal-bio'); + if (bp) { + bp.textContent = again.bio; + bp.className = ''; + bp.style.cssText = 'font-size:0.9rem;line-height:1.45;margin:0 0 0.75rem'; + } + } + } + } + if (slot && slot.acceptsGreetings) { + const hi = document.createElement('button'); + hi.id = 'bio-say-hi'; + hi.className = 'btn btn-primary btn-sm'; + hi.textContent = 'Say hi'; + row.prepend(hi); + hi.onclick = () => openGreetingCompose(slot.bioPostId, titleEl.textContent || name); + } else { + const na = document.createElement('button'); + na.className = 'btn btn-ghost btn-sm'; + na.disabled = true; + na.textContent = 'Not accepting greetings'; + row.appendChild(na); + } + }).catch(() => {}); + } } catch (e) { bodyEl.innerHTML = `

Error: ${e}

`; } } +// v0.8 (A3): greeting compose — swaps the bio modal body for a sealed +// one-shot compose aimed at the bio post's Greeting open slot. +function openGreetingCompose(bioPostIdHex, name) { + const titleEl = document.getElementById('popover-title'); + const bodyEl = document.getElementById('popover-body'); + if (!titleEl || !bodyEl) return; + titleEl.textContent = `Say hi to ${name}`; + bodyEl.innerHTML = ` + +
+ Sealed — only ${escapeHtml(name)} can read this + +
`; + const input = document.getElementById('greeting-text'); + setTimeout(() => input.focus(), 100); + const sendBtn = document.getElementById('greeting-send-btn'); + const send = async () => { + const content = input.value.trim(); + if (!content) return; + sendBtn.disabled = true; + try { + await invoke('send_greeting', { bioPostIdHex, content }); + toast('Greeting sent'); + closePopover(); + } catch (e) { + toast('Error: ' + e); + sendBtn.disabled = false; + } + }; + sendBtn.addEventListener('click', send); + input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.ctrlKey) send(); }); +} + function openAuthorFeed(nodeId, name) { authorFilterNodeId = nodeId; authorFilterName = name || nodeId.slice(0, 12); @@ -2003,7 +2194,13 @@ function renderTimer(label, lastMs, intervalSecs, now) { } function escapeHtml(str) { - return str.replace(/&/g, '&').replace(//g, '>'); + // Quotes MUST be escaped too: escaped output is interpolated into + // double-quoted HTML attributes (data-name="..."), and registry + // entries / greeting sender names are attacker-controlled — a name + // like `x" onclick="..."` would otherwise break out of the attribute + // and inject an inline handler (stored XSS). + return str.replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); } function formatTimeAgo(timestampMs) { @@ -2827,11 +3024,41 @@ async function doSyncAll() { } } +// v0.8 (A3): default persona id (hex) — the persona the Profiles lightbox +// and first-run consent act on. Falls back to the first-run auto persona. +async function getDefaultPersonaId() { + try { + const ids = await invoke('list_posting_identities'); + const def = ids.find(p => p.isDefault) || ids[0]; + if (def) return def.nodeId; + } catch (_) {} + try { + const auto = await invoke('get_first_run_auto_persona_id'); + if (auto) return auto; + } catch (_) {} + return null; +} + async function doSetupName() { // Name is optional — users who want to stay anonymous can proceed with a blank field. const name = setupName.value.trim(); setupBtn.disabled = true; try { + // v0.8 (A3): active choice at first publish — if the pre-checked + // "Accept greetings" box was unticked, record the opt-out BEFORE + // the first bio publish so no greeting slot ever ships (the + // republish inside set_display_name reads this setting). + const greetCheck = document.getElementById('setup-greetings-check'); + if (greetCheck && !greetCheck.checked) { + // The opt-out write is LOAD-BEARING (round 8: an explicit + // opt-out must never silently fail and ship the slot anyway). + // Any failure here aborts the publish; the overlay stays up. + const personaId = await getDefaultPersonaId(); + if (!personaId) { + throw new Error('could not resolve your persona to record the greeting opt-out — please try again'); + } + await invoke('set_setting', { key: 'greetings_open.' + personaId, value: '0' }); + } await invoke('set_display_name', { name }); setupOverlay.classList.add('hidden'); toast(name ? 'Welcome, ' + name + '!' : 'Welcome!'); @@ -3180,6 +3407,7 @@ document.querySelectorAll('.tab').forEach(tab => { if (!conversationsList.children.length) conversationsList.innerHTML = renderLoading(); loadMessages(true); loadDmRecipientOptions(); clearNotifications('msg-'); + clearNotifications('greet-'); } if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); @@ -3216,6 +3444,16 @@ $('#profile-lightbox-btn').addEventListener('click', () => { Show my profile to non-circle peers + + +
Not listed
+
@@ -3228,14 +3466,72 @@ $('#profile-lightbox-btn').addEventListener('click', () => {
`; document.body.appendChild(overlay); - overlay.querySelector('#lb-profile-save').addEventListener('click', async () => { + // v0.8 (A3): prefill the consent panel (registry listing + greeting + // consent) for the default persona. Status line mirrors auto-renew. + // The Save button stays DISABLED until the prefill settles: a listed + // user clicking Save against the unfilled (unchecked) checkbox would + // otherwise be treated as never-listed — their unlist intent dropped + // and auto-renew silently continuing. + let _lbWasListed = false; + const _lbSaveBtn = overlay.querySelector('#lb-profile-save'); + _lbSaveBtn.disabled = true; + (async () => { + try { + const personaId = await getDefaultPersonaId(); + if (!personaId || !document.body.contains(overlay)) return; + try { + const status = await invoke('get_registry_status', { postingIdHex: personaId }); + _lbWasListed = !!status.listed; + const listedCheck = overlay.querySelector('#lb-registry-listed'); + const kwInput = overlay.querySelector('#lb-registry-keywords'); + const statusEl = overlay.querySelector('#lb-registry-status'); + if (listedCheck) listedCheck.checked = _lbWasListed; + if (kwInput && status.keywords.length) kwInput.value = status.keywords.join(', '); + if (statusEl) statusEl.textContent = _lbWasListed + ? 'Listed — auto-renews every 30 days while checked' + : 'Not listed'; + } catch (_) {} + try { + const open = await invoke('get_greetings_open', { postingIdHex: personaId }); + const greetCheck = overlay.querySelector('#lb-greetings-open'); + if (greetCheck) greetCheck.checked = !!open; + } catch (_) {} + } finally { + _lbSaveBtn.disabled = false; + } + })(); + _lbSaveBtn.addEventListener('click', async () => { const name = overlay.querySelector('#lb-profile-name').value.trim(); const bio = overlay.querySelector('#lb-profile-bio').value.trim(); if (!name) { toast('Display name is required'); return; } try { + const personaId = await getDefaultPersonaId(); + // v0.8 (A3): write greeting consent BEFORE set_profile so the + // single bio republish below carries the right slot state + // (avoids a second republish via set_greetings_open). The + // write is LOAD-BEARING (round 8): if it fails, abort the + // publish rather than republishing with the wrong slot state. + const greetOpen = overlay.querySelector('#lb-greetings-open').checked; + if (!personaId) { + throw new Error('could not resolve your persona — profile not saved, please try again'); + } + await invoke('set_setting', { key: 'greetings_open.' + personaId, value: greetOpen ? '1' : '0' }); await invoke('set_profile', { name, bio }); const visible = overlay.querySelector('#lb-public-visible').checked; await invoke('set_public_visible', { visible }); + // v0.8 (A3): registry listing. Checked = (re-)register — the + // core renews automatically every ~25 days while listed. + // Unchecking sends the self-certifying signed delete. + if (personaId) { + const listed = overlay.querySelector('#lb-registry-listed').checked; + const keywords = overlay.querySelector('#lb-registry-keywords').value + .split(',').map(k => k.trim()).filter(k => k).slice(0, 8); + if (listed) { + await invoke('register_persona', { postingIdHex: personaId, name, keywords }); + } else if (_lbWasListed) { + await invoke('unregister_persona', { postingIdHex: personaId }); + } + } // Sync back to settings fields profileNameInput.value = name; profileBioInput.value = bio; @@ -3297,6 +3593,58 @@ $('#discover-toggle').addEventListener('click', () => { if (!body.classList.contains('hidden')) loadDiscoverPeople(); }); +// v0.8 (A3): registry search — pulls the registry comment chain from +// peers, then queries locally. Results show name/keywords only; the bio +// is fetched on-demand when a result is clicked (openBioModal). +async function runRegistrySearch() { + const input = $('#registry-search-input'); + const results = $('#registry-results'); + const btn = $('#registry-search-btn'); + const query = input.value.trim(); + if (!query) { results.innerHTML = ''; return; } + btn.disabled = true; + results.innerHTML = renderLoading(); + try { + const entries = await invoke('search_registry', { query }); + if (!entries || entries.length === 0) { + results.innerHTML = renderEmptyState( + 'No registry matches', + 'Entries expire after 30 days — try different keywords.' + ); + return; + } + results.innerHTML = `

Registry results

` + entries.map(e => { + const icon = generateIdenticon(e.authorId, 18); + const label = escapeHtml(e.displayName); + const kwLine = e.keywords && e.keywords.length + ? `
${e.keywords.map(k => escapeHtml(k)).join(' · ')}
` + : ''; + const ageLine = e.updatedAtMs + ? `
Listed ${formatTimeAgo(e.updatedAtMs)}
` + : ''; + return `
+
${icon} ${label}
+ ${kwLine} + ${ageLine} +
`; + }).join(''); + results.querySelectorAll('.bio-link').forEach(el => { + el.addEventListener('click', (ev) => { + ev.preventDefault(); + openBioModal(el.dataset.nodeId, el.dataset.name); + }); + }); + } catch (e) { + results.innerHTML = `

Error: ${e}

`; + } finally { + btn.disabled = false; + } +} +$('#registry-search-btn').addEventListener('click', runRegistrySearch); +$('#registry-search-input').addEventListener('keydown', (e) => { + if (e.key === 'Enter') runRegistrySearch(); +}); + // "See new activity" button in Following: applies staged data and // re-renders so the user picks when to rearrange the list. { @@ -3977,15 +4325,46 @@ function renderPersonasList() { const deleteBtn = p.isDefault ? '' : ``; + // Round 8: greeting consent is per-persona REVOCABLE. This toggle + // is the revocation path for non-default personas (the Profiles + // lightbox only manages the default persona). Label filled async. + const greetBtn = ``; return `
${escapeHtml(label)}${defaultTag}
${p.nodeId.substring(0, 16)}...
-
${setDefaultBtn}${deleteBtn}
+
${greetBtn}${setDefaultBtn}${deleteBtn}
`; }).join(''); + list.querySelectorAll('.persona-greetings-btn').forEach(btn => { + // Fill the current consent state, then enable the toggle. + (async () => { + try { + const open = await invoke('get_greetings_open', { postingIdHex: btn.dataset.id }); + btn.dataset.open = open ? '1' : '0'; + btn.textContent = open ? 'Greetings: on' : 'Greetings: off'; + btn.disabled = false; + } catch (_) { + btn.textContent = 'Greetings: ?'; + } + })(); + btn.addEventListener('click', async () => { + const next = btn.dataset.open !== '1'; + btn.disabled = true; + try { + await invoke('set_greetings_open', { postingIdHex: btn.dataset.id, open: next }); + btn.dataset.open = next ? '1' : '0'; + btn.textContent = next ? 'Greetings: on' : 'Greetings: off'; + toast(next + ? 'Greetings enabled — bio republished with a greeting slot' + : 'Greetings disabled — slot revoked on published bios'); + } catch (e) { toast('Error: ' + e); } + finally { btn.disabled = false; } + }); + }); + list.querySelectorAll('.set-default-persona-btn').forEach(btn => { btn.addEventListener('click', async () => { btn.disabled = true; @@ -4040,6 +4419,10 @@ $('#create-persona-btn').addEventListener('click', () => {

New Persona

Peers will see this persona as a distinct author. No one can tell which personas belong to the same device.

+
@@ -4052,7 +4435,12 @@ $('#create-persona-btn').addEventListener('click', () => { const name = nameInput.value.trim(); if (!name) { toast('Enter a name'); return; } try { - await invoke('create_posting_identity', { displayName: name }); + // Round 8: greeting consent is an ACTIVE pre-checked choice at + // first publish. The choice rides the create call itself so the + // consent setting is written BEFORE the initial bio publish — + // an opted-out persona never ships a greeting slot at all. + const greetingsOpen = overlay.querySelector('#new-persona-greetings').checked; + await invoke('create_posting_identity', { displayName: name, greetingsOpen }); toast(`Persona created: ${name}`); overlay.remove(); loadPersonas(); @@ -4346,7 +4734,7 @@ async function init() { // Update tab badges from welcome screen updateTabBadge('feed', b.newFeed || 0); updateTabBadge('myposts', b.newEngagement || 0); - updateTabBadge('messages', b.unreadMessages || 0); + updateTabBadge('messages', (b.unreadMessages || 0) + (b.greetings || 0)); // Ticker + notifications only after user leaves welcome screen // (welcome page already shows these counts directly) }).catch(() => {}); @@ -4500,6 +4888,7 @@ async function init() { const badges = await invoke('get_badge_counts', { lastFeedViewMs: _lastFeedViewMs }); if (currentTab !== 'feed') updateTabBadge('feed', badges.newFeed); if (currentTab !== 'myposts') updateTabBadge('myposts', badges.newEngagement); + if (currentTab !== 'messages') updateTabBadge('messages', (badges.unreadMessages || 0) + (badges.greetings || 0)); } catch (_) {} }, 30000); diff --git a/frontend/index.html b/frontend/index.html index 7848b1a..3fcfc55 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,6 +13,12 @@

Welcome to ItsGoin

Pick a display name if you want one — or leave blank to stay anonymous.

+ +
@@ -139,8 +145,14 @@
- - + + +
+

Greetings

+

Sealed hellos from people outside your circles. Replies go back sealed — only the sender can read them.

+
+
diff --git a/scripts/a3_integration_test.sh b/scripts/a3_integration_test.sh new file mode 100755 index 0000000..7682704 --- /dev/null +++ b/scripts/a3_integration_test.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# v0.8 A3 integration test — registry + greetings across 3 local nodes. +# +# Spec: A3-SPEC §6 multi-node scenario. Run from the repo root after +# `cargo build -p itsgoin-cli`. Uses FIFOs to drive the interactive REPL. +# +# 1. All nodes self-materialize the same registry post ID; node1 also +# runs the --publish-registry genesis one-shot (debug gate override). +# 2. node2 registers "Alice rust p2p" → node3 `search rust` finds her. +# node2 `unregister` → node3 re-search finds nothing. +# 3. node2's bio has a greeting slot; node3 greets it; node2 sees the +# unsealed text + real sender persona; node2 replies; node3 sees the +# reply. node1 (holder) stores only ciphertext. +# 5. Newest-wins: node2 re-registers; node3 sees exactly one entry. +# 6. (Optional, ITSGOIN_TEST_TTL_SECS) comment expiry sweep. +# +# Exit code 0 = all checks passed. Logs: /tmp/itsgoin-cli{1,2,3}.log +set -u +cd "$(dirname "$0")/.." +BIN=target/debug/itsgoin +[ -x "$BIN" ] || { echo "build first: cargo build -p itsgoin-cli"; exit 2; } + +PASS=0; FAIL=0 +check() { # check + local desc="$1"; shift + if "$@" >/dev/null 2>&1; then PASS=$((PASS+1)); echo "PASS: $desc"; + else FAIL=$((FAIL+1)); echo "FAIL: $desc"; fi +} +strip_ansi() { sed 's/\x1b\[[0-9;]*m//g' "$1" | grep -v "WARN.*netlink\|WARN.*buffer_tool"; } +sq() { sqlite3 "/tmp/itsgoin-test$1/itsgoin.db" "$2"; } + +cleanup() { + { exec 13>&- 14>&- 15>&-; } 2>/dev/null || true + kill $(pgrep -f 'itsgoin.*itsgoin-test') 2>/dev/null + rm -f /tmp/itsgoin-cmd{1,2,3} +} +trap cleanup EXIT + +echo "== setup ==" +kill $(pgrep -f 'itsgoin.*itsgoin-test') 2>/dev/null; sleep 1 +for i in 1 2 3; do + rm -rf /tmp/itsgoin-test$i /tmp/itsgoin-cmd$i /tmp/itsgoin-cli$i.log + mkdir -p /tmp/itsgoin-test$i + mkfifo /tmp/itsgoin-cmd$i +done + +export ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS=1 + +# Genesis one-shot on node1's data dir (debug-gate override). +"$BIN" /tmp/itsgoin-test1 --bind 127.0.0.1:18411 --publish-registry > /tmp/itsgoin-genesis.log 2>&1 +check "genesis prints registry post id matching shipped constant" \ + bash -c 'g=$(grep registry_post_id /tmp/itsgoin-genesis.log | awk "{print \$2}"); + c=$(grep shipped_constant /tmp/itsgoin-genesis.log | awk "{print \$2}"); + [ -n "$g" ] && [ "$g" = "$c" ]' + +# Start the three nodes with FIFO-driven stdin. +for i in 1 2 3; do + ( exec "$BIN" /tmp/itsgoin-test$i --bind 127.0.0.1:1841$i \ + < /tmp/itsgoin-cmd$i > /tmp/itsgoin-cli$i.log 2>&1 ) & +done +# Keep FIFO write ends open for the whole run. +exec 13>/tmp/itsgoin-cmd1 14>/tmp/itsgoin-cmd2 15>/tmp/itsgoin-cmd3 +sleep 6 + +n1_id=$(strip_ansi /tmp/itsgoin-cli1.log | grep -m1 "Node ID:" | awk '{print $3}') +n2_id=$(strip_ansi /tmp/itsgoin-cli2.log | grep -m1 "Node ID:" | awk '{print $3}') +echo "node1=$n1_id node2=$n2_id" +check "all nodes self-materialized the registry post" \ + bash -c 'for i in 1 2 3; do sqlite3 /tmp/itsgoin-test$i/itsgoin.db \ + "SELECT count(*) FROM posts WHERE hex(id) = upper(\"'"$(grep shipped_constant /tmp/itsgoin-genesis.log | awk '{print $2}')"'\")" \ + | grep -q 1 || exit 1; done' + +echo "== mesh: 2,3 -> 1; 3 -> 2 ==" +echo "connect $n1_id@127.0.0.1:18411" >&14 +echo "connect $n1_id@127.0.0.1:18411" >&15 +sleep 4 +echo "connect $n2_id@127.0.0.1:18412" >&15 +sleep 4 + +echo "== step 2: register / search / unregister ==" +echo "name Alice" >&14 +sleep 3 +echo "register Alice rust p2p" >&14 +sleep 3 +echo "search rust" >&15 +sleep 8 +check "node3 search finds Alice" grep -q "Alice" /tmp/itsgoin-cli3.log + +echo "unregister" >&14 +sleep 3 +echo "search rust" >&15 +sleep 8 +check "node3 re-search finds nothing after signed delete" \ + bash -c 'tail -20 /tmp/itsgoin-cli3.log | grep -q "no registry matches"' + +echo "== step 3: greeting roundtrip ==" +# node2's bio post id (latest Profile post) from its DB. +bio2=$(sq 2 'SELECT lower(hex(id)) FROM posts WHERE visibility_intent = '"'"'"Profile"'"'"' ORDER BY timestamp_ms DESC LIMIT 1') +check "node2 has a bio post with a greeting slot" \ + bash -c '[ -n "'"$bio2"'" ] && sqlite3 /tmp/itsgoin-test2/itsgoin.db \ + "SELECT fof_gating_json FROM posts WHERE lower(hex(id))=\"'"$bio2"'\"" | grep -q Greeting' + +echo "name Bob" >&15 +sleep 3 +echo "greet $bio2 hello alice from bob" >&15 +sleep 6 +echo "greetings" >&14 +sleep 4 +check "node2 unseals the greeting text" grep -q "hello alice from bob" /tmp/itsgoin-cli2.log + +echo "reply 0 hello back bob" >&14 +sleep 6 +echo "greetings" >&15 +sleep 4 +check "node3 unseals the reply via its stored fresh reply key" \ + grep -q "hello back bob" /tmp/itsgoin-cli3.log + +# Holder-side opacity: node1 never sees plaintext greeting bodies. +check "node1 stores only ciphertext (no greeting plaintext in db)" \ + bash -c '! sqlite3 /tmp/itsgoin-test1/itsgoin.db \ + "SELECT content FROM comments" | grep -q "hello alice"' + +echo "== step 5: newest-wins ==" +echo "register Alice rust p2p" >&14 +sleep 2 +echo "register AliceV2 rust mesh" >&14 +sleep 4 +echo "search rust" >&15 +sleep 8 +check "node3 sees exactly one (newest) entry for node2's persona" \ + bash -c 'tail -6 /tmp/itsgoin-cli3.log | grep -c "rust" | grep -q "^1$" && tail -6 /tmp/itsgoin-cli3.log | grep -q AliceV2' + +echo +echo "== results: $PASS passed, $FAIL failed ==" +[ "$FAIL" -eq 0 ]