feat: v0.8 Iteration A — ID-class fixes, manifest privacy, registry + greetings

A1 — ID-class bug family (v0.6.1 persona split left network-NodeId comparisons
where posting IDs live). ~14 sites fixed with multi-persona semantics ("author is
one of MY posting identities", not == default):
- revoke_post_access / revoke_circle_access (revocation was broken for every post)
- group key create/add-member/rotate (add-member distribution now works)
- circle profile set/delete/get, encrypted-attachment CEK unwrap
- receipt/comment slot authorship, replication cycle, blob eviction own-tier
- orphaned GroupKeyRequest/Response (0xA1/0xA2) deleted (no send side existed)

A2 — Manifest privacy: AuthorManifest.author_addresses removed from struct AND
signature digest; CdnManifest tree-era fields dropped. Startup migrations re-sign
own manifests, purge unverifiable foreign copies, strip persona fields from legacy
network-id profile rows. Posting identities are now location-anonymous.

A3 — Discovery & first contact:
- Open-slot comments: derivable slot V_x (blake3 from author + slot_binder_nonce)
  so strangers pass the CDN comment-verification gate; OpenSlotDecl in FoF gating
- Greetings: HPKE-style sealed GreetingBody (return_path + fresh reply_pubkey),
  throwaway outer ID, size-bucketed; messaging-first (Reply/Dismiss, no vouch)
- Registry: frozen canonical registry post + REGISTRY_POST_ID, signed registration
  comments (self-certifying deletes), newest-wins per persona, --publish-registry
- Comment TTL: expires_at_ms inside signed digest v2 (context bump), rand 30-365d
  ordinary / fixed 30d registrations, expiry sweep on the eviction cycle
- Single accept_incoming_comment() gate wired into all three ingest sites, closing
  a pre-existing hole where both pull paths stored comments with no verification
- UI: consent panel (visible/listed/greetings, active choice at first publish),
  registry search in Discover, sealed "Say hi" compose, greetings inbox

Security fixes from review: forged-tombstone injection, delete-by-sender-trust,
remote SetPolicy poisoning, stored XSS (escapeHtml quotes, 7 pre-existing sites),
greeting off-switch now revokes prior bios' slots.

No PoW (rejected: inverted cost). Session-relay gating untouched.
201 core tests pass; CLI + desktop build; 3-node integration green.

DEPLOY GATE: wire-incompatible with v0.7.3 — ALPN bump to itsgoin/4 lands in
Iteration B. Do not build/deploy/anchor-swap before that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
This commit is contained in:
Scott Reimers 2026-07-30 08:28:26 -04:00
parent 66c9851061
commit 17dbf076cb
19 changed files with 5237 additions and 524 deletions

View file

@ -27,6 +27,7 @@ async fn main() -> anyhow::Result<()> {
let mut web_port: Option<u16> = None;
let mut print_identity = false;
let mut announce = false;
let mut publish_registry = false;
let mut ann_category: Option<String> = None;
let mut ann_title: Option<String> = None;
let mut ann_body: Option<String> = 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 <display_name> Set your display name");
println!(" register <name> [keywords...] Register default persona in the network registry (30d, re-run to renew)");
println!(" unregister Remove your registry entry (signed delete)");
println!(" search <keywords> Search the registry (pulls chain, queries locally)");
println!(" greet <bio_post_id_hex> <text> Send a sealed greeting to a bio post's author");
println!(" greetings List unsealed greetings on your bio (with reply/dismiss index)");
println!(" reply <greeting_index> <text> Sealed reply to a greeting's return path (fresh reply key)");
println!(" dismiss <greeting_index> Dismiss a greeting (local only)");
println!(" greetings-open <on|off> 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 <n>` / `dismiss <n>` indices
// refer to the last `greetings` output.
let mut last_greetings: Vec<itsgoin_core::node::GreetingRecord> = 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<itsgoin_core::types::NodeId> = 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<String> = words.map(|w| w.to_string()).collect();
if name.is_empty() {
println!("Usage: register <name> [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 <name> [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 <bio_post_id_hex> <text>");
} 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 <bio_post_id_hex> <text>");
}
}
"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::<usize>().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 <greeting_index> <text>"),
}
} else {
println!("Usage: reply <greeting_index> <text>");
}
}
"dismiss" => {
match arg.and_then(|a| a.trim().parse::<usize>().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 <greeting_index>"),
}
}
"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 <on|off>",
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) {