From 3964e1e87def62641e65a6376845a33e10b6918f Mon Sep 17 00:00:00 2001 From: Ren Koya Date: Fri, 24 Jul 2026 10:11:39 +0900 Subject: [PATCH 01/23] =?UTF-8?q?feat(cli):=20add=20'buzz=20import=20slack?= =?UTF-8?q?'=20=E2=80=94=20Slack=20workspace=20history=20importer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Imports a standard Slack export (channels, messages, threads, reactions) into a Buzz community as backdated signed events, making Slack history part of the one searchable record. - Bot mode (default): CLI identity signs everything; original authors preserved via content prefix + import_author/import_ts provenance tags - Mapping mode (--mapping): per-user keys sign each user's own history; relay (9030) and channel (9000) membership added automatically - Flat Slack threads become NIP-10 replies resolved through a state-file ledger; re-runs resume idempotently from the same state file - mrkdwn converted to markdown (mentions, links, entities, bold); code blocks preserved; mentions stay plain text so backdated history cannot flood mention feeds - Design doc: docs/slack-import.md (identity modes, claim-mode future work, security model, limitations) Co-Authored-By: Claude Fable 5 Signed-off-by: Ren Koya --- crates/buzz-cli/src/commands/import.rs | 868 ++++++++++++++++++ crates/buzz-cli/src/commands/import/export.rs | 377 ++++++++ crates/buzz-cli/src/commands/import/mrkdwn.rs | 245 +++++ crates/buzz-cli/src/commands/import/state.rs | 126 +++ crates/buzz-cli/src/commands/mod.rs | 1 + crates/buzz-cli/src/lib.rs | 48 + docs/slack-import.md | 160 ++++ 7 files changed, 1825 insertions(+) create mode 100644 crates/buzz-cli/src/commands/import.rs create mode 100644 crates/buzz-cli/src/commands/import/export.rs create mode 100644 crates/buzz-cli/src/commands/import/mrkdwn.rs create mode 100644 crates/buzz-cli/src/commands/import/state.rs create mode 100644 docs/slack-import.md diff --git a/crates/buzz-cli/src/commands/import.rs b/crates/buzz-cli/src/commands/import.rs new file mode 100644 index 0000000000..1a07ed7551 --- /dev/null +++ b/crates/buzz-cli/src/commands/import.rs @@ -0,0 +1,868 @@ +//! `buzz import` — migrate history from external workspaces. +//! +//! v1 supports Slack workspace exports; see `docs/slack-import.md` for the +//! full design (identity modes, security model, limitations). + +mod export; +mod mrkdwn; +mod state; + +use std::collections::HashMap; +use std::path::PathBuf; + +use nostr::{EventBuilder, EventId, Keys, Kind, Tag, Timestamp}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::client::BuzzClient; +use crate::error::CliError; +use export::{ts_seconds, SlackChannel, SlackExport, SlackMessage}; +use state::{ChannelState, ImportState}; + +/// Abort after this many consecutive message-submit failures — a wall of +/// failures means the relay is down or rejecting everything, not a handful +/// of individually bad messages. +const MAX_CONSECUTIVE_FAILURES: usize = 5; + +/// Parameters for `buzz import slack`. +pub struct ImportSlackParams { + /// Unzipped Slack export directory. + pub export_dir: String, + /// Optional Slack-user-ID → private-key JSON file (mapping mode). + pub mapping: Option, + /// State file path override. + pub state: Option, + /// Optional comma-separated channel-name filter. + pub channels: Option, + /// Report the plan without writing anything. + pub dry_run: bool, + /// Skip reaction import. + pub skip_reactions: bool, + /// Skip kind 0 profile publishing for mapped users. + pub skip_profiles: bool, +} + +/// One entry in the `--mapping` file. +#[derive(Deserialize)] +struct MappingEntry { + private_key: String, +} + +#[derive(Default)] +struct Summary { + channels_created: u64, + messages_imported: u64, + reactions_imported: u64, + profiles_published: u64, + skipped: u64, + warnings: Vec, +} + +impl Summary { + fn warn(&mut self, msg: String) { + eprintln!("warning: {msg}"); + self.warnings.push(msg); + } +} + +pub async fn cmd_import_slack(client: &BuzzClient, p: ImportSlackParams) -> Result<(), CliError> { + let export_dir = PathBuf::from(&p.export_dir); + let export = SlackExport::load(&export_dir)?; + + let state_path = p + .state + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(|| export_dir.join("buzz-import-state.json")); + let mut st = ImportState::load(&state_path)?; + + // Slack user id → display name, for mrkdwn mention rewriting and + // author attribution. + let names: HashMap = export + .users + .iter() + .map(|(id, u)| (id.clone(), u.best_name().to_string())) + .collect(); + + // Mapping mode: one signing client per mapped user. The relay requires + // event.pubkey to match the NIP-98 HTTP signer, so each user's events + // must be submitted by a client holding that user's key. + let mut user_clients: HashMap = HashMap::new(); + if let Some(ref mapping_path) = p.mapping { + let raw = std::fs::read_to_string(mapping_path) + .map_err(|e| CliError::Usage(format!("cannot read --mapping {mapping_path}: {e}")))?; + let entries: HashMap = serde_json::from_str(&raw) + .map_err(|e| CliError::Usage(format!("cannot parse --mapping {mapping_path}: {e}")))?; + for (slack_id, entry) in entries { + let keys = Keys::parse(&entry.private_key).map_err(|e| { + CliError::Key(format!( + "invalid private key for {slack_id} in mapping: {e}" + )) + })?; + user_clients.insert( + slack_id, + BuzzClient::new(client.relay_url().to_string(), keys, None, None)?, + ); + } + } + + let channel_filter: Option> = p.channels.as_ref().map(|list| { + list.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }); + let selected: Vec<&SlackChannel> = export + .channels + .iter() + .filter(|c| { + channel_filter + .as_ref() + .is_none_or(|f| f.iter().any(|name| name == &c.name)) + }) + .collect(); + if selected.is_empty() { + return Err(CliError::Usage( + "no channels selected — check --channels against channels.json".into(), + )); + } + + if p.dry_run { + return dry_run_report(&export, &selected, &st, &user_clients); + } + + let mut summary = Summary::default(); + + // Relay membership for mapped users (best-effort: requires the CLI + // identity to be a community owner/admin; open relays may not enforce + // membership at all). + for (slack_id, user_client) in &user_clients { + let pk = user_client.keys().public_key().to_hex(); + if st.relay_members.contains(&pk) { + continue; + } + match add_relay_member(client, &pk).await { + Ok(()) => { + st.relay_members.insert(pk); + st.save(&state_path)?; + } + Err(e) => summary.warn(format!( + "relay add-member failed for {slack_id} ({pk}): {e} — posts by this user may be rejected" + )), + } + } + + // Profiles for mapped users. + if !p.skip_profiles { + for (slack_id, user_client) in &user_clients { + if st.profiles.contains(slack_id) { + continue; + } + let Some(user) = export.users.get(slack_id) else { + summary.warn(format!("mapping entry {slack_id} not found in users.json")); + continue; + }; + let builder = buzz_sdk::build_profile( + Some(user.best_name()), + Some(if user.name.is_empty() { + user.best_name() + } else { + &user.name + }), + user.profile.image_512.as_deref(), + None, + None, + ) + .map_err(|e| CliError::Other(format!("build_profile failed: {e}")))?; + match submit(user_client, builder).await { + Ok(_) => { + st.profiles.insert(slack_id.clone()); + st.save(&state_path)?; + summary.profiles_published += 1; + } + Err(e) => summary.warn(format!("profile publish failed for {slack_id}: {e}")), + } + } + } + + for channel in selected { + import_channel( + client, + &export, + channel, + &names, + &user_clients, + &mut st, + &state_path, + &mut summary, + p.skip_reactions, + ) + .await?; + } + + st.save(&state_path)?; + let output = serde_json::json!({ + "channels_created": summary.channels_created, + "messages_imported": summary.messages_imported, + "reactions_imported": summary.reactions_imported, + "profiles_published": summary.profiles_published, + "skipped": summary.skipped, + "warnings": summary.warnings, + "state_file": state_path.display().to_string(), + }); + println!( + "{}", + serde_json::to_string(&output) + .map_err(|e| CliError::Other(format!("summary serialization failed: {e}")))? + ); + Ok(()) +} + +fn dry_run_report( + export: &SlackExport, + selected: &[&SlackChannel], + st: &ImportState, + user_clients: &HashMap, +) -> Result<(), CliError> { + let mut channels_to_create = 0u64; + let mut messages = 0u64; + let mut reactions = 0u64; + let mut unmapped_authors: std::collections::HashSet = std::collections::HashSet::new(); + for channel in selected { + if !st.channels.contains_key(&channel.id) { + channels_to_create += 1; + } + for msg in export.channel_messages(&channel.name)? { + if st + .messages + .contains_key(&ImportState::message_key(&channel.id, &msg.ts)) + { + continue; + } + messages += 1; + reactions += msg + .reactions + .iter() + .map(|r| r.users.len() as u64) + .sum::(); + if let Some(author) = author_id(&msg) { + if !user_clients.contains_key(&author) { + unmapped_authors.insert(author); + } + } + } + } + let mut unmapped: Vec = unmapped_authors.into_iter().collect(); + unmapped.sort(); + let output = serde_json::json!({ + "dry_run": true, + "channels_selected": selected.len(), + "channels_to_create": channels_to_create, + "messages_to_import": messages, + "reactions_to_import": reactions, + "mapped_users": user_clients.len(), + "unmapped_authors": unmapped, + }); + println!( + "{}", + serde_json::to_string(&output) + .map_err(|e| CliError::Other(format!("summary serialization failed: {e}")))? + ); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn import_channel( + client: &BuzzClient, + export: &SlackExport, + channel: &SlackChannel, + names: &HashMap, + user_clients: &HashMap, + st: &mut ImportState, + state_path: &std::path::Path, + summary: &mut Summary, + skip_reactions: bool, +) -> Result<(), CliError> { + let messages = export.channel_messages(&channel.name)?; + eprintln!("importing #{} ({} messages)", channel.name, messages.len()); + + // Channel create + metadata (once). + let channel_uuid = match st.channels.get(&channel.id) { + Some(cs) => Uuid::parse_str(&cs.uuid) + .map_err(|e| CliError::Other(format!("state file holds invalid UUID: {e}")))?, + None => { + let uuid = Uuid::new_v4(); + let about = if channel.purpose.value.is_empty() { + None + } else { + Some(channel.purpose.value.as_str()) + }; + let builder = buzz_sdk::build_create_channel( + uuid, + &channel.name, + Some(buzz_sdk::Visibility::Open), + Some(buzz_sdk::ChannelKind::Stream), + about, + None, + ) + .map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?; + submit(client, builder).await.map_err(|e| { + CliError::Other(format!("channel create failed for #{}: {e}", channel.name)) + })?; + if !channel.topic.value.is_empty() { + let topic = buzz_sdk::build_set_topic(uuid, &channel.topic.value) + .map_err(|e| CliError::Other(format!("build_set_topic failed: {e}")))?; + if let Err(e) = submit(client, topic).await { + summary.warn(format!("topic set failed for #{}: {e}", channel.name)); + } + } + st.channels.insert( + channel.id.clone(), + ChannelState { + uuid: uuid.to_string(), + metadata_done: true, + }, + ); + st.save(state_path)?; + summary.channels_created += 1; + uuid + } + }; + + // Channel membership for mapped users who speak in this channel. + for msg in &messages { + let Some(author) = author_id(msg) else { + continue; + }; + let Some(user_client) = user_clients.get(&author) else { + continue; + }; + let pk = user_client.keys().public_key().to_hex(); + let member_key = format!("{}:{pk}", channel.id); + if st.channel_members.contains(&member_key) { + continue; + } + let builder = buzz_sdk::build_add_member(channel_uuid, &pk, None) + .map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?; + match submit(client, builder).await { + Ok(_) => { + st.channel_members.insert(member_key); + st.save(state_path)?; + } + Err(e) => summary.warn(format!( + "channel add-member failed for {author} in #{}: {e}", + channel.name + )), + } + } + + // Messages, oldest first; thread roots always precede replies. + let mut consecutive_failures = 0usize; + let mut imported_in_channel = 0u64; + for msg in &messages { + let key = ImportState::message_key(&channel.id, &msg.ts); + if st.messages.contains_key(&key) { + // Already imported — but a prior run may have stopped between + // the message and its reactions, so reactions still get their + // (state-deduped) pass below. + if !skip_reactions { + import_reactions( + client, + channel, + msg, + &key, + names, + user_clients, + st, + state_path, + summary, + ) + .await?; + } + continue; + } + + let author = author_id(msg); + let author_name = author_display(msg, names); + let user_client = author.as_ref().and_then(|a| user_clients.get(a)); + let bot_signed = user_client.is_none(); + let signer = user_client.unwrap_or(client); + + let mut content = mrkdwn::convert(&msg.text, names); + for file in &msg.files { + match file.link() { + Some(link) => { + content.push_str(&format!("\n📎 [{}]({link})", file.label())); + } + None => content.push_str(&format!("\n📎 {}", file.label())), + } + } + let content = content.trim().to_string(); + let content = if bot_signed { + format!("**{author_name}**: {content}") + } else { + content + }; + + // Slack threads are flat: thread_ts is the root, every reply is a + // direct reply to it. Roots resolved through the state ledger. + let thread_ref = match thread_root_key(channel, msg) { + Some(root_key) => match st.messages.get(&root_key) { + Some(root_hex) => { + let root = EventId::from_hex(root_hex).map_err(|e| { + CliError::Other(format!("state file holds invalid event id: {e}")) + })?; + Some(buzz_sdk::ThreadRef { + root_event_id: root, + parent_event_id: root, + }) + } + None => { + summary.warn(format!( + "thread root {root_key} not imported — posting {key} as top-level" + )); + None + } + }, + None => None, + }; + + let created_at = ts_seconds(&msg.ts)?; + let builder = match buzz_sdk::build_message( + channel_uuid, + &content, + thread_ref.as_ref(), + &[], + false, + &[], + ) { + Ok(b) => b, + Err(e) => { + summary.warn(format!("skipping {key}: {e}")); + summary.skipped += 1; + continue; + } + }; + let builder = builder + .custom_created_at(Timestamp::from(created_at)) + .tags(provenance_tags( + author.as_deref().unwrap_or("unknown"), + &author_name, + &msg.ts, + )?); + + match submit(signer, builder).await { + Ok(event_id) => { + consecutive_failures = 0; + st.messages.insert(key.clone(), event_id); + st.save(state_path)?; + summary.messages_imported += 1; + imported_in_channel += 1; + if imported_in_channel.is_multiple_of(50) { + eprintln!(" #{}: {imported_in_channel} imported", channel.name); + } + } + Err(e @ CliError::Auth(_)) => return Err(e), + Err(e) => { + consecutive_failures += 1; + summary.warn(format!("message {key} failed: {e}")); + summary.skipped += 1; + if consecutive_failures >= MAX_CONSECUTIVE_FAILURES { + st.save(state_path)?; + return Err(CliError::Other(format!( + "{MAX_CONSECUTIVE_FAILURES} consecutive submit failures — aborting; \ + re-run to resume from the state file" + ))); + } + continue; + } + } + + if skip_reactions { + continue; + } + import_reactions( + client, + channel, + msg, + &key, + names, + user_clients, + st, + state_path, + summary, + ) + .await?; + } + + // Mirror Slack's archived flag once the channel's history is in. + if channel.is_archived { + let builder = buzz_sdk::build_archive(channel_uuid) + .map_err(|e| CliError::Other(format!("build_archive failed: {e}")))?; + if let Err(e) = submit(client, builder).await { + summary.warn(format!("archive failed for #{}: {e}", channel.name)); + } + } + st.save(state_path)?; + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn import_reactions( + client: &BuzzClient, + channel: &SlackChannel, + msg: &SlackMessage, + message_key: &str, + names: &HashMap, + user_clients: &HashMap, + st: &mut ImportState, + state_path: &std::path::Path, + summary: &mut Summary, +) -> Result<(), CliError> { + if msg.reactions.is_empty() { + return Ok(()); + } + let Some(target_hex) = st.messages.get(message_key).cloned() else { + return Ok(()); + }; + let target = EventId::from_hex(&target_hex) + .map_err(|e| CliError::Other(format!("state file holds invalid event id: {e}")))?; + // Slack exports don't record reaction times; anchor just after the message. + let created_at = ts_seconds(&msg.ts)?.saturating_add(1); + + for reaction in &msg.reactions { + let emoji = emoji_for_shortcode(&reaction.name); + let mut bot_reacted = false; + for user in &reaction.users { + let signer = match user_clients.get(user) { + Some(c) => c, + None => { + // All unmapped reactors collapse into one bot-signed + // reaction per emoji — one key can't react twice. + if bot_reacted { + continue; + } + bot_reacted = true; + client + } + }; + let signer_pk = signer.keys().public_key().to_hex(); + let dedupe = format!("{message_key}:{emoji}:{signer_pk}"); + if st.reactions.contains(&dedupe) { + continue; + } + let builder = match buzz_sdk::build_reaction(target, &emoji) { + Ok(b) => b, + Err(e) => { + summary.warn(format!( + "reaction :{}: on {message_key}: {e}", + reaction.name + )); + continue; + } + }; + let reactor_name = names.get(user).map(String::as_str).unwrap_or(user.as_str()); + let builder = builder + .custom_created_at(Timestamp::from(created_at)) + .tags(provenance_tags(user, reactor_name, &msg.ts)?); + match submit(signer, builder).await { + Ok(_) => { + st.reactions.insert(dedupe); + st.save(state_path)?; + summary.reactions_imported += 1; + } + Err(e) => summary.warn(format!( + "reaction :{}: on {message_key} in #{} failed: {e}", + reaction.name, channel.name + )), + } + } + } + Ok(()) +} + +/// Sign with `client` and submit, returning the locally computed event id. +/// +/// A 2xx response with `accepted: false` whose message marks a duplicate is +/// success (idempotent re-run after state loss); any other rejection is an +/// error. +async fn submit(client: &BuzzClient, builder: EventBuilder) -> Result { + let event = client.sign_event(builder)?; + let event_id = event.id.to_hex(); + let resp = client.submit_event(event).await?; + let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap_or_default(); + let accepted = parsed + .get("accepted") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + if !accepted { + let message = parsed + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if !message.contains("duplicate") { + return Err(CliError::Other(format!( + "relay rejected event: {}", + if message.is_empty() { &resp } else { &message } + ))); + } + } + Ok(event_id) +} + +/// NIP-43 relay-admin add-member (kind 9030), signed by the CLI identity. +/// Requires community owner/admin; freshness-gated ±120s by the relay, so +/// the default (current) timestamp is correct here. +async fn add_relay_member(client: &BuzzClient, pubkey_hex: &str) -> Result<(), CliError> { + let tag = Tag::parse(["p", pubkey_hex]) + .map_err(|e| CliError::Other(format!("invalid p tag: {e}")))?; + let builder = EventBuilder::new(Kind::Custom(9030), "").tags([tag]); + submit(client, builder).await.map(|_| ()) +} + +/// Provenance tags carried by every imported event. +fn provenance_tags( + author_id: &str, + author_name: &str, + slack_ts: &str, +) -> Result, CliError> { + let mk = |parts: &[&str]| { + Tag::parse(parts.iter().copied()) + .map_err(|e| CliError::Other(format!("invalid provenance tag: {e}"))) + }; + Ok(vec![ + mk(&["import", "slack"])?, + mk(&["import_author", author_id, author_name])?, + mk(&["import_ts", slack_ts])?, + ]) +} + +/// The Slack-side author identifier of a message: user id, else bot id. +fn author_id(msg: &SlackMessage) -> Option { + msg.user + .clone() + .filter(|u| !u.is_empty()) + .or_else(|| msg.bot_id.clone().filter(|b| !b.is_empty())) +} + +/// Human-readable author name for prefixes and attribution tags. +fn author_display(msg: &SlackMessage, names: &HashMap) -> String { + if let Some(ref user) = msg.user { + if let Some(name) = names.get(user) { + return name.clone(); + } + } + if let Some(ref username) = msg.username { + if !username.is_empty() { + return username.clone(); + } + } + author_id(msg).unwrap_or_else(|| "unknown".to_string()) +} + +/// Map common Slack reaction shortcodes to Unicode; anything unknown keeps +/// the `:shortcode:` form (rendered when the custom emoji is registered). +/// Skin-tone suffixes (`::skin-tone-N`) are dropped. +fn emoji_for_shortcode(name: &str) -> String { + let base = name.split("::").next().unwrap_or(name); + let mapped = match base { + "+1" | "thumbsup" => "👍", + "-1" | "thumbsdown" => "👎", + "heart" => "❤️", + "joy" => "😂", + "smile" => "😄", + "grin" => "😁", + "laughing" => "😆", + "sweat_smile" => "😅", + "sob" => "😭", + "cry" => "😢", + "tada" => "🎉", + "eyes" => "👀", + "fire" => "🔥", + "rocket" => "🚀", + "pray" => "🙏", + "clap" => "👏", + "wave" => "👋", + "raised_hands" => "🙌", + "ok_hand" => "👌", + "muscle" => "💪", + "100" => "💯", + "thinking_face" => "🤔", + "white_check_mark" => "✅", + "heavy_check_mark" => "✔️", + "x" => "❌", + "heart_eyes" => "😍", + "sunglasses" => "😎", + "sparkles" => "✨", + "star" => "⭐", + "zap" => "⚡", + "warning" => "⚠️", + "question" => "❓", + "exclamation" => "❗", + "bulb" => "💡", + "memo" => "📝", + "bug" => "🐛", + "wink" => "😉", + "point_up" => "☝️", + "point_down" => "👇", + "seedling" => "🌱", + "bee" | "honeybee" => "🐝", + _ => return format!(":{base}:"), + }; + mapped.to_string() +} + +/// `Some(state key of the thread root)` when `msg` is a reply (its +/// `thread_ts` differs from its own `ts`). +fn thread_root_key(channel: &SlackChannel, msg: &SlackMessage) -> Option { + let root_ts = msg.thread_ts.as_deref()?; + if root_ts == msg.ts { + return None; + } + Some(ImportState::message_key(&channel.id, root_ts)) +} + +/// Dispatch for `buzz import`. +pub async fn dispatch(cmd: crate::ImportCmd, client: &BuzzClient) -> Result<(), CliError> { + match cmd { + crate::ImportCmd::Slack { + export_dir, + mapping, + state, + channels, + dry_run, + skip_reactions, + skip_profiles, + } => { + cmd_import_slack( + client, + ImportSlackParams { + export_dir, + mapping, + state, + channels, + dry_run, + skip_reactions, + skip_profiles, + }, + ) + .await + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn msg(json: &str) -> SlackMessage { + serde_json::from_str(json).expect("test message parses") + } + + #[test] + fn author_resolution() { + let user_msg = msg(r#"{"type":"message","user":"U1","text":"x","ts":"1.0"}"#); + assert_eq!(author_id(&user_msg).as_deref(), Some("U1")); + + let bot_msg = msg( + r#"{"type":"message","subtype":"bot_message","bot_id":"B9","username":"CI","text":"x","ts":"1.0"}"#, + ); + assert_eq!(author_id(&bot_msg).as_deref(), Some("B9")); + + let mut names = HashMap::new(); + names.insert("U1".to_string(), "alice".to_string()); + assert_eq!(author_display(&user_msg, &names), "alice"); + assert_eq!(author_display(&bot_msg, &names), "CI"); + } + + #[test] + fn thread_root_key_only_for_replies() { + let channel: SlackChannel = + serde_json::from_str(r#"{"id":"C1","name":"general"}"#).expect("channel parses"); + let root = msg(r#"{"type":"message","user":"U1","text":"x","ts":"5.0","thread_ts":"5.0"}"#); + assert_eq!(thread_root_key(&channel, &root), None); + let reply = + msg(r#"{"type":"message","user":"U1","text":"y","ts":"6.0","thread_ts":"5.0"}"#); + assert_eq!( + thread_root_key(&channel, &reply), + Some("C1:5.0".to_string()) + ); + let plain = msg(r#"{"type":"message","user":"U1","text":"z","ts":"7.0"}"#); + assert_eq!(thread_root_key(&channel, &plain), None); + } + + #[test] + fn emoji_mapping() { + assert_eq!(emoji_for_shortcode("+1"), "👍"); + assert_eq!(emoji_for_shortcode("thumbsup::skin-tone-3"), "👍"); + assert_eq!(emoji_for_shortcode("party_parrot"), ":party_parrot:"); + } + + #[tokio::test] + async fn dry_run_is_offline_and_reports_counts() { + let dir = std::env::temp_dir().join(format!("buzz-import-dryrun-{}", std::process::id())); + let general = dir.join("general"); + std::fs::create_dir_all(&general).expect("mkdir"); + std::fs::write( + dir.join("channels.json"), + r#"[{"id":"C1","name":"general"}]"#, + ) + .expect("write channels"); + std::fs::write(dir.join("users.json"), r#"[{"id":"U1","name":"alice"}]"#) + .expect("write users"); + std::fs::write( + general.join("2024-01-01.json"), + r#"[{"type":"message","user":"U1","text":"hello","ts":"100.0"}]"#, + ) + .expect("write day"); + + // Points at a port nothing listens on — dry run must never dial it. + let client = BuzzClient::new( + "http://127.0.0.1:1".to_string(), + Keys::generate(), + None, + None, + ) + .expect("client"); + cmd_import_slack( + &client, + ImportSlackParams { + export_dir: dir.display().to_string(), + mapping: None, + state: None, + channels: None, + dry_run: true, + skip_reactions: false, + skip_profiles: false, + }, + ) + .await + .expect("dry run succeeds offline"); + + // Dry run writes no state file. + assert!(!dir.join("buzz-import-state.json").exists()); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn provenance_tags_shape() { + let tags = provenance_tags("U1", "alice", "1.000200").expect("tags build"); + let flat: Vec> = tags + .iter() + .map(|t| t.as_slice().iter().map(|s| s.to_string()).collect()) + .collect(); + assert_eq!( + flat, + vec![ + vec!["import".to_string(), "slack".to_string()], + vec![ + "import_author".to_string(), + "U1".to_string(), + "alice".to_string() + ], + vec!["import_ts".to_string(), "1.000200".to_string()], + ] + ); + } +} diff --git a/crates/buzz-cli/src/commands/import/export.rs b/crates/buzz-cli/src/commands/import/export.rs new file mode 100644 index 0000000000..00d4da4f70 --- /dev/null +++ b/crates/buzz-cli/src/commands/import/export.rs @@ -0,0 +1,377 @@ +//! Slack workspace export parsing. +//! +//! A standard Slack export is a directory containing `channels.json`, +//! `users.json`, and one subdirectory per channel (named by channel name) +//! holding `YYYY-MM-DD.json` message arrays. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use crate::error::CliError; + +/// A user record from `users.json`. +#[derive(Debug, Clone, Deserialize)] +pub struct SlackUser { + /// Slack user ID (`U...`). + pub id: String, + /// Login-style short name. + #[serde(default)] + pub name: String, + /// Nested profile fields. + #[serde(default)] + pub profile: SlackUserProfile, +} + +/// The `profile` object nested in a user record. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct SlackUserProfile { + /// Preferred display name (may be empty). + #[serde(default)] + pub display_name: String, + /// Full real name (may be empty). + #[serde(default)] + pub real_name: String, + /// 512px avatar URL, when present. + #[serde(default)] + pub image_512: Option, +} + +impl SlackUser { + /// Best available human-readable name: display name, then real name, + /// then the login name, then the raw ID. + pub fn best_name(&self) -> &str { + if !self.profile.display_name.is_empty() { + &self.profile.display_name + } else if !self.profile.real_name.is_empty() { + &self.profile.real_name + } else if !self.name.is_empty() { + &self.name + } else { + &self.id + } + } +} + +/// A channel record from `channels.json`. +#[derive(Debug, Clone, Deserialize)] +pub struct SlackChannel { + /// Slack channel ID (`C...`). + pub id: String, + /// Channel name (also the export subdirectory name). + pub name: String, + /// Whether the channel is archived in Slack. + #[serde(default)] + pub is_archived: bool, + /// Channel topic. + #[serde(default)] + pub topic: SlackTopicLike, + /// Channel purpose (description). + #[serde(default)] + pub purpose: SlackTopicLike, +} + +/// Shared shape of Slack `topic` / `purpose` objects. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct SlackTopicLike { + /// The text value. + #[serde(default)] + pub value: String, +} + +/// One message from a per-day export file. +#[derive(Debug, Clone, Deserialize)] +pub struct SlackMessage { + /// Message type — importable messages have `"message"`. + #[serde(default, rename = "type")] + pub msg_type: String, + /// Slack subtype (`channel_join`, `bot_message`, ...); absent for + /// ordinary user messages. + #[serde(default)] + pub subtype: Option, + /// Author user ID (`U...`); absent for some bot messages. + #[serde(default)] + pub user: Option, + /// Author bot ID (`B...`) for bot messages. + #[serde(default)] + pub bot_id: Option, + /// Display username for bot messages. + #[serde(default)] + pub username: Option, + /// Message text in Slack mrkdwn. + #[serde(default)] + pub text: String, + /// Microsecond-precision timestamp string, e.g. `"1610000000.000200"`. + /// Unique per channel — Slack's message primary key. + pub ts: String, + /// Thread root `ts` when this message is part of a thread. + #[serde(default)] + pub thread_ts: Option, + /// Emoji reactions on this message. + #[serde(default)] + pub reactions: Vec, + /// Attached files. + #[serde(default)] + pub files: Vec, +} + +/// One emoji reaction group on a message. +#[derive(Debug, Clone, Deserialize)] +pub struct SlackReaction { + /// Emoji shortcode without colons (may carry `::skin-tone-N`). + pub name: String, + /// User IDs who reacted. + #[serde(default)] + pub users: Vec, +} + +/// One file attachment stub. +#[derive(Debug, Clone, Deserialize)] +pub struct SlackFile { + /// File name. + #[serde(default)] + pub name: Option, + /// Human title. + #[serde(default)] + pub title: Option, + /// Slack-hosted permalink (requires Slack auth to fetch). + #[serde(default)] + pub permalink: Option, + /// Private download URL (requires Slack auth to fetch). + #[serde(default)] + pub url_private: Option, +} + +impl SlackFile { + /// Best display label for the attachment. + pub fn label(&self) -> &str { + match (&self.name, &self.title) { + (Some(n), _) if !n.is_empty() => n, + (_, Some(t)) if !t.is_empty() => t, + _ => "attachment", + } + } + + /// Best link target, preferring the permalink. + pub fn link(&self) -> Option<&str> { + self.permalink + .as_deref() + .filter(|s| !s.is_empty()) + .or(self.url_private.as_deref().filter(|s| !s.is_empty())) + } +} + +/// A loaded Slack export: user/channel indexes plus the directory root for +/// lazy per-channel message reads. +pub struct SlackExport { + /// Users indexed by Slack user ID. + pub users: HashMap, + /// Channels in `channels.json` order. + pub channels: Vec, + root: PathBuf, +} + +impl SlackExport { + /// Load `channels.json` and `users.json` from an export directory. + pub fn load(dir: &Path) -> Result { + if !dir.is_dir() { + return Err(CliError::Usage(format!( + "--export-dir is not a directory: {}", + dir.display() + ))); + } + let channels: Vec = read_json(&dir.join("channels.json"))?; + let user_list: Vec = read_json(&dir.join("users.json"))?; + let users = user_list.into_iter().map(|u| (u.id.clone(), u)).collect(); + Ok(Self { + users, + channels, + root: dir.to_path_buf(), + }) + } + + /// Read every day file for a channel, keeping only importable messages, + /// deduplicated by `ts` and sorted chronologically. + /// + /// Returns `Ok(vec![])` with no error if the channel directory is + /// missing (an empty channel exports no directory). + pub fn channel_messages(&self, channel_name: &str) -> Result, CliError> { + let dir = self.root.join(channel_name); + if !dir.is_dir() { + return Ok(Vec::new()); + } + let mut day_files: Vec = std::fs::read_dir(&dir) + .map_err(|e| CliError::Other(format!("cannot read {}: {e}", dir.display())))? + .filter_map(|entry| entry.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|ext| ext == "json")) + .collect(); + day_files.sort(); + + let mut by_ts: HashMap = HashMap::new(); + for file in &day_files { + let messages: Vec = read_json(file)?; + for msg in messages { + if is_importable(&msg) { + by_ts.entry(msg.ts.clone()).or_insert(msg); + } + } + } + let mut messages: Vec = by_ts.into_values().collect(); + messages.sort_by(|a, b| { + ts_sort_key(&a.ts) + .partial_cmp(&ts_sort_key(&b.ts)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + Ok(messages) + } +} + +/// Whether a message carries content worth importing. System messages +/// (joins, renames, topic changes, ...) are excluded; the relay materializes +/// its own membership history. +pub fn is_importable(msg: &SlackMessage) -> bool { + if msg.msg_type != "message" { + return false; + } + let subtype_ok = matches!( + msg.subtype.as_deref(), + None | Some("thread_broadcast") + | Some("bot_message") + | Some("file_share") + | Some("me_message") + ); + subtype_ok && (!msg.text.is_empty() || !msg.files.is_empty()) +} + +/// Whole-second part of a Slack `ts` — becomes the Nostr `created_at`. +pub fn ts_seconds(ts: &str) -> Result { + ts.split('.') + .next() + .and_then(|s| s.parse().ok()) + .ok_or_else(|| CliError::Other(format!("malformed Slack ts: {ts:?}"))) +} + +/// Full-precision sort key for chronological ordering within a channel. +fn ts_sort_key(ts: &str) -> f64 { + ts.parse().unwrap_or(0.0) +} + +fn read_json(path: &Path) -> Result { + let raw = std::fs::read_to_string(path) + .map_err(|e| CliError::Usage(format!("cannot read {}: {e}", path.display())))?; + serde_json::from_str(&raw) + .map_err(|e| CliError::Usage(format!("cannot parse {}: {e}", path.display()))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn msg(json: &str) -> SlackMessage { + serde_json::from_str(json).expect("test message parses") + } + + #[test] + fn importable_filters_system_subtypes() { + assert!(is_importable(&msg( + r#"{"type":"message","user":"U1","text":"hi","ts":"1.000"}"# + ))); + assert!(is_importable(&msg( + r#"{"type":"message","subtype":"thread_broadcast","user":"U1","text":"hi","ts":"1.000"}"# + ))); + assert!(is_importable(&msg( + r#"{"type":"message","subtype":"bot_message","bot_id":"B1","text":"hi","ts":"1.000"}"# + ))); + assert!(!is_importable(&msg( + r#"{"type":"message","subtype":"channel_join","user":"U1","text":"<@U1> joined","ts":"1.000"}"# + ))); + // Empty text with no files carries nothing to import. + assert!(!is_importable(&msg( + r#"{"type":"message","user":"U1","text":"","ts":"1.000"}"# + ))); + // Empty text but a file attachment is importable. + assert!(is_importable(&msg( + r#"{"type":"message","user":"U1","text":"","ts":"1.000","files":[{"name":"a.png"}]}"# + ))); + } + + #[test] + fn ts_seconds_parses_whole_part() { + assert_eq!(ts_seconds("1610000000.000200").expect("parses"), 1610000000); + assert_eq!(ts_seconds("1610000000").expect("parses"), 1610000000); + assert!(ts_seconds("not-a-ts").is_err()); + } + + #[test] + fn user_best_name_falls_back() { + let user: SlackUser = serde_json::from_str( + r#"{"id":"U1","name":"alice","profile":{"display_name":"","real_name":"Alice A"}}"#, + ) + .expect("parses"); + assert_eq!(user.best_name(), "Alice A"); + let bare: SlackUser = serde_json::from_str(r#"{"id":"U2"}"#).expect("parses"); + assert_eq!(bare.best_name(), "U2"); + } + + #[test] + fn loads_fixture_export_directory() { + let dir = std::env::temp_dir().join(format!("buzz-slack-export-{}", std::process::id())); + let general = dir.join("general"); + std::fs::create_dir_all(&general).expect("mkdir"); + std::fs::write( + dir.join("channels.json"), + r#"[{"id":"C1","name":"general","is_archived":false, + "topic":{"value":"the topic"},"purpose":{"value":"the purpose"}}]"#, + ) + .expect("write channels"); + std::fs::write( + dir.join("users.json"), + r#"[{"id":"U1","name":"alice","profile":{"display_name":"Alice"}}]"#, + ) + .expect("write users"); + // Two day files, out of order on disk, with a system message, a + // duplicate ts, and a threaded reply. + std::fs::write( + general.join("2024-01-02.json"), + r#"[{"type":"message","user":"U1","text":"reply","ts":"200.000100","thread_ts":"100.000100"}, + {"type":"message","subtype":"channel_join","user":"U1","text":"joined","ts":"150.0"}]"#, + ) + .expect("write day 2"); + std::fs::write( + general.join("2024-01-01.json"), + r#"[{"type":"message","user":"U1","text":"root","ts":"100.000100","thread_ts":"100.000100", + "reactions":[{"name":"+1","users":["U1"]}]}, + {"type":"message","user":"U1","text":"dupe","ts":"100.000100"}]"#, + ) + .expect("write day 1"); + + let export = SlackExport::load(&dir).expect("load"); + assert_eq!(export.channels.len(), 1); + assert_eq!(export.users["U1"].best_name(), "Alice"); + + let messages = export.channel_messages("general").expect("messages"); + // join filtered, duplicate ts collapsed, sorted oldest-first + assert_eq!(messages.len(), 2); + assert_eq!(messages[0].ts, "100.000100"); + assert_eq!(messages[1].ts, "200.000100"); + assert_eq!(messages[0].reactions.len(), 1); + + // Missing channel directory is an empty channel, not an error. + let empty = export.channel_messages("nonexistent").expect("empty ok"); + assert!(empty.is_empty()); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn file_label_and_link() { + let f: SlackFile = + serde_json::from_str(r#"{"name":"a.png","permalink":"https://x/p"}"#).expect("parses"); + assert_eq!(f.label(), "a.png"); + assert_eq!(f.link(), Some("https://x/p")); + let empty: SlackFile = serde_json::from_str(r#"{}"#).expect("parses"); + assert_eq!(empty.label(), "attachment"); + assert_eq!(empty.link(), None); + } +} diff --git a/crates/buzz-cli/src/commands/import/mrkdwn.rs b/crates/buzz-cli/src/commands/import/mrkdwn.rs new file mode 100644 index 0000000000..da241d78f9 --- /dev/null +++ b/crates/buzz-cli/src/commands/import/mrkdwn.rs @@ -0,0 +1,245 @@ +//! Minimal Slack mrkdwn → markdown conversion. +//! +//! Code blocks (``` fenced) and inline code (single backtick) are preserved +//! verbatim. Outside code, Slack angle-bracket tokens are rewritten to +//! plain-text/markdown equivalents and HTML entities are unescaped. +//! +//! Mentions become plain `@Name` text on purpose — no `p` tags are emitted +//! anywhere in the importer, so backdated history cannot flood mention +//! feeds (see docs/slack-import.md). + +use std::collections::HashMap; + +/// Convert one Slack message body to markdown. +/// +/// `user_names` maps Slack user IDs to display names for `<@U...>` tokens. +pub fn convert(text: &str, user_names: &HashMap) -> String { + map_outside_code_blocks(text, |segment| { + map_outside_inline_code(segment, |plain| { + let replaced = convert_tokens(plain, user_names); + let unescaped = unescape_entities(&replaced); + convert_bold(&unescaped) + }) + }) +} + +/// Split on ``` fences; apply `f` to segments outside fences, keep fenced +/// segments (and the fences themselves) verbatim. +fn map_outside_code_blocks(text: &str, f: impl Fn(&str) -> String) -> String { + let mut out = String::with_capacity(text.len()); + for (i, segment) in text.split("```").enumerate() { + if i > 0 { + out.push_str("```"); + } + if i % 2 == 0 { + out.push_str(&f(segment)); + } else { + out.push_str(segment); + } + } + out +} + +/// Split on single backticks; apply `f` outside inline code spans. +fn map_outside_inline_code(text: &str, f: impl Fn(&str) -> String) -> String { + let mut out = String::with_capacity(text.len()); + for (i, segment) in text.split('`').enumerate() { + if i > 0 { + out.push('`'); + } + if i % 2 == 0 { + out.push_str(&f(segment)); + } else { + out.push_str(segment); + } + } + out +} + +/// Rewrite `<...>` tokens: user/channel mentions, specials, and links. +fn convert_tokens(text: &str, user_names: &HashMap) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find('<') { + out.push_str(&rest[..start]); + let after = &rest[start + 1..]; + match after.find('>') { + Some(end) => { + out.push_str(&convert_one_token(&after[..end], user_names)); + rest = &after[end + 1..]; + } + None => { + // Unclosed '<' — keep the rest verbatim. + out.push('<'); + rest = after; + } + } + } + out.push_str(rest); + out +} + +fn convert_one_token(inner: &str, user_names: &HashMap) -> String { + // `<@U123>` or `<@U123|fallback>` + if let Some(body) = inner.strip_prefix('@') { + let (id, fallback) = split_pipe(body); + let name = user_names + .get(id) + .map(String::as_str) + .or(fallback) + .unwrap_or(id); + return format!("@{name}"); + } + // `<#C123|name>` or `<#C123>` + if let Some(body) = inner.strip_prefix('#') { + let (id, label) = split_pipe(body); + return format!("#{}", label.unwrap_or(id)); + } + // ``, ``, ``, `` + if let Some(body) = inner.strip_prefix('!') { + let (id, label) = split_pipe(body); + return match id { + "here" | "channel" | "everyone" => format!("@{id}"), + _ => label + .map(str::to_string) + .unwrap_or_else(|| format!("@{id}")), + }; + } + // `` or `` + let (url, label) = split_pipe(inner); + match label { + Some(label) if !label.is_empty() => format!("[{label}]({url})"), + _ => url.to_string(), + } +} + +fn split_pipe(s: &str) -> (&str, Option<&str>) { + match s.split_once('|') { + Some((a, b)) => (a, Some(b)), + None => (s, None), + } +} + +/// Unescape the three entities Slack always escapes in message text. +fn unescape_entities(text: &str) -> String { + text.replace("<", "<") + .replace(">", ">") + .replace("&", "&") +} + +/// Convert Slack `*bold*` to markdown `**bold**`, conservatively: the pair +/// must sit on one line, open must be followed by non-space, close must be +/// preceded by non-space. Anything else is left untouched. +fn convert_bold(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for (i, line) in text.split('\n').enumerate() { + if i > 0 { + out.push('\n'); + } + out.push_str(&convert_bold_line(line)); + } + out +} + +fn convert_bold_line(line: &str) -> String { + let chars: Vec = line.chars().collect(); + let mut doubled: Vec = vec![false; chars.len()]; + let mut open: Option = None; + for (i, &c) in chars.iter().enumerate() { + if c != '*' { + continue; + } + match open { + None => { + let can_open = chars.get(i + 1).is_some_and(|&n| n != ' ' && n != '*') + && (i == 0 || chars[i - 1] != '*'); + if can_open { + open = Some(i); + } + } + Some(start) => { + let can_close = i > 0 && chars[i - 1] != ' ' && chars[i - 1] != '*'; + if can_close { + doubled[start] = true; + doubled[i] = true; + open = None; + } + } + } + } + let mut out = String::with_capacity(line.len() + 8); + for (i, &c) in chars.iter().enumerate() { + out.push(c); + if doubled[i] { + out.push('*'); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names() -> HashMap { + let mut m = HashMap::new(); + m.insert("U123".to_string(), "alice".to_string()); + m + } + + #[test] + fn converts_user_mentions() { + assert_eq!(convert("hi <@U123>!", &names()), "hi @alice!"); + // Unknown user falls back to the fallback label, then the raw id. + assert_eq!(convert("<@U999|bob>", &names()), "@bob"); + assert_eq!(convert("<@U999>", &names()), "@U999"); + } + + #[test] + fn converts_channels_and_specials() { + assert_eq!(convert("see <#C1|general>", &names()), "see #general"); + assert_eq!(convert(" heads up", &names()), "@here heads up"); + assert_eq!(convert("", &names()), "@channel"); + } + + #[test] + fn converts_links() { + assert_eq!( + convert("see ", &names()), + "see [the docs](https://a.io)" + ); + assert_eq!(convert("", &names()), "https://a.io"); + } + + #[test] + fn unescapes_entities() { + assert_eq!( + convert("a < b && c > d", &names()), + "a < b && c > d" + ); + } + + #[test] + fn converts_bold_conservatively() { + assert_eq!(convert("*bold* text", &names()), "**bold** text"); + assert_eq!(convert("2 * 3 * 4", &names()), "2 * 3 * 4"); + assert_eq!(convert("a *b\nc* d", &names()), "a *b\nc* d"); + } + + #[test] + fn preserves_code() { + assert_eq!( + convert("look ```<@U123> *x*``` done *y*", &names()), + "look ```<@U123> *x*``` done **y**" + ); + assert_eq!( + convert("run `cmd <@U123>` now", &names()), + "run `cmd <@U123>` now" + ); + } + + #[test] + fn keeps_unclosed_angle_verbatim() { + assert_eq!(convert("a < b", &names()), "a < b"); + } +} diff --git a/crates/buzz-cli/src/commands/import/state.rs b/crates/buzz-cli/src/commands/import/state.rs new file mode 100644 index 0000000000..0b3beda9a8 --- /dev/null +++ b/crates/buzz-cli/src/commands/import/state.rs @@ -0,0 +1,126 @@ +//! Import state file — idempotency and resume. +//! +//! The state file records every write the importer has completed, keyed by +//! Slack-side identifiers, so a re-run (after an interruption or on a +//! refreshed export) skips work already done. It also doubles as the +//! Slack-ts → Nostr-event-id ledger that thread replies are resolved from. + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::error::CliError; + +/// Per-channel state: the Buzz UUID minted for it and whether metadata +/// (create + topic/purpose) has been published. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelState { + /// Buzz channel UUID. + pub uuid: String, + /// Whether the create/topic/purpose events were accepted. + #[serde(default)] + pub metadata_done: bool, +} + +/// The whole state file. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct ImportState { + /// Slack channel ID → channel state. + #[serde(default)] + pub channels: HashMap, + /// `":"` → Nostr event ID (hex). + #[serde(default)] + pub messages: HashMap, + /// Reaction dedupe keys: `":::"`. + #[serde(default)] + pub reactions: HashSet, + /// Slack user IDs whose kind 0 profile has been published. + #[serde(default)] + pub profiles: HashSet, + /// Channel-membership keys already added: `":"`. + #[serde(default)] + pub channel_members: HashSet, + /// Pubkeys already added as relay members. + #[serde(default)] + pub relay_members: HashSet, +} + +impl ImportState { + /// Load state from `path`; a missing file yields empty state. + pub fn load(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(raw) => serde_json::from_str(&raw).map_err(|e| { + CliError::Usage(format!( + "state file {} is corrupt: {e} — move it aside to restart the import", + path.display() + )) + }), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(e) => Err(CliError::Other(format!( + "cannot read state file {}: {e}", + path.display() + ))), + } + } + + /// Persist state to `path` (write-temp-then-rename so an interrupted + /// save never truncates the previous state). + pub fn save(&self, path: &Path) -> Result<(), CliError> { + let raw = serde_json::to_string(self) + .map_err(|e| CliError::Other(format!("state serialization failed: {e}")))?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, raw) + .map_err(|e| CliError::Other(format!("cannot write {}: {e}", tmp.display())))?; + std::fs::rename(&tmp, path) + .map_err(|e| CliError::Other(format!("cannot rename state file into place: {e}")))?; + Ok(()) + } + + /// Ledger key for a message. + pub fn message_key(channel_id: &str, ts: &str) -> String { + format!("{channel_id}:{ts}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrips_through_disk() { + let dir = std::env::temp_dir().join(format!("buzz-import-state-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("mkdir"); + let path = dir.join("state.json"); + + let mut state = ImportState::default(); + state.channels.insert( + "C1".into(), + ChannelState { + uuid: "u-u-i-d".into(), + metadata_done: true, + }, + ); + state + .messages + .insert(ImportState::message_key("C1", "1.000"), "ff".repeat(32)); + state.reactions.insert("C1:1.000:👍:aa".into()); + state.save(&path).expect("save"); + + let loaded = ImportState::load(&path).expect("load"); + assert_eq!(loaded.channels["C1"].uuid, "u-u-i-d"); + assert!(loaded.channels["C1"].metadata_done); + assert_eq!(loaded.messages["C1:1.000"], "ff".repeat(32)); + assert!(loaded.reactions.contains("C1:1.000:👍:aa")); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn missing_file_is_empty_state() { + let path = std::path::PathBuf::from("/nonexistent/dir/state.json"); + let state = ImportState::load(&path).expect("missing file is fine"); + assert!(state.channels.is_empty()); + assert!(state.messages.is_empty()); + } +} diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 8691590636..c08493e39b 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod channels; pub mod dms; pub mod emoji; pub mod feed; +pub mod import; pub mod issues; pub mod mem; pub mod messages; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0f8caa416a..3367fba1e8 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -203,6 +203,9 @@ enum Cmd { /// Read the activity feed #[command(subcommand)] Feed(FeedCmd), + /// Import history from an external workspace (Slack export) + #[command(subcommand)] + Import(ImportCmd), /// Publish notes and manage the social graph (NIP-01/02) #[command(subcommand)] Social(SocialCmd), @@ -919,6 +922,47 @@ pub enum WorkflowsCmd { }, } +/// Subcommands for `buzz import`. +#[derive(Subcommand)] +pub enum ImportCmd { + /// Import a Slack workspace export directory (see docs/slack-import.md) + #[command(after_help = "Modes:\n \ +bot mode (default): everything is signed by the CLI identity; original \ +authors are preserved in content prefixes and import_author tags.\n \ +mapping mode (--mapping): a JSON file maps Slack user IDs to private keys \ +({\"U123\": {\"private_key\": \"nsec1...\"}}); each user's history is signed \ +with their own key. Requires the CLI identity to be a community owner/admin \ +so mapped users can be added as relay members.\n\n\ +Re-running resumes from the state file — completed writes are skipped.\n\n\ +Examples:\n \ +buzz import slack --export-dir ./export --dry-run\n \ +buzz import slack --export-dir ./export\n \ +buzz import slack --export-dir ./export --mapping keys.json --channels general,random")] + Slack { + /// Path to the unzipped Slack export directory + #[arg(long)] + export_dir: String, + /// JSON file mapping Slack user IDs to Nostr private keys (mapping mode) + #[arg(long)] + mapping: Option, + /// State file path (default: /buzz-import-state.json) + #[arg(long)] + state: Option, + /// Only import these channel names (comma-separated) + #[arg(long)] + channels: Option, + /// Parse and report what would be imported without writing + #[arg(long, default_value_t = false)] + dry_run: bool, + /// Skip importing reactions + #[arg(long, default_value_t = false)] + skip_reactions: bool, + /// Skip publishing kind 0 profiles for mapped users + #[arg(long, default_value_t = false)] + skip_profiles: bool, + }, +} + #[derive(Subcommand)] pub enum FeedCmd { /// Get recent activity feed entries @@ -1775,6 +1819,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Users(sub) => commands::users::dispatch(sub, &client, &cli.format).await, Cmd::Workflows(sub) => commands::workflows::dispatch(sub, &client).await, Cmd::Feed(sub) => commands::feed::dispatch(sub, &client, &cli.format).await, + Cmd::Import(sub) => commands::import::dispatch(sub, &client).await, Cmd::Social(sub) => commands::social::dispatch(sub, &client).await, Cmd::Notes(sub) => commands::notes::dispatch(sub, &client).await, Cmd::Repos(sub) => commands::repos::dispatch(sub, &client).await, @@ -1809,6 +1854,7 @@ mod tests { "dms", "emoji", "feed", + "import", "issues", "media", "mem", @@ -1928,6 +1974,7 @@ mod tests { vec!["approve", "create", "delete", "get", "list", "runs", "trigger", "update"] ); assert_eq!(names(&cmd, "feed"), vec!["get"]); + assert_eq!(names(&cmd, "import"), vec!["slack"]); assert_eq!( names(&cmd, "social"), vec![ @@ -1998,6 +2045,7 @@ mod tests { ("dms", 4), ("emoji", 5), ("feed", 1), + ("import", 1), ("issues", 4), ("media", 1), ("messages", 8), diff --git a/docs/slack-import.md b/docs/slack-import.md new file mode 100644 index 0000000000..fc0ac4d045 --- /dev/null +++ b/docs/slack-import.md @@ -0,0 +1,160 @@ +# Slack Import + +`buzz import slack` migrates a Slack workspace export into a Buzz community. +Buzz was built to reduce dependency on Slack; this tool is the on-ramp — it +carries a team's conversational history (channels, messages, threads, +reactions) onto a relay you own, so agents and people can search it as one +record from day one. + +```bash +buzz import slack --export-dir ./my-workspace-export # bot mode +buzz import slack --export-dir ./export --mapping keys.json # mapping mode +buzz import slack --export-dir ./export --dry-run # plan only +``` + +## What gets imported + +| Slack | Buzz | Notes | +|-------|------|-------| +| Channels (`channels.json`) | kind `9007` create + `9002` topic/purpose | UUID generated per channel, recorded in the state file | +| Messages (per-day JSON) | kind `9` stream message, `h`-tagged | `created_at` backdated to the original Slack `ts` | +| Threads (`thread_ts`) | NIP-10 `e` reply tags | Slack threads are flat; every reply is a direct reply to the root | +| Reactions | kind `7` | Common shortcodes mapped to Unicode, otherwise `:shortcode:` | +| Users (`users.json`) | kind `0` profiles | Mapping mode only — signed by each user's key | +| Files | Links appended to message content | Blobs are **not** downloaded/re-hosted (see Limitations) | +| Custom emoji | — | Use `scripts/grab-emoji.sh` (separate tool, needs a Slack API token) | + +Every imported event carries provenance tags: + +- `["import", "slack"]` — marks the event as imported +- `["import_author", "", ""]` — original author +- `["import_ts", ""]` — original microsecond-precision timestamp + (Nostr `created_at` is seconds, so this preserves sub-second ordering data) + +## Identity modes + +### Bot mode (default) + +Everything is signed by the CLI identity (`BUZZ_PRIVATE_KEY`). Message +content is prefixed with the original author's display name +(`**Alice**: …`) so history stays readable; machine-readable attribution +lives in the `import_author` tag. + +- Zero key custody — no keys are generated or distributed. +- History is attributed to the importer identity, not to individual people. + +### Mapping mode (`--mapping keys.json`) + +A JSON file maps Slack user IDs to Nostr private keys: + +```json +{ + "U01ABCDEF": { "private_key": "nsec1..." }, + "U02GHIJKL": { "private_key": "<64-char hex>" } +} +``` + +Messages and reactions from mapped users are signed with *their* keys, so +imported history is natively attributable — six months from now, "my +messages" really are that pubkey's messages. Unmapped users (departed +members, bots) fall back to bot-mode signing with the author-name prefix. + +Requirements and behavior: + +- The relay only accepts an event whose `pubkey` matches the NIP-98 HTTP + signer, so the importer submits each user's events through a client + authenticated as that user's key. +- Mapped users must be relay members before they can post. The importer + sends a NIP-43 relay-admin add-member (kind `9030`, requires the CLI + identity to be a community **owner or admin**) and a channel add-member + (kind `9000`) for every mapped user active in each imported channel. + Failures are surfaced as warnings (e.g. on open relays where membership + enforcement is off, or when the CLI identity lacks admin). +- A kind `0` profile (display name, avatar URL from `users.json`) is + published for each mapped user unless `--skip-profiles` is set. + +**Key custody warning:** whoever produces `keys.json` holds every mapped +user's private key until it is handed over. Generate keys on one machine, +deliver each `nsec` to its person over a secure channel — Buzz's NIP-AB +pairing (`buzz-pair-relay`) is designed for exactly this one-time key +transfer — and destroy the mapping file after import. Prefer generating the +mapping *with* each user present when the team is small. + +### Claim mode (future work) + +The zero-custody end state, not yet implemented: + +1. Each person onboards in Buzz normally (key generated on-device, never + leaves it). +2. The importer (as a Slack app) DMs each member a one-time claim token — + receiving the token proves control of the Slack account; signing the + claim proves control of the Buzz key. Neither email infrastructure nor + key distribution is required. +3. Each person runs `buzz import slack --claim ` against the + shared export, signing only their own messages locally. + +This needs a shared cross-run message-ID ledger (replies must reference +event IDs of messages signed by *other* users' claims) — the state-file +design below anticipates it, but multi-party coordination is out of scope +for v1. Fallback hierarchy for unclaimed users stays the same: bot-signed +with attribution tags. + +## Ordering, threads, idempotency + +- Channels are imported one at a time; messages within a channel are sorted + by Slack `ts`, so a thread root is always imported before its replies. +- A state file (default `/buzz-import-state.json`) records + `slack channel id → Buzz channel UUID` and + `":" → Nostr event id`. Re-running the import skips + everything already recorded — interrupted imports resume where they + stopped. The state file is saved incrementally during the run. +- Reply `e`-tags are resolved from the state map, never from the relay. + +## Text conversion (mrkdwn → markdown) + +Code blocks and inline code are preserved verbatim. Outside code: + +- `<@U123>` → `@DisplayName` (plain text — see mention note below) +- `<#C123|name>` → `#name` +- `` → `[label](url)`; `` → `url` +- `` / `` / `` → `@here` / `@channel` / `@everyone` +- `<` `>` `&` unescaped +- `*bold*` → `**bold**` (conservative, single-line, non-space-adjacent) + +**Mentions are intentionally not `p`-tagged.** A `p` tag on thousands of +backdated messages would flood mention feeds and notifications for everyone +who was ever @-mentioned in Slack. Imported mentions render as plain +`@Name` text. + +## Limitations (v1) + +- **Files are not re-hosted.** Slack file URLs (which require Slack auth) + are appended as links. A future `--download-files` could fetch blobs with + a Slack token and re-upload via Blossom, rewriting links. +- **DMs and private channels are not imported.** Standard Slack exports + only contain public channels; DM import also raises consent questions + that belong with the claim-mode design. +- **Reaction timestamps are synthetic** (message `ts + 1s`) — Slack exports + don't record when a reaction was added. +- **Sub-second ordering may flatten.** Two messages inside the same second + get the same `created_at`; original ordering is preserved in `import_ts`. +- **Edit history is not reconstructed** — the export contains only final + text. +- **Slack workflows are not translated** to `buzz-workflow` YAML. + +## CLI reference + +``` +buzz import slack + --export-dir unzipped Slack export directory (required) + --mapping Slack user id → private key JSON (mapping mode) + --state state file (default: /buzz-import-state.json) + --channels import only these channel names + --dry-run parse and report what would be imported; no writes + --skip-reactions do not import reactions + --skip-profiles do not publish kind 0 profiles for mapped users +``` + +Output follows CLI conventions: progress on stderr, a final JSON summary on +stdout (`channels_created`, `messages_imported`, `reactions_imported`, +`skipped`, `warnings`). From 93baceb73bd9a4a0e9acaee9d4d285ba4ad886ea Mon Sep 17 00:00:00 2001 From: Ren Koya Date: Fri, 24 Jul 2026 11:52:06 +0900 Subject: [PATCH 02/23] feat(relay): authorized-import exemption for backdated third-party events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit History imports (buzz import slack) replay events with their original timestamps and original authors' signatures. Two ingest checks blocked this: the ±15-minute created_at drift gate and the event.pubkey == authenticated-submitter requirement. Both now carry a scoped exemption: an event with an `import` provenance tag, submitted by an authenticated community owner/admin, may be backdated and third-party-signed — the Schnorr signature proves authorship, and the admin's auth is the authorization. No relay restart or config change needed. Defense-in-depth layers move in lockstep: - The DB commit-time floor guard (migration 0021) is disarmed transaction-locally for exactly these inserts (SET LOCAL). - The replica fence refuses to advance from any probe sample taken while a backfill insert is in flight or before the last one finished, so a floor-exempt commit can never be claimed covered by a stale handshake sample. Fence closes during backfill; reopens on the next fresh handshake. Single-instance deployments are unaffected. - BUZZ_MAX_PAST_DRIFT_SECS (default 900, unchanged) remains as a relay-wide fallback for operators who cannot grant the importer admin; the armed DB floor follows it at +60s. buzz import slack updates: - Mapping mode now signs each user's history locally and submits everything over the single admin connection — mapped keys never need to be live relay members, which also covers departed users. - 429 rate-limit responses are absorbed with exponential backoff (the relay's `retry in 0s` hint made the client's built-in retry spin). Live-tested end to end against a real 20-channel / 2,979-message / 3,346-reaction Slack workspace export on a local relay: full import, idempotent re-run (0 new events), mapped-user attribution, thread linkage, and 2023 timestamps verified via relay queries. Co-Authored-By: Claude Fable 5 Signed-off-by: Ren Koya --- .env.example | 4 + crates/buzz-cli/src/commands/import.rs | 148 ++++++++++++++--------- crates/buzz-db/src/event.rs | 29 +++++ crates/buzz-db/src/lib.rs | 74 +++++++++++- crates/buzz-db/src/replica_fence.rs | 88 ++++++++++++-- crates/buzz-db/src/thread.rs | 22 +++- crates/buzz-relay/src/config.rs | 17 +++ crates/buzz-relay/src/handlers/ingest.rs | 43 ++++++- crates/buzz-relay/src/main.rs | 3 + docs/slack-import.md | 57 +++++++-- 10 files changed, 395 insertions(+), 90 deletions(-) diff --git a/.env.example b/.env.example index db5a7ea25c..8a66cc214c 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,10 @@ RELAY_URL=ws://localhost:3000 # the web frontend at / for browser requests. Leave unset for local dev # (use `just web` for Vite HMR instead). # BUZZ_WEB_DIR=./web/dist +# Maximum accepted age of an event's created_at in seconds (default 900). +# Future drift stays fixed at +15 min. Raise temporarily to run a history +# import (e.g. `buzz import slack` — see docs/slack-import.md), then restore. +# BUZZ_MAX_PAST_DRIFT_SECS=900 # Shared Redis-backed admission limits. Defaults shown below; each value must # be a positive integer. diff --git a/crates/buzz-cli/src/commands/import.rs b/crates/buzz-cli/src/commands/import.rs index 1a07ed7551..d3ddf0266b 100644 --- a/crates/buzz-cli/src/commands/import.rs +++ b/crates/buzz-cli/src/commands/import.rs @@ -84,10 +84,13 @@ pub async fn cmd_import_slack(client: &BuzzClient, p: ImportSlackParams) -> Resu .map(|(id, u)| (id.clone(), u.best_name().to_string())) .collect(); - // Mapping mode: one signing client per mapped user. The relay requires - // event.pubkey to match the NIP-98 HTTP signer, so each user's events - // must be submitted by a client holding that user's key. - let mut user_clients: HashMap = HashMap::new(); + // Mapping mode: sign each mapped user's history with their own key + // locally; every event is submitted over the single CLI connection. The + // relay accepts third-party-signed events carrying `import` provenance + // tags when the submitter is a community owner/admin (the Schnorr + // signature proves authorship), so no per-user connection or relay + // membership is needed at import time. + let mut user_keys: HashMap = HashMap::new(); if let Some(ref mapping_path) = p.mapping { let raw = std::fs::read_to_string(mapping_path) .map_err(|e| CliError::Usage(format!("cannot read --mapping {mapping_path}: {e}")))?; @@ -99,10 +102,7 @@ pub async fn cmd_import_slack(client: &BuzzClient, p: ImportSlackParams) -> Resu "invalid private key for {slack_id} in mapping: {e}" )) })?; - user_clients.insert( - slack_id, - BuzzClient::new(client.relay_url().to_string(), keys, None, None)?, - ); + user_keys.insert(slack_id, keys); } } @@ -128,16 +128,15 @@ pub async fn cmd_import_slack(client: &BuzzClient, p: ImportSlackParams) -> Resu } if p.dry_run { - return dry_run_report(&export, &selected, &st, &user_clients); + return dry_run_report(&export, &selected, &st, &user_keys); } let mut summary = Summary::default(); - // Relay membership for mapped users (best-effort: requires the CLI - // identity to be a community owner/admin; open relays may not enforce - // membership at all). - for (slack_id, user_client) in &user_clients { - let pk = user_client.keys().public_key().to_hex(); + // Relay membership for mapped users (best-effort: lets them read once + // they log in with their key; posting during import does not need it). + for (slack_id, keys) in &user_keys { + let pk = keys.public_key().to_hex(); if st.relay_members.contains(&pk) { continue; } @@ -147,14 +146,16 @@ pub async fn cmd_import_slack(client: &BuzzClient, p: ImportSlackParams) -> Resu st.save(&state_path)?; } Err(e) => summary.warn(format!( - "relay add-member failed for {slack_id} ({pk}): {e} — posts by this user may be rejected" + "relay add-member failed for {slack_id} ({pk}): {e}" )), } } - // Profiles for mapped users. + // Profiles for mapped users — signed by the user's key, submitted over + // the CLI connection (import tags make the third-party signature + // acceptable to the relay). if !p.skip_profiles { - for (slack_id, user_client) in &user_clients { + for (slack_id, keys) in &user_keys { if st.profiles.contains(slack_id) { continue; } @@ -162,10 +163,11 @@ pub async fn cmd_import_slack(client: &BuzzClient, p: ImportSlackParams) -> Resu summary.warn(format!("mapping entry {slack_id} not found in users.json")); continue; }; + let name = user.best_name().to_string(); let builder = buzz_sdk::build_profile( - Some(user.best_name()), + Some(&name), Some(if user.name.is_empty() { - user.best_name() + &name } else { &user.name }), @@ -173,8 +175,9 @@ pub async fn cmd_import_slack(client: &BuzzClient, p: ImportSlackParams) -> Resu None, None, ) - .map_err(|e| CliError::Other(format!("build_profile failed: {e}")))?; - match submit(user_client, builder).await { + .map_err(|e| CliError::Other(format!("build_profile failed: {e}")))? + .tags(provenance_tags(slack_id, &name, "")?); + match submit_as(client, keys, builder).await { Ok(_) => { st.profiles.insert(slack_id.clone()); st.save(&state_path)?; @@ -191,7 +194,7 @@ pub async fn cmd_import_slack(client: &BuzzClient, p: ImportSlackParams) -> Resu &export, channel, &names, - &user_clients, + &user_keys, &mut st, &state_path, &mut summary, @@ -222,7 +225,7 @@ fn dry_run_report( export: &SlackExport, selected: &[&SlackChannel], st: &ImportState, - user_clients: &HashMap, + user_keys: &HashMap, ) -> Result<(), CliError> { let mut channels_to_create = 0u64; let mut messages = 0u64; @@ -246,7 +249,7 @@ fn dry_run_report( .map(|r| r.users.len() as u64) .sum::(); if let Some(author) = author_id(&msg) { - if !user_clients.contains_key(&author) { + if !user_keys.contains_key(&author) { unmapped_authors.insert(author); } } @@ -260,7 +263,7 @@ fn dry_run_report( "channels_to_create": channels_to_create, "messages_to_import": messages, "reactions_to_import": reactions, - "mapped_users": user_clients.len(), + "mapped_users": user_keys.len(), "unmapped_authors": unmapped, }); println!( @@ -277,7 +280,7 @@ async fn import_channel( export: &SlackExport, channel: &SlackChannel, names: &HashMap, - user_clients: &HashMap, + user_keys: &HashMap, st: &mut ImportState, state_path: &std::path::Path, summary: &mut Summary, @@ -334,10 +337,10 @@ async fn import_channel( let Some(author) = author_id(msg) else { continue; }; - let Some(user_client) = user_clients.get(&author) else { + let Some(keys) = user_keys.get(&author) else { continue; }; - let pk = user_client.keys().public_key().to_hex(); + let pk = keys.public_key().to_hex(); let member_key = format!("{}:{pk}", channel.id); if st.channel_members.contains(&member_key) { continue; @@ -367,15 +370,7 @@ async fn import_channel( // (state-deduped) pass below. if !skip_reactions { import_reactions( - client, - channel, - msg, - &key, - names, - user_clients, - st, - state_path, - summary, + client, channel, msg, &key, names, user_keys, st, state_path, summary, ) .await?; } @@ -384,9 +379,8 @@ async fn import_channel( let author = author_id(msg); let author_name = author_display(msg, names); - let user_client = author.as_ref().and_then(|a| user_clients.get(a)); - let bot_signed = user_client.is_none(); - let signer = user_client.unwrap_or(client); + let signing_keys = author.as_ref().and_then(|a| user_keys.get(a)); + let bot_signed = signing_keys.is_none(); let mut content = mrkdwn::convert(&msg.text, names); for file in &msg.files { @@ -451,7 +445,11 @@ async fn import_channel( &msg.ts, )?); - match submit(signer, builder).await { + let submitted = match signing_keys { + Some(keys) => submit_as(client, keys, builder).await, + None => submit(client, builder).await, + }; + match submitted { Ok(event_id) => { consecutive_failures = 0; st.messages.insert(key.clone(), event_id); @@ -482,15 +480,7 @@ async fn import_channel( continue; } import_reactions( - client, - channel, - msg, - &key, - names, - user_clients, - st, - state_path, - summary, + client, channel, msg, &key, names, user_keys, st, state_path, summary, ) .await?; } @@ -514,7 +504,7 @@ async fn import_reactions( msg: &SlackMessage, message_key: &str, names: &HashMap, - user_clients: &HashMap, + user_keys: &HashMap, st: &mut ImportState, state_path: &std::path::Path, summary: &mut Summary, @@ -534,8 +524,8 @@ async fn import_reactions( let emoji = emoji_for_shortcode(&reaction.name); let mut bot_reacted = false; for user in &reaction.users { - let signer = match user_clients.get(user) { - Some(c) => c, + let signing_keys = match user_keys.get(user) { + Some(keys) => Some(keys), None => { // All unmapped reactors collapse into one bot-signed // reaction per emoji — one key can't react twice. @@ -543,10 +533,12 @@ async fn import_reactions( continue; } bot_reacted = true; - client + None } }; - let signer_pk = signer.keys().public_key().to_hex(); + let signer_pk = signing_keys + .map(|k| k.public_key().to_hex()) + .unwrap_or_else(|| client.keys().public_key().to_hex()); let dedupe = format!("{message_key}:{emoji}:{signer_pk}"); if st.reactions.contains(&dedupe) { continue; @@ -565,7 +557,11 @@ async fn import_reactions( let builder = builder .custom_created_at(Timestamp::from(created_at)) .tags(provenance_tags(user, reactor_name, &msg.ts)?); - match submit(signer, builder).await { + let submitted = match signing_keys { + Some(keys) => submit_as(client, keys, builder).await, + None => submit(client, builder).await, + }; + match submitted { Ok(_) => { st.reactions.insert(dedupe); st.save(state_path)?; @@ -586,10 +582,48 @@ async fn import_reactions( /// A 2xx response with `accepted: false` whose message marks a duplicate is /// success (idempotent re-run after state loss); any other rejection is an /// error. +/// +/// Bulk imports run head-first into the relay's per-pubkey minute quotas, +/// and the relay's `retry in 0s` hint makes the client's built-in retry +/// spin uselessly — so 429s are absorbed here with a real backoff. The +/// signed event is resubmitted verbatim; a re-send that lands twice is a +/// relay-side duplicate, which the acceptance check below treats as +/// success. async fn submit(client: &BuzzClient, builder: EventBuilder) -> Result { let event = client.sign_event(builder)?; + submit_signed(client, event).await +} + +/// Sign with a mapped user's key and submit over the CLI connection. +/// +/// The relay accepts the author/submitter mismatch because imported events +/// carry `import` provenance tags and the CLI identity is a community +/// owner/admin — the event's own Schnorr signature proves authorship. +async fn submit_as( + client: &BuzzClient, + keys: &Keys, + builder: EventBuilder, +) -> Result { + let event = builder + .sign_with_keys(keys) + .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + submit_signed(client, event).await +} + +async fn submit_signed(client: &BuzzClient, event: nostr::Event) -> Result { let event_id = event.id.to_hex(); - let resp = client.submit_event(event).await?; + let mut backoff_secs = 1u64; + let resp = loop { + match client.submit_event(event.clone()).await { + Ok(resp) => break resp, + Err(CliError::Relay { status: 429, .. }) if backoff_secs <= 64 => { + eprintln!(" rate-limited — retrying in {backoff_secs}s"); + tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await; + backoff_secs *= 2; + } + Err(e) => return Err(e), + } + }; let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap_or_default(); let accepted = parsed .get("accepted") diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 5adcb05bdc..5cdd528327 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -1183,8 +1183,12 @@ pub async fn insert_event_with_thread_metadata( event: &Event, channel_id: Option, thread_meta: Option>, + backfill: bool, ) -> Result<(StoredEvent, bool)> { let mut tx = pool.begin().await?; + if backfill { + disarm_floor_guard_tx(&mut tx).await?; + } let result = insert_event_with_thread_metadata_tx(&mut tx, community_id, event, channel_id, thread_meta) .await?; @@ -1192,6 +1196,21 @@ pub async fn insert_event_with_thread_metadata( Ok(result) } +/// Disarm the commit-time `created_at` floor guard (migration 0021) for the +/// current transaction only. +/// +/// Used by authorized history imports: the guard's GUC is transaction-locally +/// cleared so a backdated row can commit. Callers MUST pair this with +/// [`crate::replica_fence::ReplicaFence::backfill_begin`]/`backfill_end` on +/// replica-enabled deployments — a floor-exempt commit is outside the fence +/// proof until the next fresh handshake. +async fn disarm_floor_guard_tx(tx: &mut sqlx::Transaction<'_, sqlx::Postgres>) -> Result<()> { + sqlx::query("SELECT set_config('buzz.created_at_floor', '', true)") + .execute(&mut **tx) + .await?; + Ok(()) +} + /// Atomically insert a kind:7 reaction event and its reaction row. /// /// Ordering is load-bearing: resolve target, upsert/reactivate the reaction row, @@ -1207,8 +1226,12 @@ pub async fn insert_reaction_event_with_thread_metadata( target_event_id: &[u8], actor_pubkey: &[u8], emoji: &str, + backfill: bool, ) -> Result { let mut tx = pool.begin().await?; + if backfill { + disarm_floor_guard_tx(&mut tx).await?; + } let target_row = sqlx::query( "SELECT created_at FROM events \ @@ -1858,6 +1881,7 @@ mod tests { target.id.as_bytes(), &actor_pubkey, "👍", + false, ) .await .expect("first reaction insert"); @@ -1878,6 +1902,7 @@ mod tests { target.id.as_bytes(), &actor_pubkey, "👍", + false, ) .await .expect("duplicate reaction insert"); @@ -1916,6 +1941,7 @@ mod tests { target.id.as_bytes(), &actor_pubkey, "👍", + false, ) .await .expect("cross-community reaction attempt"); @@ -1975,6 +2001,7 @@ mod tests { target.id.as_bytes(), &actor_pubkey, "👍", + false, ) .await .expect_err("ephemeral event insert must fail after reaction upsert attempt"); @@ -2024,6 +2051,7 @@ mod tests { target.id.as_bytes(), &actor_pubkey, "👍", + false, ) .await .expect("first reaction insert"), @@ -2049,6 +2077,7 @@ mod tests { target.id.as_bytes(), &actor_pubkey, "👍", + false, ) .await .expect("reactivate reaction"); diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index fdd72c3c32..6da50623cb 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -179,6 +179,10 @@ pub struct Db { /// route here (see [`Db::read`]); locks, transactions, and anything /// consistency-critical stays on `pool`. pub(crate) read_pool: Option, + /// The commit-time floor armed on the writer pool (from + /// [`DbConfig::created_at_floor_secs`]); the fence probe must subtract + /// the same value — the two uses must never diverge. + pub(crate) created_at_floor_secs: i64, /// Freshness fence gating cursor-page routing to the replica. /// /// Starts closed; a background probe ([`replica_fence::run_probe`]) @@ -238,6 +242,12 @@ pub struct DbConfig { pub max_lifetime_secs: u64, /// Seconds a connection may sit idle before being closed. pub idle_timeout_secs: u64, + /// Seconds of `created_at` history the commit-time floor guard tolerates + /// (migration 0021). Must exceed the relay's accepted past drift by + /// enough slack that a legitimately accepted event still commits within + /// the floor. Defaults to [`replica_fence::CREATED_AT_FLOOR_SECS`]; + /// raised together with `BUZZ_MAX_PAST_DRIFT_SECS` for history imports. + pub created_at_floor_secs: i64, } impl Default for DbConfig { @@ -253,6 +263,7 @@ impl Default for DbConfig { acquire_timeout_secs: 3, max_lifetime_secs: 1800, idle_timeout_secs: 600, + created_at_floor_secs: replica_fence::CREATED_AT_FLOOR_SECS, } } } @@ -367,6 +378,7 @@ impl Db { pool, max_connections: config.max_connections, read_pool, + created_at_floor_secs: config.created_at_floor_secs, fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), }) } @@ -385,11 +397,12 @@ impl Db { .max_lifetime(Duration::from_secs(config.max_lifetime_secs)) .idle_timeout(Duration::from_secs(config.idle_timeout_secs)); if arm_floor_guard { - options = options.after_connect(|conn, _meta| { + let floor_secs = config.created_at_floor_secs; + options = options.after_connect(move |conn, _meta| { Box::pin(async move { // `SET` cannot take bind parameters; `set_config` can. sqlx::query("SELECT set_config('buzz.created_at_floor', $1, false)") - .bind(replica_fence::CREATED_AT_FLOOR_SECS.to_string()) + .bind(floor_secs.to_string()) .execute(conn) .await?; Ok(()) @@ -405,6 +418,7 @@ impl Db { max_connections: pool.options().get_max_connections(), pool, read_pool: None, + created_at_floor_secs: replica_fence::CREATED_AT_FLOOR_SECS, fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), } } @@ -421,6 +435,7 @@ impl Db { max_connections: pool.options().get_max_connections(), pool, read_pool: Some(read_pool), + created_at_floor_secs: replica_fence::CREATED_AT_FLOOR_SECS, fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), } } @@ -451,11 +466,12 @@ impl Db { return Ok(false); }; replica_fence::verify_floor_guard_catalog(&self.pool).await?; - replica_fence::verify_floor_guard_behavior(&self.pool).await?; + replica_fence::verify_floor_guard_behavior(&self.pool, self.created_at_floor_secs).await?; tokio::spawn(replica_fence::run_probe( self.pool.clone(), read_pool.clone(), std::sync::Arc::clone(&self.fence), + self.created_at_floor_secs, )); Ok(true) } @@ -1383,14 +1399,46 @@ impl Db { channel_id: Option, thread_meta: Option>, ) -> Result<(StoredEvent, bool)> { + self.insert_event_with_thread_metadata_opts( + community_id, + event, + channel_id, + thread_meta, + false, + ) + .await + } + + /// [`Self::insert_event_with_thread_metadata`] with a `backfill` switch. + /// + /// `backfill: true` disarms the commit-time `created_at` floor guard for + /// this insert's transaction (authorized history imports) and brackets + /// the write with the replica fence's backfill guard so a floor-exempt + /// commit can never be claimed covered by a stale handshake sample. + pub async fn insert_event_with_thread_metadata_opts( + &self, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, + thread_meta: Option>, + backfill: bool, + ) -> Result<(StoredEvent, bool)> { + if backfill { + self.fence.backfill_begin(); + } let result = event::insert_event_with_thread_metadata( &self.pool, community_id, event, channel_id, thread_meta, + backfill, ) - .await?; + .await; + if backfill { + self.fence.backfill_end(); + } + let result = result?; if result.1 { if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); @@ -1400,6 +1448,10 @@ impl Db { } /// Atomically insert a kind:7 reaction event and its reaction row. + /// + /// `backfill: true` disarms the floor guard for this transaction and + /// brackets the write with the fence's backfill guard (see + /// [`Self::insert_event_with_thread_metadata_opts`]). #[allow(clippy::too_many_arguments)] pub async fn insert_reaction_event_with_thread_metadata( &self, @@ -1410,7 +1462,11 @@ impl Db { target_event_id: &[u8], actor_pubkey: &[u8], emoji: &str, + backfill: bool, ) -> Result { + if backfill { + self.fence.backfill_begin(); + } let outcome = event::insert_reaction_event_with_thread_metadata( &self.pool, community_id, @@ -1420,8 +1476,13 @@ impl Db { target_event_id, actor_pubkey, emoji, + backfill, ) - .await?; + .await; + if backfill { + self.fence.backfill_end(); + } + let outcome = outcome?; if let event::ReactionEventInsertOutcome::Inserted { was_inserted: true, .. } = &outcome @@ -5315,6 +5376,7 @@ mod tests { depth: 0, broadcast: true, }), + false, ) .await .expect("insert top-level event"); @@ -5347,6 +5409,7 @@ mod tests { depth: 1, broadcast: false, }), + false, ) .await .expect("insert reply"); @@ -5911,6 +5974,7 @@ mod tests { depth: 0, broadcast: true, }), + false, ) .await .expect_err("armed pool must reject below-floor thread-metadata inserts"); diff --git a/crates/buzz-db/src/replica_fence.rs b/crates/buzz-db/src/replica_fence.rs index 03bc1c77f0..04e49b7264 100644 --- a/crates/buzz-db/src/replica_fence.rs +++ b/crates/buzz-db/src/replica_fence.rs @@ -76,6 +76,15 @@ pub struct ReplicaFence { fence_micros: AtomicI64, /// Unix micros when the fence was last advanced (staleness check). updated_micros: AtomicI64, + /// Number of backfill (floor-exempt) inserts currently in flight. + /// + /// A backfill commit writes a row far below the armed floor, which the + /// handshake proof cannot account for — so while any backfill is in + /// flight, and for any probe sample taken before the last backfill + /// finished, advancing the fence is refused (see [`Self::backfill_begin`]). + backfill_inflight: AtomicI64, + /// Unix micros when the most recent backfill insert finished. + backfill_watermark_micros: AtomicI64, } impl ReplicaFence { @@ -84,6 +93,8 @@ impl ReplicaFence { Self { fence_micros: AtomicI64::new(CLOSED), updated_micros: AtomicI64::new(CLOSED), + backfill_inflight: AtomicI64::new(0), + backfill_watermark_micros: AtomicI64::new(CLOSED), } } @@ -92,6 +103,36 @@ impl ReplicaFence { self.fence_micros.store(CLOSED, Ordering::Relaxed); } + /// Mark a backfill (floor-exempt) insert as starting: closes the fence + /// and blocks advances until [`Self::backfill_end`] moves the watermark. + /// + /// Backfilled rows carry `created_at` far below the armed floor, so the + /// handshake's bucket argument no longer bounds them. Refusing to + /// advance from any sample taken while a backfill is in flight — or + /// taken before the last backfill finished — restores the proof: only a + /// sample whose WAL capture provably follows the backfill commit can + /// reopen the fence, and by then the commit is bucket (a). + pub fn backfill_begin(&self) { + self.backfill_inflight.fetch_add(1, Ordering::SeqCst); + self.close(); + } + + /// Mark a backfill insert as finished (whether it committed or failed). + pub fn backfill_end(&self) { + self.backfill_watermark_micros + .store(Utc::now().timestamp_micros(), Ordering::SeqCst); + self.backfill_inflight.fetch_sub(1, Ordering::SeqCst); + } + + /// Whether a probe sample taken at `sampled_at` may advance the fence. + fn sample_admissible(&self, sampled_at: DateTime) -> bool { + if self.backfill_inflight.load(Ordering::SeqCst) > 0 { + return false; + } + let watermark = self.backfill_watermark_micros.load(Ordering::SeqCst); + watermark == CLOSED || sampled_at.timestamp_micros() > watermark + } + fn advance(&self, fence: DateTime) { self.fence_micros .store(fence.timestamp_micros(), Ordering::Relaxed); @@ -186,7 +227,7 @@ pub async fn verify_floor_guard_catalog(pool: &PgPool) -> crate::Result<()> { /// pool; this proves the semantics the fence proof cites, inside one /// rolled-back transaction: /// -/// 1. the pool's session GUC equals [`CREATED_AT_FLOOR_SECS`] (arming); +/// 1. the pool's session GUC equals the configured `floor_secs` (arming); /// 2. an old channel-bearing INSERT raises `check_violation` (23514); /// 3. a fresh channel-bearing INSERT commits; /// 4. rewriting a fresh row's `created_at` below the floor raises; @@ -196,7 +237,7 @@ pub async fn verify_floor_guard_catalog(pool: &PgPool) -> crate::Result<()> { /// `SET CONSTRAINTS ALL IMMEDIATE` makes the deferred trigger fire per /// statement so each adversary is observable under a savepoint; deferral to /// COMMIT is separately pinned by the held-transaction fixture. -pub async fn verify_floor_guard_behavior(pool: &PgPool) -> crate::Result<()> { +pub async fn verify_floor_guard_behavior(pool: &PgPool, floor_secs: i64) -> crate::Result<()> { use crate::error::DbError; let expect_violation = |res: Result, @@ -224,9 +265,9 @@ pub async fn verify_floor_guard_behavior(pool: &PgPool) -> crate::Result<()> { "buzz.created_at_floor GUC not set on this pool: {e}" )) })?; - if armed != CREATED_AT_FLOOR_SECS.to_string() { + if armed != floor_secs.to_string() { return Err(DbError::InvalidData(format!( - "buzz.created_at_floor is '{armed}', expected '{CREATED_AT_FLOOR_SECS}': \ + "buzz.created_at_floor is '{armed}', expected '{floor_secs}': \ pool is not armed" ))); } @@ -258,7 +299,7 @@ pub async fn verify_floor_guard_behavior(pool: &PgPool) -> crate::Result<()> { .bind(vec![0u8; 64]) .bind(ch) }; - let old_age = CREATED_AT_FLOOR_SECS + 60; + let old_age = floor_secs + 60; // 2. Old channel-bearing insert must raise. sqlx::query("SAVEPOINT floor_probe") @@ -467,6 +508,7 @@ pub async fn probe_once( writer: &PgPool, replica: &PgPool, fence: &ReplicaFence, + floor_secs: i64, ) -> Result>, ProbeError> { let sample = sample_writer(writer).await?; if !replica_covers(replica, &sample.wal_lsn).await? { @@ -476,8 +518,14 @@ pub async fn probe_once( Some(oldest) => oldest.min(sample.sampled_at), None => sample.sampled_at, }; + // A sample taken during (or before the end of) a backfill insert cannot + // prove coverage of the backfilled rows — skip; the next interval + // samples fresh. + if !fence.sample_admissible(sample.sampled_at) { + return Ok(None); + } let new_fence = lower - - chrono::Duration::seconds(CREATED_AT_FLOOR_SECS) + - chrono::Duration::seconds(floor_secs) - chrono::Duration::seconds(FENCE_CLOCK_MARGIN_SECS); fence.advance(new_fence); Ok(Some(new_fence)) @@ -485,12 +533,12 @@ pub async fn probe_once( /// Background probe loop: sample every `PROBE_INTERVAL`, close the fence on /// any error. Runs for the life of the process. -pub async fn run_probe(writer: PgPool, replica: PgPool, fence: Arc) { +pub async fn run_probe(writer: PgPool, replica: PgPool, fence: Arc, floor_secs: i64) { let mut interval = tokio::time::interval(PROBE_INTERVAL); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { interval.tick().await; - match probe_once(&writer, &replica, &fence).await { + match probe_once(&writer, &replica, &fence, floor_secs).await { Ok(Some(_)) => {} Ok(None) => { // Replica behind the sample: leave the fence; staleness @@ -533,6 +581,28 @@ mod tests { assert!(!fence.covers(ts - chrono::Duration::days(365))); } + #[test] + fn backfill_blocks_stale_samples_from_advancing() { + let fence = ReplicaFence::new(); + + // No backfill yet: any sample is admissible. + let before = Utc::now(); + assert!(fence.sample_admissible(before)); + + // In-flight backfill: nothing is admissible and the fence closes. + fence.advance(before); + fence.backfill_begin(); + assert!(fence.verified_through().is_none(), "backfill closes fence"); + assert!(!fence.sample_admissible(Utc::now())); + + // Finished: samples taken at/before the watermark stay refused; + // fresh samples are admissible again. + fence.backfill_end(); + assert!(!fence.sample_admissible(before)); + let after = Utc::now() + chrono::Duration::seconds(1); + assert!(fence.sample_admissible(after)); + } + #[test] fn stale_fence_reads_as_closed() { let fence = ReplicaFence::new(); @@ -653,7 +723,7 @@ mod tests { fence.advance(Utc::now()); // pretend a previous handshake succeeded // Using the primary as its own "replica": replay LSN is NULL. - let err = probe_once(&pool, &pool, &fence) + let err = probe_once(&pool, &pool, &fence, CREATED_AT_FLOOR_SECS) .await .expect_err("NULL replay LSN must be an error"); assert!(matches!(err, ProbeError::ReplicaLsnUnavailable)); diff --git a/crates/buzz-db/src/thread.rs b/crates/buzz-db/src/thread.rs index 3f92212dd5..0f64db667f 100644 --- a/crates/buzz-db/src/thread.rs +++ b/crates/buzz-db/src/thread.rs @@ -959,6 +959,7 @@ mod tests { depth: 0, broadcast: true, }), + false, ) .await .expect("insert community A metadata"); @@ -978,6 +979,7 @@ mod tests { depth: 3, broadcast: false, }), + false, ) .await .expect("insert community B metadata"); @@ -1016,7 +1018,7 @@ mod tests { let root = make_stream_event(&author, "root"); let root_created_at = event_created_at(&root); - insert_event_with_thread_metadata(&pool, community, &root, Some(channel.id), None) + insert_event_with_thread_metadata(&pool, community, &root, Some(channel.id), None, false) .await .expect("insert root event"); @@ -1039,6 +1041,7 @@ mod tests { depth: 1, broadcast: false, }), + false, ) .await .expect("insert reply event and metadata"); @@ -1081,7 +1084,7 @@ mod tests { let root = make_stream_event(&author, "root"); let root_created_at = event_created_at(&root); - insert_event_with_thread_metadata(&pool, community, &root, Some(channel.id), None) + insert_event_with_thread_metadata(&pool, community, &root, Some(channel.id), None, false) .await .expect("insert root event"); @@ -1113,6 +1116,7 @@ mod tests { depth: 1, broadcast: false, }), + false, ) .await .expect("insert tied reply"); @@ -1196,7 +1200,7 @@ mod tests { // Root (no metadata row on first insert — a depth-0 message). let root = make_stream_event(&author, "root"); let root_created_at = event_created_at(&root); - insert_event_with_thread_metadata(&pool, community, &root, Some(channel.id), None) + insert_event_with_thread_metadata(&pool, community, &root, Some(channel.id), None, false) .await .expect("insert root event"); @@ -1219,6 +1223,7 @@ mod tests { depth: 1, broadcast: false, }), + false, ) .await .expect("insert depth-1 child"); @@ -1243,6 +1248,7 @@ mod tests { depth: 2, broadcast: false, }), + false, ) .await .expect("insert depth-2 grandchild"); @@ -1305,7 +1311,7 @@ mod tests { let child = make_stream_event(&author, "child"); let grandchild = make_stream_event(&author, "grandchild"); for ev in [&root, &child, &grandchild] { - insert_event_with_thread_metadata(&pool, community, ev, Some(channel.id), None) + insert_event_with_thread_metadata(&pool, community, ev, Some(channel.id), None, false) .await .expect("insert event"); } @@ -1363,7 +1369,7 @@ mod tests { let root = make_stream_event(&author, "root"); let root_created_at = event_created_at(&root); - insert_event_with_thread_metadata(&pool, community, &root, Some(channel.id), None) + insert_event_with_thread_metadata(&pool, community, &root, Some(channel.id), None, false) .await .expect("insert root event"); @@ -1387,6 +1393,7 @@ mod tests { depth: 1, broadcast: false, }), + false, ) .await .expect("insert good reply"); @@ -1409,6 +1416,7 @@ mod tests { depth: 1, broadcast: false, }), + false, ) .await .expect("insert bad reply"); @@ -1459,6 +1467,7 @@ mod tests { depth: 0, broadcast: true, }), + false, ) .await .expect("insert top-level event"); @@ -1489,6 +1498,7 @@ mod tests { depth: 1, broadcast, }), + false, ) .await .expect("insert reply event"); @@ -1521,7 +1531,7 @@ mod tests { // No thread metadata at all — the legacy-ingest shape. Top-level. let bare = make_stream_event(&author, "bare"); - insert_event_with_thread_metadata(&pool, community, &bare, Some(channel.id), None) + insert_event_with_thread_metadata(&pool, community, &bare, Some(channel.id), None, false) .await .expect("insert bare event"); diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 47030dcf3f..df0e39d804 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -99,6 +99,16 @@ pub struct Config { /// TCP port for the Prometheus metrics exporter (`GET /metrics`). pub metrics_port: u16, + /// Maximum accepted age of an event's `created_at` in seconds + /// (`BUZZ_MAX_PAST_DRIFT_SECS`, default 900 = 15 minutes). + /// + /// Future drift stays fixed at 15 minutes — a future timestamp is always + /// a clock error or a forgery. Past drift is operator-tunable so a + /// community owner can open a window for history imports (e.g. + /// `buzz import slack`, which replays messages with their original + /// timestamps), then restore the default once the import completes. + pub max_past_drift_secs: i64, + /// When true, NIP-42 pubkey-only authentication (no API token) is /// restricted to pubkeys in the `pubkey_allowlist` table. Users with valid /// API tokens bypass the allowlist entirely. @@ -616,6 +626,12 @@ impl Config { .and_then(|v| v.parse().ok()) .unwrap_or(9102); + let max_past_drift_secs = std::env::var("BUZZ_MAX_PAST_DRIFT_SECS") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| *v >= 0) + .unwrap_or(900); + let media = buzz_media::MediaConfig { s3_endpoint: std::env::var("BUZZ_S3_ENDPOINT") .unwrap_or_else(|_| "http://localhost:9000".to_string()), @@ -889,6 +905,7 @@ impl Config { uds_path, health_port, metrics_port, + max_past_drift_secs, pubkey_allowlist_enabled, require_relay_membership, huddle_audio_available, diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index ca529d1db6..dfa5d91b1e 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -304,6 +304,14 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result bool { + event + .tags + .iter() + .any(|tag| tag.as_slice().first().map(|s| s.as_str()) == Some("import")) +} + /// Extract a channel UUID from the `"h"` NIP-29 group tag. pub(crate) fn extract_channel_id(event: &Event) -> Option { for tag in event.tags.iter() { @@ -1477,10 +1485,35 @@ async fn ingest_event_inner( } let event = std::sync::Arc::try_unwrap(event).unwrap_or_else(|arc| (*arc).clone()); - const MAX_TIMESTAMP_DRIFT_SECS: i64 = 900; // ±15 minutes + // Authorized-import carve-out: an event carrying an `import` provenance + // tag, submitted by an authenticated community owner/admin, may (a) be + // backdated past the drift envelope and (b) be signed by a key other + // than the submitter — the Schnorr signature already proves authorship, + // and the admin's own auth answers "who is allowed to write history + // here". The trust model matches BUZZ_MAX_PAST_DRIFT_SECS (trust the + // operator), scoped per event instead of relay-wide, with no restart. + let import_exempt = has_import_tag(&event) + && matches!( + state + .db + .get_relay_member(tenant.community(), &auth.pubkey().to_hex()) + .await + .map_err(|e| IngestError::Internal(format!("error: {e}")))? + .as_ref() + .map(|m| m.role.as_str()), + Some("owner") | Some("admin") + ); + + // Future drift is a fixed bound — a future timestamp is always a clock + // error or a forgery. Past drift is operator-tunable + // (BUZZ_MAX_PAST_DRIFT_SECS, default 900) so history imports can replay + // events with their original timestamps. + const MAX_FUTURE_DRIFT_SECS: i64 = 900; // +15 minutes let now = chrono::Utc::now().timestamp(); let event_ts = event.created_at.as_secs() as i64; - if (event_ts - now).abs() > MAX_TIMESTAMP_DRIFT_SECS { + if event_ts - now > MAX_FUTURE_DRIFT_SECS + || (!import_exempt && now - event_ts > state.config.max_past_drift_secs) + { return Err(IngestError::Rejected( "invalid: event timestamp too far from server time".into(), )); @@ -1496,7 +1529,7 @@ async fn ingest_event_inner( } let is_gift_wrap = kind_u32 == KIND_GIFT_WRAP; - if event.pubkey != *auth.pubkey() && !is_gift_wrap { + if event.pubkey != *auth.pubkey() && !is_gift_wrap && !import_exempt { return Err(IngestError::AuthFailed( "invalid: event pubkey does not match authenticated identity".into(), )); @@ -2303,6 +2336,7 @@ async fn ingest_event_inner( &target_id, &actor_bytes, emoji, + import_exempt, ) .await .map_err(|e| IngestError::Internal(format!("error: {e}")))? @@ -2391,11 +2425,12 @@ async fn ingest_event_inner( let thread_params = thread_meta.as_ref().map(|m| m.as_params()); match state .db - .insert_event_with_thread_metadata( + .insert_event_with_thread_metadata_opts( tenant.community(), &event, channel_id, thread_params, + import_exempt, ) .await { diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 22101219eb..6876fa382f 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -146,6 +146,9 @@ async fn main() -> anyhow::Result<()> { let db_config = DbConfig { database_url: config.database_url.clone(), read_database_url: config.read_database_url.clone(), + // The DB commit-time floor guard must stay above the ingest past- + // drift envelope, with slack for validation/lock waits at commit. + created_at_floor_secs: config.max_past_drift_secs + 60, ..DbConfig::default() }; let db = Db::new(&db_config).await.map_err(|e| { diff --git a/docs/slack-import.md b/docs/slack-import.md index fc0ac4d045..27a62baa96 100644 --- a/docs/slack-import.md +++ b/docs/slack-import.md @@ -61,15 +61,15 @@ members, bots) fall back to bot-mode signing with the author-name prefix. Requirements and behavior: -- The relay only accepts an event whose `pubkey` matches the NIP-98 HTTP - signer, so the importer submits each user's events through a client - authenticated as that user's key. -- Mapped users must be relay members before they can post. The importer - sends a NIP-43 relay-admin add-member (kind `9030`, requires the CLI - identity to be a community **owner or admin**) and a channel add-member - (kind `9000`) for every mapped user active in each imported channel. - Failures are surfaced as warnings (e.g. on open relays where membership - enforcement is off, or when the CLI identity lacks admin). +- Every event is signed locally with the mapped user's key and submitted + over the single CLI connection. The relay accepts the author/submitter + mismatch because the events carry `import` provenance tags and the CLI + identity is a community owner/admin (see the exemption below) — the + event's own Schnorr signature proves authorship. Mapped keys never need + to be live relay members to import. +- The importer still best-effort registers mapped users as relay members + (kind `9030`) and channel members (kind `9000`) so their history is + readable to them the moment they log in with their key. - A kind `0` profile (display name, avatar URL from `users.json`) is published for each mapped user unless `--skip-profiles` is set. @@ -99,6 +99,45 @@ design below anticipates it, but multi-party coordination is out of scope for v1. Fallback hierarchy for unclaimed users stays the same: bot-signed with attribution tags. +## Relay requirements + +**The CLI identity must be a community owner or admin.** The relay +normally rejects events whose `created_at` is more than 15 minutes in the +past, and events whose author differs from the authenticated submitter. +Both checks carry an authorized-import exemption: an event with an +`import` provenance tag, submitted by an authenticated community +owner/admin, may be backdated and may be third-party-signed (its Schnorr +signature proves authorship). No relay restart or configuration change is +needed — the operator's own auth *is* the authorization, scoped per event. + +Under the hood the exemption also disarms the DB commit-time floor guard +(migration 0021) for exactly those inserts, and on read-replica +deployments it closes the replica fence until a fresh handshake provably +covers the backfilled rows — degraded read capacity during the import, +never missing rows. Single-instance deployments are unaffected. + +Two optional knobs for the import window: + +```bash +# Only if you cannot grant the importer admin: raise the past-drift window +# relay-wide instead (requires restart; the DB floor guard follows at +60s). +BUZZ_MAX_PAST_DRIFT_SECS=315360000 + +# Speed: bot mode signs thousands of events with one key and the default +# quota is 60 messages/minute. The importer backs off and retries on 429, +# so an import finishes at default limits — just slowly. +BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=100000 +BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=100000 +``` + +### Future: self-expiring import window + +A possible refinement — proposed, not implemented — is an admin-published +relay command ("open import window: max age N, expires in H hours"), +making the import authorization itself a signed, audit-logged, +self-expiring event, with optional scoping to specific author pubkeys. +The per-event admin exemption above covers the practical cases today. + ## Ordering, threads, idempotency - Channels are imported one at a time; messages within a channel are sorted From f93a4de4dcf6b7393b1941d415a5a693d10c90cd Mon Sep 17 00:00:00 2001 From: Ren Koya Date: Fri, 24 Jul 2026 13:23:46 +0900 Subject: [PATCH 03/23] test(desktop): serialize oversized-hint test on the rate-limit gate lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `relay::tests::oversized_hint_is_capped_in_relay_error_message_string` arms the process-wide rate-limit gate (via relay_error_message → activate_rate_limit) but did not hold TEST_SERIAL or clear the gate afterward. Its 300s expiry could bleed into a parallel relay_admission gate test, flaking hint_zero_uses_default with an observed 300.005s wait instead of the 10s default. Lock the shared TEST_SERIAL and reset the gate before and after, matching the relay_admission gate tests. Exposes relay_admission::tests as pub(crate) so the sibling module can share the one serial lock. Co-Authored-By: Claude Fable 5 Signed-off-by: Ren Koya --- desktop/src-tauri/src/relay.rs | 10 ++++++++++ desktop/src-tauri/src/relay_admission.rs | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 1c9ba0095a..b34e03f3dd 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -693,6 +693,14 @@ mod tests { use crate::relay_admission::MAX_HINT_SECONDS; use std::io::{Read as _, Write as _}; + // relay_error_message arms the process-wide rate-limit gate via + // activate_rate_limit — serialize with the relay_admission gate + // tests and clear the armed window afterwards, or this test's 300s + // expiry bleeds into whichever gate test runs next (flaky + // hint_zero_uses_default under parallel test threads). + let _serial = crate::relay_admission::tests::TEST_SERIAL.lock().await; + crate::relay_admission::reset_rate_limit_gate(); + // Use a std::net listener on a std::thread — the same pattern as the // relay_admission loopback tests. This avoids two races that cause CI // failures with tokio::net + into_std(): @@ -740,6 +748,8 @@ mod tests { !msg.contains(&oversized.to_string()), "raw oversized hint must not appear in the message string" ); + + crate::relay_admission::reset_rate_limit_gate(); } // ── effective_agent_relay_url: legacy pin ignored ───────────────────────── diff --git a/desktop/src-tauri/src/relay_admission.rs b/desktop/src-tauri/src/relay_admission.rs index 15222f8590..b77417892e 100644 --- a/desktop/src-tauri/src/relay_admission.rs +++ b/desktop/src-tauri/src/relay_admission.rs @@ -101,12 +101,14 @@ pub fn reset_rate_limit_gate() { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; // The gate is a process-wide static shared by every test in this binary, // so all gate tests serialize on one async lock to keep armed expiries - // from bleeding between parallel test threads. + // from bleeding between parallel test threads. `relay.rs`'s + // `oversized_hint_is_capped_*` test also arms the gate, so it locks this + // same serial (hence `pub(crate)`). pub(crate) static TEST_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); #[tokio::test(start_paused = true)] From 01531da8feb2153da3b2864fef066b070dee3f1c Mon Sep 17 00:00:00 2001 From: Ren Koya Date: Fri, 24 Jul 2026 13:51:10 +0900 Subject: [PATCH 04/23] refactor(cli): bundle slack-import session state into an Importer struct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the per-channel/per-reaction helpers' nine-argument signatures (and their two #[allow(clippy::too_many_arguments)]) into methods on an Importer that owns the state ledger and summary and borrows the export inputs. Extract submit_maybe_as, print_json, load_mapping, and select_channels to drop the duplicated signer-dispatch match, JSON-print map_err, and inline argument parsing. Behavior unchanged — same events, same state file, same summary; unit tests green. Co-Authored-By: Claude Fable 5 Signed-off-by: Ren Koya --- crates/buzz-cli/src/commands/import.rs | 817 +++++++++++++------------ 1 file changed, 438 insertions(+), 379 deletions(-) diff --git a/crates/buzz-cli/src/commands/import.rs b/crates/buzz-cli/src/commands/import.rs index d3ddf0266b..f936467be3 100644 --- a/crates/buzz-cli/src/commands/import.rs +++ b/crates/buzz-cli/src/commands/import.rs @@ -7,7 +7,7 @@ mod export; mod mrkdwn; mod state; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use nostr::{EventBuilder, EventId, Keys, Kind, Tag, Timestamp}; @@ -74,7 +74,7 @@ pub async fn cmd_import_slack(client: &BuzzClient, p: ImportSlackParams) -> Resu .as_ref() .map(PathBuf::from) .unwrap_or_else(|| export_dir.join("buzz-import-state.json")); - let mut st = ImportState::load(&state_path)?; + let state = ImportState::load(&state_path)?; // Slack user id → display name, for mrkdwn mention rewriting and // author attribution. @@ -90,23 +90,71 @@ pub async fn cmd_import_slack(client: &BuzzClient, p: ImportSlackParams) -> Resu // tags when the submitter is a community owner/admin (the Schnorr // signature proves authorship), so no per-user connection or relay // membership is needed at import time. - let mut user_keys: HashMap = HashMap::new(); - if let Some(ref mapping_path) = p.mapping { - let raw = std::fs::read_to_string(mapping_path) - .map_err(|e| CliError::Usage(format!("cannot read --mapping {mapping_path}: {e}")))?; - let entries: HashMap = serde_json::from_str(&raw) - .map_err(|e| CliError::Usage(format!("cannot parse --mapping {mapping_path}: {e}")))?; - for (slack_id, entry) in entries { + let user_keys = load_mapping(p.mapping.as_deref())?; + + let selected = select_channels(&export, p.channels.as_deref())?; + + if p.dry_run { + return dry_run_report(&export, &selected, &state, &user_keys); + } + + let mut importer = Importer { + client, + export: &export, + names: &names, + user_keys: &user_keys, + state, + state_path, + summary: Summary::default(), + skip_reactions: p.skip_reactions, + skip_profiles: p.skip_profiles, + }; + + // Relay membership for mapped users (best-effort: lets them read once + // they log in with their key; posting during import does not need it). + importer.add_relay_members().await?; + + // Profiles for mapped users — signed by the user's key, submitted over + // the CLI connection (import tags make the third-party signature + // acceptable to the relay). + importer.publish_profiles().await?; + + for channel in &selected { + importer.import_channel(channel).await?; + } + + importer.finish() +} + +/// Parse a `--mapping` file into Slack-user-ID → signing keys. +fn load_mapping(path: Option<&str>) -> Result, CliError> { + let Some(path) = path else { + return Ok(HashMap::new()); + }; + let raw = std::fs::read_to_string(path) + .map_err(|e| CliError::Usage(format!("cannot read --mapping {path}: {e}")))?; + let entries: HashMap = serde_json::from_str(&raw) + .map_err(|e| CliError::Usage(format!("cannot parse --mapping {path}: {e}")))?; + entries + .into_iter() + .map(|(slack_id, entry)| { let keys = Keys::parse(&entry.private_key).map_err(|e| { CliError::Key(format!( "invalid private key for {slack_id} in mapping: {e}" )) })?; - user_keys.insert(slack_id, keys); - } - } + Ok((slack_id, keys)) + }) + .collect() +} - let channel_filter: Option> = p.channels.as_ref().map(|list| { +/// Resolve the `--channels` filter against the export's channel list, +/// erroring if the filter selects nothing. +fn select_channels<'e>( + export: &'e SlackExport, + filter: Option<&str>, +) -> Result, CliError> { + let filter: Option> = filter.map(|list| { list.split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) @@ -116,7 +164,7 @@ pub async fn cmd_import_slack(client: &BuzzClient, p: ImportSlackParams) -> Resu .channels .iter() .filter(|c| { - channel_filter + filter .as_ref() .is_none_or(|f| f.iter().any(|name| name == &c.name)) }) @@ -126,41 +174,71 @@ pub async fn cmd_import_slack(client: &BuzzClient, p: ImportSlackParams) -> Resu "no channels selected — check --channels against channels.json".into(), )); } + Ok(selected) +} - if p.dry_run { - return dry_run_report(&export, &selected, &st, &user_keys); - } +/// A single `buzz import slack` run: the borrowed export/index inputs plus +/// the mutable state ledger and summary threaded through every write. Bundled +/// here so the per-channel/per-reaction helpers stay methods rather than +/// ten-argument free functions. +struct Importer<'a> { + client: &'a BuzzClient, + export: &'a SlackExport, + /// Slack user id → display name. + names: &'a HashMap, + /// Slack user id → signing key (mapping mode); empty in bot mode. + user_keys: &'a HashMap, + state: ImportState, + state_path: PathBuf, + summary: Summary, + skip_reactions: bool, + skip_profiles: bool, +} - let mut summary = Summary::default(); +impl Importer<'_> { + /// Persist the running state ledger. + fn save(&self) -> Result<(), CliError> { + self.state.save(&self.state_path) + } - // Relay membership for mapped users (best-effort: lets them read once - // they log in with their key; posting during import does not need it). - for (slack_id, keys) in &user_keys { - let pk = keys.public_key().to_hex(); - if st.relay_members.contains(&pk) { - continue; - } - match add_relay_member(client, &pk).await { - Ok(()) => { - st.relay_members.insert(pk); - st.save(&state_path)?; + /// Add every mapped user as a relay member (best-effort — a failure + /// warns and moves on). + async fn add_relay_members(&mut self) -> Result<(), CliError> { + let client = self.client; + let user_keys = self.user_keys; + for (slack_id, keys) in user_keys { + let pk = keys.public_key().to_hex(); + if self.state.relay_members.contains(&pk) { + continue; + } + match add_relay_member(client, &pk).await { + Ok(()) => { + self.state.relay_members.insert(pk); + self.save()?; + } + Err(e) => self.summary.warn(format!( + "relay add-member failed for {slack_id} ({pk}): {e}" + )), } - Err(e) => summary.warn(format!( - "relay add-member failed for {slack_id} ({pk}): {e}" - )), } + Ok(()) } - // Profiles for mapped users — signed by the user's key, submitted over - // the CLI connection (import tags make the third-party signature - // acceptable to the relay). - if !p.skip_profiles { - for (slack_id, keys) in &user_keys { - if st.profiles.contains(slack_id) { + /// Publish a kind 0 profile for each mapped user, signed by their key. + async fn publish_profiles(&mut self) -> Result<(), CliError> { + if self.skip_profiles { + return Ok(()); + } + let client = self.client; + let export = self.export; + let user_keys = self.user_keys; + for (slack_id, keys) in user_keys { + if self.state.profiles.contains(slack_id) { continue; } let Some(user) = export.users.get(slack_id) else { - summary.warn(format!("mapping entry {slack_id} not found in users.json")); + self.summary + .warn(format!("mapping entry {slack_id} not found in users.json")); continue; }; let name = user.best_name().to_string(); @@ -179,46 +257,314 @@ pub async fn cmd_import_slack(client: &BuzzClient, p: ImportSlackParams) -> Resu .tags(provenance_tags(slack_id, &name, "")?); match submit_as(client, keys, builder).await { Ok(_) => { - st.profiles.insert(slack_id.clone()); - st.save(&state_path)?; - summary.profiles_published += 1; + self.state.profiles.insert(slack_id.clone()); + self.save()?; + self.summary.profiles_published += 1; } - Err(e) => summary.warn(format!("profile publish failed for {slack_id}: {e}")), + Err(e) => self + .summary + .warn(format!("profile publish failed for {slack_id}: {e}")), } } + Ok(()) } - for channel in selected { - import_channel( - client, - &export, - channel, - &names, - &user_keys, - &mut st, - &state_path, - &mut summary, - p.skip_reactions, - ) - .await?; + async fn import_channel(&mut self, channel: &SlackChannel) -> Result<(), CliError> { + let client = self.client; + let export = self.export; + let names = self.names; + let user_keys = self.user_keys; + + let messages = export.channel_messages(&channel.name)?; + eprintln!("importing #{} ({} messages)", channel.name, messages.len()); + + // Channel create + metadata (once). + let channel_uuid = match self.state.channels.get(&channel.id) { + Some(cs) => Uuid::parse_str(&cs.uuid) + .map_err(|e| CliError::Other(format!("state file holds invalid UUID: {e}")))?, + None => { + let uuid = Uuid::new_v4(); + let about = if channel.purpose.value.is_empty() { + None + } else { + Some(channel.purpose.value.as_str()) + }; + let builder = buzz_sdk::build_create_channel( + uuid, + &channel.name, + Some(buzz_sdk::Visibility::Open), + Some(buzz_sdk::ChannelKind::Stream), + about, + None, + ) + .map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?; + submit(client, builder).await.map_err(|e| { + CliError::Other(format!("channel create failed for #{}: {e}", channel.name)) + })?; + if !channel.topic.value.is_empty() { + let topic = buzz_sdk::build_set_topic(uuid, &channel.topic.value) + .map_err(|e| CliError::Other(format!("build_set_topic failed: {e}")))?; + if let Err(e) = submit(client, topic).await { + self.summary + .warn(format!("topic set failed for #{}: {e}", channel.name)); + } + } + self.state.channels.insert( + channel.id.clone(), + ChannelState { + uuid: uuid.to_string(), + metadata_done: true, + }, + ); + self.save()?; + self.summary.channels_created += 1; + uuid + } + }; + + // Channel membership for mapped users who speak in this channel. + for msg in &messages { + let Some(author) = author_id(msg) else { + continue; + }; + let Some(keys) = user_keys.get(&author) else { + continue; + }; + let pk = keys.public_key().to_hex(); + let member_key = format!("{}:{pk}", channel.id); + if self.state.channel_members.contains(&member_key) { + continue; + } + let builder = buzz_sdk::build_add_member(channel_uuid, &pk, None) + .map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?; + match submit(client, builder).await { + Ok(_) => { + self.state.channel_members.insert(member_key); + self.save()?; + } + Err(e) => self.summary.warn(format!( + "channel add-member failed for {author} in #{}: {e}", + channel.name + )), + } + } + + // Messages, oldest first; thread roots always precede replies. + let mut consecutive_failures = 0usize; + let mut imported_in_channel = 0u64; + for msg in &messages { + let key = ImportState::message_key(&channel.id, &msg.ts); + if self.state.messages.contains_key(&key) { + // Already imported — but a prior run may have stopped between + // the message and its reactions, so reactions still get their + // (state-deduped) pass below. + if !self.skip_reactions { + self.import_reactions(channel, msg, &key).await?; + } + continue; + } + + let author = author_id(msg); + let author_name = author_display(msg, names); + let signing_keys = author.as_ref().and_then(|a| user_keys.get(a)); + let bot_signed = signing_keys.is_none(); + + let mut content = mrkdwn::convert(&msg.text, names); + for file in &msg.files { + match file.link() { + Some(link) => content.push_str(&format!("\n📎 [{}]({link})", file.label())), + None => content.push_str(&format!("\n📎 {}", file.label())), + } + } + let content = content.trim().to_string(); + let content = if bot_signed { + format!("**{author_name}**: {content}") + } else { + content + }; + + // Slack threads are flat: thread_ts is the root, every reply is a + // direct reply to it. Roots resolved through the state ledger. + let thread_ref = match thread_root_key(channel, msg) { + Some(root_key) => match self.state.messages.get(&root_key) { + Some(root_hex) => { + let root = EventId::from_hex(root_hex).map_err(|e| { + CliError::Other(format!("state file holds invalid event id: {e}")) + })?; + Some(buzz_sdk::ThreadRef { + root_event_id: root, + parent_event_id: root, + }) + } + None => { + self.summary.warn(format!( + "thread root {root_key} not imported — posting {key} as top-level" + )); + None + } + }, + None => None, + }; + + let created_at = ts_seconds(&msg.ts)?; + let builder = match buzz_sdk::build_message( + channel_uuid, + &content, + thread_ref.as_ref(), + &[], + false, + &[], + ) { + Ok(b) => b, + Err(e) => { + self.summary.warn(format!("skipping {key}: {e}")); + self.summary.skipped += 1; + continue; + } + }; + let builder = + builder + .custom_created_at(Timestamp::from(created_at)) + .tags(provenance_tags( + author.as_deref().unwrap_or("unknown"), + &author_name, + &msg.ts, + )?); + + match submit_maybe_as(client, signing_keys, builder).await { + Ok(event_id) => { + consecutive_failures = 0; + self.state.messages.insert(key.clone(), event_id); + self.save()?; + self.summary.messages_imported += 1; + imported_in_channel += 1; + if imported_in_channel.is_multiple_of(50) { + eprintln!(" #{}: {imported_in_channel} imported", channel.name); + } + } + Err(e @ CliError::Auth(_)) => return Err(e), + Err(e) => { + consecutive_failures += 1; + self.summary.warn(format!("message {key} failed: {e}")); + self.summary.skipped += 1; + if consecutive_failures >= MAX_CONSECUTIVE_FAILURES { + self.save()?; + return Err(CliError::Other(format!( + "{MAX_CONSECUTIVE_FAILURES} consecutive submit failures — aborting; \ + re-run to resume from the state file" + ))); + } + continue; + } + } + + if self.skip_reactions { + continue; + } + self.import_reactions(channel, msg, &key).await?; + } + + // Mirror Slack's archived flag once the channel's history is in. + if channel.is_archived { + let builder = buzz_sdk::build_archive(channel_uuid) + .map_err(|e| CliError::Other(format!("build_archive failed: {e}")))?; + if let Err(e) = submit(client, builder).await { + self.summary + .warn(format!("archive failed for #{}: {e}", channel.name)); + } + } + self.save()?; + Ok(()) } - st.save(&state_path)?; - let output = serde_json::json!({ - "channels_created": summary.channels_created, - "messages_imported": summary.messages_imported, - "reactions_imported": summary.reactions_imported, - "profiles_published": summary.profiles_published, - "skipped": summary.skipped, - "warnings": summary.warnings, - "state_file": state_path.display().to_string(), - }); - println!( - "{}", - serde_json::to_string(&output) - .map_err(|e| CliError::Other(format!("summary serialization failed: {e}")))? - ); - Ok(()) + async fn import_reactions( + &mut self, + channel: &SlackChannel, + msg: &SlackMessage, + message_key: &str, + ) -> Result<(), CliError> { + if msg.reactions.is_empty() { + return Ok(()); + } + let client = self.client; + let names = self.names; + let user_keys = self.user_keys; + + let Some(target_hex) = self.state.messages.get(message_key).cloned() else { + return Ok(()); + }; + let target = EventId::from_hex(&target_hex) + .map_err(|e| CliError::Other(format!("state file holds invalid event id: {e}")))?; + // Slack exports don't record reaction times; anchor just after the message. + let created_at = ts_seconds(&msg.ts)?.saturating_add(1); + + for reaction in &msg.reactions { + let emoji = emoji_for_shortcode(&reaction.name); + let mut bot_reacted = false; + for user in &reaction.users { + let signing_keys = match user_keys.get(user) { + Some(keys) => Some(keys), + None => { + // All unmapped reactors collapse into one bot-signed + // reaction per emoji — one key can't react twice. + if bot_reacted { + continue; + } + bot_reacted = true; + None + } + }; + let signer_pk = signing_keys + .map(|k| k.public_key().to_hex()) + .unwrap_or_else(|| client.keys().public_key().to_hex()); + let dedupe = format!("{message_key}:{emoji}:{signer_pk}"); + if self.state.reactions.contains(&dedupe) { + continue; + } + let builder = match buzz_sdk::build_reaction(target, &emoji) { + Ok(b) => b, + Err(e) => { + self.summary.warn(format!( + "reaction :{}: on {message_key}: {e}", + reaction.name + )); + continue; + } + }; + let reactor_name = names.get(user).map(String::as_str).unwrap_or(user.as_str()); + let builder = builder + .custom_created_at(Timestamp::from(created_at)) + .tags(provenance_tags(user, reactor_name, &msg.ts)?); + match submit_maybe_as(client, signing_keys, builder).await { + Ok(_) => { + self.state.reactions.insert(dedupe); + self.save()?; + self.summary.reactions_imported += 1; + } + Err(e) => self.summary.warn(format!( + "reaction :{}: on {message_key} in #{} failed: {e}", + reaction.name, channel.name + )), + } + } + } + Ok(()) + } + + /// Flush the final state and print the run summary as JSON. + fn finish(&self) -> Result<(), CliError> { + self.save()?; + let output = serde_json::json!({ + "channels_created": self.summary.channels_created, + "messages_imported": self.summary.messages_imported, + "reactions_imported": self.summary.reactions_imported, + "profiles_published": self.summary.profiles_published, + "skipped": self.summary.skipped, + "warnings": self.summary.warnings, + "state_file": self.state_path.display().to_string(), + }); + print_json(&output) + } } fn dry_run_report( @@ -230,7 +576,7 @@ fn dry_run_report( let mut channels_to_create = 0u64; let mut messages = 0u64; let mut reactions = 0u64; - let mut unmapped_authors: std::collections::HashSet = std::collections::HashSet::new(); + let mut unmapped_authors: HashSet = HashSet::new(); for channel in selected { if !st.channels.contains_key(&channel.id) { channels_to_create += 1; @@ -266,314 +612,14 @@ fn dry_run_report( "mapped_users": user_keys.len(), "unmapped_authors": unmapped, }); - println!( - "{}", - serde_json::to_string(&output) - .map_err(|e| CliError::Other(format!("summary serialization failed: {e}")))? - ); - Ok(()) -} - -#[allow(clippy::too_many_arguments)] -async fn import_channel( - client: &BuzzClient, - export: &SlackExport, - channel: &SlackChannel, - names: &HashMap, - user_keys: &HashMap, - st: &mut ImportState, - state_path: &std::path::Path, - summary: &mut Summary, - skip_reactions: bool, -) -> Result<(), CliError> { - let messages = export.channel_messages(&channel.name)?; - eprintln!("importing #{} ({} messages)", channel.name, messages.len()); - - // Channel create + metadata (once). - let channel_uuid = match st.channels.get(&channel.id) { - Some(cs) => Uuid::parse_str(&cs.uuid) - .map_err(|e| CliError::Other(format!("state file holds invalid UUID: {e}")))?, - None => { - let uuid = Uuid::new_v4(); - let about = if channel.purpose.value.is_empty() { - None - } else { - Some(channel.purpose.value.as_str()) - }; - let builder = buzz_sdk::build_create_channel( - uuid, - &channel.name, - Some(buzz_sdk::Visibility::Open), - Some(buzz_sdk::ChannelKind::Stream), - about, - None, - ) - .map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?; - submit(client, builder).await.map_err(|e| { - CliError::Other(format!("channel create failed for #{}: {e}", channel.name)) - })?; - if !channel.topic.value.is_empty() { - let topic = buzz_sdk::build_set_topic(uuid, &channel.topic.value) - .map_err(|e| CliError::Other(format!("build_set_topic failed: {e}")))?; - if let Err(e) = submit(client, topic).await { - summary.warn(format!("topic set failed for #{}: {e}", channel.name)); - } - } - st.channels.insert( - channel.id.clone(), - ChannelState { - uuid: uuid.to_string(), - metadata_done: true, - }, - ); - st.save(state_path)?; - summary.channels_created += 1; - uuid - } - }; - - // Channel membership for mapped users who speak in this channel. - for msg in &messages { - let Some(author) = author_id(msg) else { - continue; - }; - let Some(keys) = user_keys.get(&author) else { - continue; - }; - let pk = keys.public_key().to_hex(); - let member_key = format!("{}:{pk}", channel.id); - if st.channel_members.contains(&member_key) { - continue; - } - let builder = buzz_sdk::build_add_member(channel_uuid, &pk, None) - .map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?; - match submit(client, builder).await { - Ok(_) => { - st.channel_members.insert(member_key); - st.save(state_path)?; - } - Err(e) => summary.warn(format!( - "channel add-member failed for {author} in #{}: {e}", - channel.name - )), - } - } - - // Messages, oldest first; thread roots always precede replies. - let mut consecutive_failures = 0usize; - let mut imported_in_channel = 0u64; - for msg in &messages { - let key = ImportState::message_key(&channel.id, &msg.ts); - if st.messages.contains_key(&key) { - // Already imported — but a prior run may have stopped between - // the message and its reactions, so reactions still get their - // (state-deduped) pass below. - if !skip_reactions { - import_reactions( - client, channel, msg, &key, names, user_keys, st, state_path, summary, - ) - .await?; - } - continue; - } - - let author = author_id(msg); - let author_name = author_display(msg, names); - let signing_keys = author.as_ref().and_then(|a| user_keys.get(a)); - let bot_signed = signing_keys.is_none(); - - let mut content = mrkdwn::convert(&msg.text, names); - for file in &msg.files { - match file.link() { - Some(link) => { - content.push_str(&format!("\n📎 [{}]({link})", file.label())); - } - None => content.push_str(&format!("\n📎 {}", file.label())), - } - } - let content = content.trim().to_string(); - let content = if bot_signed { - format!("**{author_name}**: {content}") - } else { - content - }; - - // Slack threads are flat: thread_ts is the root, every reply is a - // direct reply to it. Roots resolved through the state ledger. - let thread_ref = match thread_root_key(channel, msg) { - Some(root_key) => match st.messages.get(&root_key) { - Some(root_hex) => { - let root = EventId::from_hex(root_hex).map_err(|e| { - CliError::Other(format!("state file holds invalid event id: {e}")) - })?; - Some(buzz_sdk::ThreadRef { - root_event_id: root, - parent_event_id: root, - }) - } - None => { - summary.warn(format!( - "thread root {root_key} not imported — posting {key} as top-level" - )); - None - } - }, - None => None, - }; - - let created_at = ts_seconds(&msg.ts)?; - let builder = match buzz_sdk::build_message( - channel_uuid, - &content, - thread_ref.as_ref(), - &[], - false, - &[], - ) { - Ok(b) => b, - Err(e) => { - summary.warn(format!("skipping {key}: {e}")); - summary.skipped += 1; - continue; - } - }; - let builder = builder - .custom_created_at(Timestamp::from(created_at)) - .tags(provenance_tags( - author.as_deref().unwrap_or("unknown"), - &author_name, - &msg.ts, - )?); - - let submitted = match signing_keys { - Some(keys) => submit_as(client, keys, builder).await, - None => submit(client, builder).await, - }; - match submitted { - Ok(event_id) => { - consecutive_failures = 0; - st.messages.insert(key.clone(), event_id); - st.save(state_path)?; - summary.messages_imported += 1; - imported_in_channel += 1; - if imported_in_channel.is_multiple_of(50) { - eprintln!(" #{}: {imported_in_channel} imported", channel.name); - } - } - Err(e @ CliError::Auth(_)) => return Err(e), - Err(e) => { - consecutive_failures += 1; - summary.warn(format!("message {key} failed: {e}")); - summary.skipped += 1; - if consecutive_failures >= MAX_CONSECUTIVE_FAILURES { - st.save(state_path)?; - return Err(CliError::Other(format!( - "{MAX_CONSECUTIVE_FAILURES} consecutive submit failures — aborting; \ - re-run to resume from the state file" - ))); - } - continue; - } - } - - if skip_reactions { - continue; - } - import_reactions( - client, channel, msg, &key, names, user_keys, st, state_path, summary, - ) - .await?; - } - - // Mirror Slack's archived flag once the channel's history is in. - if channel.is_archived { - let builder = buzz_sdk::build_archive(channel_uuid) - .map_err(|e| CliError::Other(format!("build_archive failed: {e}")))?; - if let Err(e) = submit(client, builder).await { - summary.warn(format!("archive failed for #{}: {e}", channel.name)); - } - } - st.save(state_path)?; - Ok(()) + print_json(&output) } -#[allow(clippy::too_many_arguments)] -async fn import_reactions( - client: &BuzzClient, - channel: &SlackChannel, - msg: &SlackMessage, - message_key: &str, - names: &HashMap, - user_keys: &HashMap, - st: &mut ImportState, - state_path: &std::path::Path, - summary: &mut Summary, -) -> Result<(), CliError> { - if msg.reactions.is_empty() { - return Ok(()); - } - let Some(target_hex) = st.messages.get(message_key).cloned() else { - return Ok(()); - }; - let target = EventId::from_hex(&target_hex) - .map_err(|e| CliError::Other(format!("state file holds invalid event id: {e}")))?; - // Slack exports don't record reaction times; anchor just after the message. - let created_at = ts_seconds(&msg.ts)?.saturating_add(1); - - for reaction in &msg.reactions { - let emoji = emoji_for_shortcode(&reaction.name); - let mut bot_reacted = false; - for user in &reaction.users { - let signing_keys = match user_keys.get(user) { - Some(keys) => Some(keys), - None => { - // All unmapped reactors collapse into one bot-signed - // reaction per emoji — one key can't react twice. - if bot_reacted { - continue; - } - bot_reacted = true; - None - } - }; - let signer_pk = signing_keys - .map(|k| k.public_key().to_hex()) - .unwrap_or_else(|| client.keys().public_key().to_hex()); - let dedupe = format!("{message_key}:{emoji}:{signer_pk}"); - if st.reactions.contains(&dedupe) { - continue; - } - let builder = match buzz_sdk::build_reaction(target, &emoji) { - Ok(b) => b, - Err(e) => { - summary.warn(format!( - "reaction :{}: on {message_key}: {e}", - reaction.name - )); - continue; - } - }; - let reactor_name = names.get(user).map(String::as_str).unwrap_or(user.as_str()); - let builder = builder - .custom_created_at(Timestamp::from(created_at)) - .tags(provenance_tags(user, reactor_name, &msg.ts)?); - let submitted = match signing_keys { - Some(keys) => submit_as(client, keys, builder).await, - None => submit(client, builder).await, - }; - match submitted { - Ok(_) => { - st.reactions.insert(dedupe); - st.save(state_path)?; - summary.reactions_imported += 1; - } - Err(e) => summary.warn(format!( - "reaction :{}: on {message_key} in #{} failed: {e}", - reaction.name, channel.name - )), - } - } - } +/// Serialize `value` to compact JSON on stdout. +fn print_json(value: &serde_json::Value) -> Result<(), CliError> { + let rendered = serde_json::to_string(value) + .map_err(|e| CliError::Other(format!("summary serialization failed: {e}")))?; + println!("{rendered}"); Ok(()) } @@ -610,6 +656,19 @@ async fn submit_as( submit_signed(client, event).await } +/// Submit `builder` signed by a mapped user's key when present (mapping +/// mode), else by the CLI identity (bot mode). +async fn submit_maybe_as( + client: &BuzzClient, + signing_keys: Option<&Keys>, + builder: EventBuilder, +) -> Result { + match signing_keys { + Some(keys) => submit_as(client, keys, builder).await, + None => submit(client, builder).await, + } +} + async fn submit_signed(client: &BuzzClient, event: nostr::Event) -> Result { let event_id = event.id.to_hex(); let mut backoff_secs = 1u64; From 14fac12236d9b3c40eea2ab627bd9f128147bc9b Mon Sep 17 00:00:00 2001 From: Ren Koya Date: Fri, 24 Jul 2026 14:36:16 +0900 Subject: [PATCH 05/23] feat: owner-signed identity bindings; drop private-key mapping mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the custody-heavy mapping mode with a zero-custody attribution model that closes the migration account-takeover risk. Before: mapping mode generated/held every user's PRIVATE key, signed their history as them, and required distributing nsecs. The relay had a carve-out letting import-tagged events be signed by a key other than the submitter. Now: - Import is always bot mode (operator key signs everything). No private key is generated for or distributed to anyone. - Attribution to real people is an owner/admin-signed identity binding (new kind KIND_IMPORT_IDENTITY_BINDING = 30623) mapping slack: -> a PUBLIC key (npub/hex). The relay accepts this kind ONLY from a community owner/admin (validate_import_identity_binding + role check mirroring the 9030 authz), so a member cannot claim another person's imported history — the exact takeover the user wanted prevented. - The pubkey!=submitter carve-out is removed from ingest: every stored event is now signed by its submitter (only gift wraps exempt), tightening the surface. The past-drift/backfill exemption for backdating is kept. CLI: - Remove `buzz import slack --mapping`/`--skip-profiles`; add `--identity-map U060=npub1…,…` and a standalone `buzz import bind --slack-id --pubkey`. Both take PUBLIC keys only and reject nsec. - Reactions are one bot-signed reaction per distinct emoji (per-reactor identity can't be reproduced in bot mode; documented, `users` no longer parsed). buzz-sdk: build_import_identity_binding + slack_identity_binding_d_tag. Tests: binding shape validation, import-tag detection, identity-map parsing (npub/hex accepted, nsec/malformed rejected). buzz-cli 270, buzz-sdk 233, relay unit tests green; clippy clean. Co-Authored-By: Claude Fable 5 Signed-off-by: Ren Koya --- crates/buzz-cli/src/commands/import.rs | 443 +++++++----------- crates/buzz-cli/src/commands/import/export.rs | 10 +- crates/buzz-cli/src/lib.rs | 47 +- crates/buzz-core/src/kind.rs | 12 + crates/buzz-relay/src/handlers/ingest.rs | 176 ++++++- crates/buzz-sdk/src/builders.rs | 37 +- 6 files changed, 392 insertions(+), 333 deletions(-) diff --git a/crates/buzz-cli/src/commands/import.rs b/crates/buzz-cli/src/commands/import.rs index f936467be3..ff6338bea4 100644 --- a/crates/buzz-cli/src/commands/import.rs +++ b/crates/buzz-cli/src/commands/import.rs @@ -1,17 +1,26 @@ //! `buzz import` — migrate history from external workspaces. //! //! v1 supports Slack workspace exports; see `docs/slack-import.md` for the -//! full design (identity modes, security model, limitations). +//! full design (attribution model, security, limitations). +//! +//! ## Attribution model (zero key custody) +//! +//! Every imported event is signed by the CLI identity (bot mode) and carries +//! `import`/`import_author`/`import_ts` provenance tags. Real people are +//! attributed by **owner/admin-signed identity bindings** (kind +//! `KIND_IMPORT_IDENTITY_BINDING`) that map a Slack user id to that person's +//! own Buzz pubkey — using **public keys only**. No private key is ever +//! generated for or distributed to anyone, and because only an owner/admin +//! can publish a binding, nobody can claim another person's imported history. mod export; mod mrkdwn; mod state; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::path::PathBuf; -use nostr::{EventBuilder, EventId, Keys, Kind, Tag, Timestamp}; -use serde::Deserialize; +use nostr::{EventBuilder, EventId, PublicKey, Timestamp}; use uuid::Uuid; use crate::client::BuzzClient; @@ -28,8 +37,6 @@ const MAX_CONSECUTIVE_FAILURES: usize = 5; pub struct ImportSlackParams { /// Unzipped Slack export directory. pub export_dir: String, - /// Optional Slack-user-ID → private-key JSON file (mapping mode). - pub mapping: Option, /// State file path override. pub state: Option, /// Optional comma-separated channel-name filter. @@ -38,14 +45,9 @@ pub struct ImportSlackParams { pub dry_run: bool, /// Skip reaction import. pub skip_reactions: bool, - /// Skip kind 0 profile publishing for mapped users. - pub skip_profiles: bool, -} - -/// One entry in the `--mapping` file. -#[derive(Deserialize)] -struct MappingEntry { - private_key: String, + /// Optional `SLACKID=npub,SLACKID=hex,…` identity bindings to publish + /// (owner/admin-signed) so imported history renders under real people. + pub identity_map: Option, } #[derive(Default)] @@ -53,7 +55,7 @@ struct Summary { channels_created: u64, messages_imported: u64, reactions_imported: u64, - profiles_published: u64, + bindings_published: u64, skipped: u64, warnings: Vec, } @@ -77,77 +79,92 @@ pub async fn cmd_import_slack(client: &BuzzClient, p: ImportSlackParams) -> Resu let state = ImportState::load(&state_path)?; // Slack user id → display name, for mrkdwn mention rewriting and - // author attribution. + // author attribution tags. let names: HashMap = export .users .iter() .map(|(id, u)| (id.clone(), u.best_name().to_string())) .collect(); - // Mapping mode: sign each mapped user's history with their own key - // locally; every event is submitted over the single CLI connection. The - // relay accepts third-party-signed events carrying `import` provenance - // tags when the submitter is a community owner/admin (the Schnorr - // signature proves authorship), so no per-user connection or relay - // membership is needed at import time. - let user_keys = load_mapping(p.mapping.as_deref())?; + // Parse identity bindings (Slack id → Buzz pubkey), public keys only. + let bindings = parse_identity_map(p.identity_map.as_deref())?; let selected = select_channels(&export, p.channels.as_deref())?; if p.dry_run { - return dry_run_report(&export, &selected, &state, &user_keys); + return dry_run_report(&export, &selected, &state, &bindings); } let mut importer = Importer { client, export: &export, names: &names, - user_keys: &user_keys, state, state_path, summary: Summary::default(), skip_reactions: p.skip_reactions, - skip_profiles: p.skip_profiles, }; - // Relay membership for mapped users (best-effort: lets them read once - // they log in with their key; posting during import does not need it). - importer.add_relay_members().await?; - - // Profiles for mapped users — signed by the user's key, submitted over - // the CLI connection (import tags make the third-party signature - // acceptable to the relay). - importer.publish_profiles().await?; - for channel in &selected { importer.import_channel(channel).await?; } + // Publish owner/admin-signed identity bindings last, so the history they + // attribute is already in place. + importer.publish_bindings(&bindings).await?; + importer.finish() } -/// Parse a `--mapping` file into Slack-user-ID → signing keys. -fn load_mapping(path: Option<&str>) -> Result, CliError> { - let Some(path) = path else { - return Ok(HashMap::new()); +/// Publish a single owner/admin-signed identity binding. +pub async fn cmd_import_bind( + client: &BuzzClient, + slack_id: &str, + pubkey: &str, +) -> Result<(), CliError> { + let pubkey_hex = parse_pubkey(pubkey)?; + let event_id = publish_binding(client, slack_id, &pubkey_hex).await?; + print_json(&serde_json::json!({ + "event_id": event_id, + "slack_id": slack_id, + "pubkey": pubkey_hex, + "accepted": true, + })) +} + +/// Parse a `SLACKID=key,SLACKID=key` list into `(slack_id, pubkey_hex)` pairs. +/// Each key may be an `npub1…` or a 64-char hex pubkey — **public keys only**. +fn parse_identity_map(spec: Option<&str>) -> Result, CliError> { + let Some(spec) = spec else { + return Ok(Vec::new()); }; - let raw = std::fs::read_to_string(path) - .map_err(|e| CliError::Usage(format!("cannot read --mapping {path}: {e}")))?; - let entries: HashMap = serde_json::from_str(&raw) - .map_err(|e| CliError::Usage(format!("cannot parse --mapping {path}: {e}")))?; - entries - .into_iter() - .map(|(slack_id, entry)| { - let keys = Keys::parse(&entry.private_key).map_err(|e| { - CliError::Key(format!( - "invalid private key for {slack_id} in mapping: {e}" + spec.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|entry| { + let (slack_id, key) = entry.split_once('=').ok_or_else(|| { + CliError::Usage(format!( + "--identity-map entry must be SLACKID=npub-or-hex (got {entry:?})" )) })?; - Ok((slack_id, keys)) + Ok((slack_id.trim().to_string(), parse_pubkey(key.trim())?)) }) .collect() } +/// Parse an `npub1…` or 64-char hex string into a hex pubkey. Rejects nsec so +/// a private key can never be passed where a public key belongs. +fn parse_pubkey(key: &str) -> Result { + if key.starts_with("nsec1") { + return Err(CliError::Usage( + "identity bindings take a PUBLIC key (npub or hex), not an nsec".into(), + )); + } + PublicKey::parse(key) + .map(|pk| pk.to_hex()) + .map_err(|_| CliError::Usage(format!("invalid pubkey (expected npub or 64-hex): {key}"))) +} + /// Resolve the `--channels` filter against the export's channel list, /// erroring if the filter selects nothing. fn select_channels<'e>( @@ -177,22 +194,17 @@ fn select_channels<'e>( Ok(selected) } -/// A single `buzz import slack` run: the borrowed export/index inputs plus -/// the mutable state ledger and summary threaded through every write. Bundled -/// here so the per-channel/per-reaction helpers stay methods rather than -/// ten-argument free functions. +/// A single `buzz import slack` run: borrowed export/index inputs plus the +/// mutable state ledger and summary threaded through every write. struct Importer<'a> { client: &'a BuzzClient, export: &'a SlackExport, /// Slack user id → display name. names: &'a HashMap, - /// Slack user id → signing key (mapping mode); empty in bot mode. - user_keys: &'a HashMap, state: ImportState, state_path: PathBuf, summary: Summary, skip_reactions: bool, - skip_profiles: bool, } impl Importer<'_> { @@ -201,79 +213,10 @@ impl Importer<'_> { self.state.save(&self.state_path) } - /// Add every mapped user as a relay member (best-effort — a failure - /// warns and moves on). - async fn add_relay_members(&mut self) -> Result<(), CliError> { - let client = self.client; - let user_keys = self.user_keys; - for (slack_id, keys) in user_keys { - let pk = keys.public_key().to_hex(); - if self.state.relay_members.contains(&pk) { - continue; - } - match add_relay_member(client, &pk).await { - Ok(()) => { - self.state.relay_members.insert(pk); - self.save()?; - } - Err(e) => self.summary.warn(format!( - "relay add-member failed for {slack_id} ({pk}): {e}" - )), - } - } - Ok(()) - } - - /// Publish a kind 0 profile for each mapped user, signed by their key. - async fn publish_profiles(&mut self) -> Result<(), CliError> { - if self.skip_profiles { - return Ok(()); - } - let client = self.client; - let export = self.export; - let user_keys = self.user_keys; - for (slack_id, keys) in user_keys { - if self.state.profiles.contains(slack_id) { - continue; - } - let Some(user) = export.users.get(slack_id) else { - self.summary - .warn(format!("mapping entry {slack_id} not found in users.json")); - continue; - }; - let name = user.best_name().to_string(); - let builder = buzz_sdk::build_profile( - Some(&name), - Some(if user.name.is_empty() { - &name - } else { - &user.name - }), - user.profile.image_512.as_deref(), - None, - None, - ) - .map_err(|e| CliError::Other(format!("build_profile failed: {e}")))? - .tags(provenance_tags(slack_id, &name, "")?); - match submit_as(client, keys, builder).await { - Ok(_) => { - self.state.profiles.insert(slack_id.clone()); - self.save()?; - self.summary.profiles_published += 1; - } - Err(e) => self - .summary - .warn(format!("profile publish failed for {slack_id}: {e}")), - } - } - Ok(()) - } - async fn import_channel(&mut self, channel: &SlackChannel) -> Result<(), CliError> { let client = self.client; let export = self.export; let names = self.names; - let user_keys = self.user_keys; let messages = export.channel_messages(&channel.name)?; eprintln!("importing #{} ({} messages)", channel.name, messages.len()); @@ -322,33 +265,6 @@ impl Importer<'_> { } }; - // Channel membership for mapped users who speak in this channel. - for msg in &messages { - let Some(author) = author_id(msg) else { - continue; - }; - let Some(keys) = user_keys.get(&author) else { - continue; - }; - let pk = keys.public_key().to_hex(); - let member_key = format!("{}:{pk}", channel.id); - if self.state.channel_members.contains(&member_key) { - continue; - } - let builder = buzz_sdk::build_add_member(channel_uuid, &pk, None) - .map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?; - match submit(client, builder).await { - Ok(_) => { - self.state.channel_members.insert(member_key); - self.save()?; - } - Err(e) => self.summary.warn(format!( - "channel add-member failed for {author} in #{}: {e}", - channel.name - )), - } - } - // Messages, oldest first; thread roots always precede replies. let mut consecutive_failures = 0usize; let mut imported_in_channel = 0u64; @@ -366,8 +282,6 @@ impl Importer<'_> { let author = author_id(msg); let author_name = author_display(msg, names); - let signing_keys = author.as_ref().and_then(|a| user_keys.get(a)); - let bot_signed = signing_keys.is_none(); let mut content = mrkdwn::convert(&msg.text, names); for file in &msg.files { @@ -376,12 +290,9 @@ impl Importer<'_> { None => content.push_str(&format!("\n📎 {}", file.label())), } } - let content = content.trim().to_string(); - let content = if bot_signed { - format!("**{author_name}**: {content}") - } else { - content - }; + // Bot-signed history keeps the author's name in a content prefix so + // it stays readable even before an identity binding is published. + let content = format!("**{author_name}**: {}", content.trim()); // Slack threads are flat: thread_ts is the root, every reply is a // direct reply to it. Roots resolved through the state ledger. @@ -431,7 +342,7 @@ impl Importer<'_> { &msg.ts, )?); - match submit_maybe_as(client, signing_keys, builder).await { + match submit(client, builder).await { Ok(event_id) => { consecutive_failures = 0; self.state.messages.insert(key.clone(), event_id); @@ -477,6 +388,9 @@ impl Importer<'_> { Ok(()) } + /// Import reactions for one message. Bot mode signs with a single key, so + /// each distinct emoji becomes one bot-signed reaction (a key can react + /// only once per target); the count of reactors is not preserved. async fn import_reactions( &mut self, channel: &SlackChannel, @@ -487,8 +401,6 @@ impl Importer<'_> { return Ok(()); } let client = self.client; - let names = self.names; - let user_keys = self.user_keys; let Some(target_hex) = self.state.messages.get(message_key).cloned() else { return Ok(()); @@ -500,52 +412,46 @@ impl Importer<'_> { for reaction in &msg.reactions { let emoji = emoji_for_shortcode(&reaction.name); - let mut bot_reacted = false; - for user in &reaction.users { - let signing_keys = match user_keys.get(user) { - Some(keys) => Some(keys), - None => { - // All unmapped reactors collapse into one bot-signed - // reaction per emoji — one key can't react twice. - if bot_reacted { - continue; - } - bot_reacted = true; - None - } - }; - let signer_pk = signing_keys - .map(|k| k.public_key().to_hex()) - .unwrap_or_else(|| client.keys().public_key().to_hex()); - let dedupe = format!("{message_key}:{emoji}:{signer_pk}"); - if self.state.reactions.contains(&dedupe) { + let dedupe = format!("{message_key}:{emoji}"); + if self.state.reactions.contains(&dedupe) { + continue; + } + let builder = match buzz_sdk::build_reaction(target, &emoji) { + Ok(b) => b, + Err(e) => { + self.summary.warn(format!( + "reaction :{}: on {message_key}: {e}", + reaction.name + )); continue; } - let builder = match buzz_sdk::build_reaction(target, &emoji) { - Ok(b) => b, - Err(e) => { - self.summary.warn(format!( - "reaction :{}: on {message_key}: {e}", - reaction.name - )); - continue; - } - }; - let reactor_name = names.get(user).map(String::as_str).unwrap_or(user.as_str()); - let builder = builder - .custom_created_at(Timestamp::from(created_at)) - .tags(provenance_tags(user, reactor_name, &msg.ts)?); - match submit_maybe_as(client, signing_keys, builder).await { - Ok(_) => { - self.state.reactions.insert(dedupe); - self.save()?; - self.summary.reactions_imported += 1; - } - Err(e) => self.summary.warn(format!( - "reaction :{}: on {message_key} in #{} failed: {e}", - reaction.name, channel.name - )), + }; + let builder = builder + .custom_created_at(Timestamp::from(created_at)) + .tags(provenance_tags("slack", "slack", &msg.ts)?); + match submit(client, builder).await { + Ok(_) => { + self.state.reactions.insert(dedupe); + self.save()?; + self.summary.reactions_imported += 1; } + Err(e) => self.summary.warn(format!( + "reaction :{}: on {message_key} in #{} failed: {e}", + reaction.name, channel.name + )), + } + } + Ok(()) + } + + /// Publish owner/admin-signed identity bindings (public keys only). + async fn publish_bindings(&mut self, bindings: &[(String, String)]) -> Result<(), CliError> { + for (slack_id, pubkey_hex) in bindings { + match publish_binding(self.client, slack_id, pubkey_hex).await { + Ok(_) => self.summary.bindings_published += 1, + Err(e) => self + .summary + .warn(format!("identity binding for {slack_id} failed: {e}")), } } Ok(()) @@ -554,16 +460,15 @@ impl Importer<'_> { /// Flush the final state and print the run summary as JSON. fn finish(&self) -> Result<(), CliError> { self.save()?; - let output = serde_json::json!({ + print_json(&serde_json::json!({ "channels_created": self.summary.channels_created, "messages_imported": self.summary.messages_imported, "reactions_imported": self.summary.reactions_imported, - "profiles_published": self.summary.profiles_published, + "bindings_published": self.summary.bindings_published, "skipped": self.summary.skipped, "warnings": self.summary.warnings, "state_file": self.state_path.display().to_string(), - }); - print_json(&output) + })) } } @@ -571,12 +476,11 @@ fn dry_run_report( export: &SlackExport, selected: &[&SlackChannel], st: &ImportState, - user_keys: &HashMap, + bindings: &[(String, String)], ) -> Result<(), CliError> { let mut channels_to_create = 0u64; let mut messages = 0u64; let mut reactions = 0u64; - let mut unmapped_authors: HashSet = HashSet::new(); for channel in selected { if !st.channels.contains_key(&channel.id) { channels_to_create += 1; @@ -589,30 +493,22 @@ fn dry_run_report( continue; } messages += 1; - reactions += msg - .reactions - .iter() - .map(|r| r.users.len() as u64) - .sum::(); - if let Some(author) = author_id(&msg) { - if !user_keys.contains_key(&author) { - unmapped_authors.insert(author); - } + // One bot reaction per distinct emoji (bot mode dedup). + let mut emojis: std::collections::HashSet = std::collections::HashSet::new(); + for r in &msg.reactions { + emojis.insert(emoji_for_shortcode(&r.name)); } + reactions += emojis.len() as u64; } } - let mut unmapped: Vec = unmapped_authors.into_iter().collect(); - unmapped.sort(); - let output = serde_json::json!({ + print_json(&serde_json::json!({ "dry_run": true, "channels_selected": selected.len(), "channels_to_create": channels_to_create, "messages_to_import": messages, "reactions_to_import": reactions, - "mapped_users": user_keys.len(), - "unmapped_authors": unmapped, - }); - print_json(&output) + "bindings_to_publish": bindings.len(), + })) } /// Serialize `value` to compact JSON on stdout. @@ -633,43 +529,9 @@ fn print_json(value: &serde_json::Value) -> Result<(), CliError> { /// and the relay's `retry in 0s` hint makes the client's built-in retry /// spin uselessly — so 429s are absorbed here with a real backoff. The /// signed event is resubmitted verbatim; a re-send that lands twice is a -/// relay-side duplicate, which the acceptance check below treats as -/// success. +/// relay-side duplicate, which the acceptance check below treats as success. async fn submit(client: &BuzzClient, builder: EventBuilder) -> Result { let event = client.sign_event(builder)?; - submit_signed(client, event).await -} - -/// Sign with a mapped user's key and submit over the CLI connection. -/// -/// The relay accepts the author/submitter mismatch because imported events -/// carry `import` provenance tags and the CLI identity is a community -/// owner/admin — the event's own Schnorr signature proves authorship. -async fn submit_as( - client: &BuzzClient, - keys: &Keys, - builder: EventBuilder, -) -> Result { - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; - submit_signed(client, event).await -} - -/// Submit `builder` signed by a mapped user's key when present (mapping -/// mode), else by the CLI identity (bot mode). -async fn submit_maybe_as( - client: &BuzzClient, - signing_keys: Option<&Keys>, - builder: EventBuilder, -) -> Result { - match signing_keys { - Some(keys) => submit_as(client, keys, builder).await, - None => submit(client, builder).await, - } -} - -async fn submit_signed(client: &BuzzClient, event: nostr::Event) -> Result { let event_id = event.id.to_hex(); let mut backoff_secs = 1u64; let resp = loop { @@ -704,14 +566,16 @@ async fn submit_signed(client: &BuzzClient, event: nostr::Event) -> Result Result<(), CliError> { - let tag = Tag::parse(["p", pubkey_hex]) - .map_err(|e| CliError::Other(format!("invalid p tag: {e}")))?; - let builder = EventBuilder::new(Kind::Custom(9030), "").tags([tag]); - submit(client, builder).await.map(|_| ()) +/// Build and submit an owner/admin-signed Slack identity binding. +async fn publish_binding( + client: &BuzzClient, + slack_id: &str, + pubkey_hex: &str, +) -> Result { + let d_tag = buzz_sdk::slack_identity_binding_d_tag(slack_id); + let builder = buzz_sdk::build_import_identity_binding(&d_tag, pubkey_hex) + .map_err(|e| CliError::Other(format!("build_import_identity_binding failed: {e}")))?; + submit(client, builder).await } /// Provenance tags carried by every imported event. @@ -719,9 +583,9 @@ fn provenance_tags( author_id: &str, author_name: &str, slack_ts: &str, -) -> Result, CliError> { +) -> Result, CliError> { let mk = |parts: &[&str]| { - Tag::parse(parts.iter().copied()) + nostr::Tag::parse(parts.iter().copied()) .map_err(|e| CliError::Other(format!("invalid provenance tag: {e}"))) }; Ok(vec![ @@ -821,33 +685,35 @@ pub async fn dispatch(cmd: crate::ImportCmd, client: &BuzzClient) -> Result<(), match cmd { crate::ImportCmd::Slack { export_dir, - mapping, state, channels, dry_run, skip_reactions, - skip_profiles, + identity_map, } => { cmd_import_slack( client, ImportSlackParams { export_dir, - mapping, state, channels, dry_run, skip_reactions, - skip_profiles, + identity_map, }, ) .await } + crate::ImportCmd::Bind { slack_id, pubkey } => { + cmd_import_bind(client, &slack_id, &pubkey).await + } } } #[cfg(test)] mod tests { use super::*; + use nostr::Keys; fn msg(json: &str) -> SlackMessage { serde_json::from_str(json).expect("test message parses") @@ -892,6 +758,23 @@ mod tests { assert_eq!(emoji_for_shortcode("party_parrot"), ":party_parrot:"); } + #[test] + fn identity_map_parses_npub_and_hex_and_rejects_nsec() { + let hex = "8f3904246ba9d9cc7e821e7752e123d435234d17c2513d85785f4a0b1ca07e56"; + let parsed = parse_identity_map(Some(&format!("U1={hex}"))).expect("parses hex"); + assert_eq!(parsed, vec![("U1".to_string(), hex.to_string())]); + + assert!( + parse_identity_map(Some("U1=nsec1abc")).is_err(), + "nsec rejected" + ); + assert!( + parse_identity_map(Some("U1")).is_err(), + "missing = rejected" + ); + assert!(parse_identity_map(None).expect("none ok").is_empty()); + } + #[tokio::test] async fn dry_run_is_offline_and_reports_counts() { let dir = std::env::temp_dir().join(format!("buzz-import-dryrun-{}", std::process::id())); @@ -922,18 +805,16 @@ mod tests { &client, ImportSlackParams { export_dir: dir.display().to_string(), - mapping: None, state: None, channels: None, dry_run: true, skip_reactions: false, - skip_profiles: false, + identity_map: None, }, ) .await .expect("dry run succeeds offline"); - // Dry run writes no state file. assert!(!dir.join("buzz-import-state.json").exists()); std::fs::remove_dir_all(&dir).ok(); } diff --git a/crates/buzz-cli/src/commands/import/export.rs b/crates/buzz-cli/src/commands/import/export.rs index 00d4da4f70..ac8f6f2ad1 100644 --- a/crates/buzz-cli/src/commands/import/export.rs +++ b/crates/buzz-cli/src/commands/import/export.rs @@ -33,9 +33,6 @@ pub struct SlackUserProfile { /// Full real name (may be empty). #[serde(default)] pub real_name: String, - /// 512px avatar URL, when present. - #[serde(default)] - pub image_512: Option, } impl SlackUser { @@ -116,14 +113,13 @@ pub struct SlackMessage { pub files: Vec, } -/// One emoji reaction group on a message. +/// One emoji reaction group on a message. Only the emoji name is used: bot +/// mode signs one reaction per distinct emoji, so per-reactor identity (the +/// export's `users` array) cannot be reproduced and is not parsed. #[derive(Debug, Clone, Deserialize)] pub struct SlackReaction { /// Emoji shortcode without colons (may carry `::skin-tone-N`). pub name: String, - /// User IDs who reacted. - #[serde(default)] - pub users: Vec, } /// One file attachment stub. diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 3367fba1e8..e4bf3d725f 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -926,25 +926,24 @@ pub enum WorkflowsCmd { #[derive(Subcommand)] pub enum ImportCmd { /// Import a Slack workspace export directory (see docs/slack-import.md) - #[command(after_help = "Modes:\n \ -bot mode (default): everything is signed by the CLI identity; original \ -authors are preserved in content prefixes and import_author tags.\n \ -mapping mode (--mapping): a JSON file maps Slack user IDs to private keys \ -({\"U123\": {\"private_key\": \"nsec1...\"}}); each user's history is signed \ -with their own key. Requires the CLI identity to be a community owner/admin \ -so mapped users can be added as relay members.\n\n\ + #[command( + after_help = "History is signed by the CLI identity (bot mode) and tagged with the \ +original author (import_author) and timestamp. No private key is ever \ +generated for or distributed to anyone.\n\n\ +To attribute history to real people, publish owner/admin-signed identity \ +bindings mapping a Slack user id to that person's own PUBLIC key (npub or \ +hex) — via --identity-map here, or `buzz import bind` later. A member cannot \ +claim another person's history: only an owner/admin can publish a binding.\n\n\ Re-running resumes from the state file — completed writes are skipped.\n\n\ Examples:\n \ buzz import slack --export-dir ./export --dry-run\n \ buzz import slack --export-dir ./export\n \ -buzz import slack --export-dir ./export --mapping keys.json --channels general,random")] +buzz import slack --export-dir ./export --identity-map U060=npub1abc,U081=npub1def" + )] Slack { /// Path to the unzipped Slack export directory #[arg(long)] export_dir: String, - /// JSON file mapping Slack user IDs to Nostr private keys (mapping mode) - #[arg(long)] - mapping: Option, /// State file path (default: /buzz-import-state.json) #[arg(long)] state: Option, @@ -957,9 +956,25 @@ buzz import slack --export-dir ./export --mapping keys.json --channels general,r /// Skip importing reactions #[arg(long, default_value_t = false)] skip_reactions: bool, - /// Skip publishing kind 0 profiles for mapped users - #[arg(long, default_value_t = false)] - skip_profiles: bool, + /// Owner/admin-signed identity bindings: SLACKID=npub-or-hex, comma-separated. + /// Public keys only — never an nsec. + #[arg(long)] + identity_map: Option, + }, + /// Publish one owner/admin-signed identity binding (Slack id → public key) + #[command( + after_help = "Attributes imported history to a real person. The pubkey is PUBLIC \ +(npub or hex) — never an nsec. Requires the CLI identity to be a community \ +owner or admin.\n\nExample:\n \ +buzz import bind --slack-id U060976D0QN --pubkey npub1abc..." + )] + Bind { + /// Slack user id (e.g. U060976D0QN) + #[arg(long)] + slack_id: String, + /// The person's PUBLIC key: npub1… or 64-char hex + #[arg(long)] + pubkey: String, }, } @@ -1974,7 +1989,7 @@ mod tests { vec!["approve", "create", "delete", "get", "list", "runs", "trigger", "update"] ); assert_eq!(names(&cmd, "feed"), vec!["get"]); - assert_eq!(names(&cmd, "import"), vec!["slack"]); + assert_eq!(names(&cmd, "import"), vec!["bind", "slack"]); assert_eq!( names(&cmd, "social"), vec![ @@ -2045,7 +2060,7 @@ mod tests { ("dms", 4), ("emoji", 5), ("feed", 1), - ("import", 1), + ("import", 2), ("issues", 4), ("media", 1), ("messages", 8), diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index b912169801..fcd70dd72c 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -312,6 +312,17 @@ pub const KIND_WORKFLOW_DEF: u32 = 30620; /// `hidden_at` per viewer; this is the only Nostr-visible projection of it. pub const KIND_DM_VISIBILITY: u32 = 30622; +/// Import identity binding: an owner/admin-signed attestation that a foreign +/// workspace identity (e.g. a Slack user id) belongs to a given Buzz pubkey. +/// Parameterized-replaceable, `d = :` (e.g. +/// `slack:U060976D0QN`), with a single `["p", ]` naming the bound +/// identity. The relay accepts this kind ONLY from a community owner or admin +/// (mirrors the kind:9030 relay-admin authorization), so a member cannot claim +/// another person's imported history — the whole point of the binding. Clients +/// read these to render `import_author`-tagged history under the bound pubkey's +/// profile. It carries public keys only; no secret ever transits. +pub const KIND_IMPORT_IDENTITY_BINDING: u32 = 30623; + /// Lower bound of the NIP-33 parameterized replaceable range (30000–39999). pub const PARAM_REPLACEABLE_KIND_MIN: u32 = 30000; /// Upper bound of the NIP-33 parameterized replaceable range (30000–39999). @@ -615,6 +626,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, + KIND_IMPORT_IDENTITY_BINDING, ]; /// Returns `true` if `kind` is in the ephemeral range (20000–29999). diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index dfa5d91b1e..f8c2f1909b 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -21,15 +21,15 @@ use buzz_core::kind::{ KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, - KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, - KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, - KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, - KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, - KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, - KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, + KIND_IA_UNARCHIVE_REQUEST, KIND_IMPORT_IDENTITY_BINDING, KIND_LONG_FORM, KIND_MANAGED_AGENT, + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, + KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, + KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, + KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, + KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, + KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, + KIND_PRESENCE_UPDATE, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, + KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, @@ -300,6 +300,10 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), KIND_WORKFLOW_DEF | KIND_WORKFLOW_TRIGGER => Ok(Scope::MessagesWrite), KIND_APPROVAL_GRANT | KIND_APPROVAL_DENY => Ok(Scope::MessagesWrite), + // Import identity binding — scope gets the caller in the door; the + // owner/admin role check that actually authorizes it runs in + // `validate_import_identity_binding` before storage. + KIND_IMPORT_IDENTITY_BINDING => Ok(Scope::AdminUsers), _ => Err("restricted: unknown event kind"), } } @@ -312,6 +316,49 @@ fn has_import_tag(event: &Event) -> bool { .any(|tag| tag.as_slice().first().map(|s| s.as_str()) == Some("import")) } +/// Validate the shape of a `KIND_IMPORT_IDENTITY_BINDING` event before storage. +/// +/// Requires a non-empty `d` tag (the `:` key, e.g. +/// `slack:U060976D0QN`) and exactly one `p` tag holding a 64-char hex pubkey +/// (the bound Buzz identity). Authorization (owner/admin) is checked by the +/// caller; this only enforces that the stored event is well-formed so clients +/// can trust its shape. +fn validate_import_identity_binding(event: &Event) -> Result<(), IngestError> { + let d_tag = event.tags.iter().find_map(|t| { + let parts = t.as_slice(); + (parts.first().map(|s| s.as_str()) == Some("d")) + .then(|| parts.get(1).map(|s| s.as_str()).unwrap_or("")) + }); + match d_tag { + Some(d) if !d.is_empty() => {} + _ => { + return Err(IngestError::Rejected( + "invalid: identity binding requires a non-empty d tag (e.g. slack:U123)".into(), + )) + } + } + + let p_tags: Vec<&str> = event + .tags + .iter() + .filter_map(|t| { + let parts = t.as_slice(); + (parts.first().map(|s| s.as_str()) == Some("p")) + .then(|| parts.get(1).map(|s| s.as_str())) + .flatten() + }) + .collect(); + match p_tags.as_slice() { + [pubkey] if pubkey.len() == 64 && pubkey.chars().all(|c| c.is_ascii_hexdigit()) => Ok(()), + [_] => Err(IngestError::Rejected( + "invalid: identity binding p tag must be a 64-char hex pubkey".into(), + )), + _ => Err(IngestError::Rejected( + "invalid: identity binding requires exactly one p tag (the bound pubkey)".into(), + )), + } +} + /// Extract a channel UUID from the `"h"` NIP-29 group tag. pub(crate) fn extract_channel_id(event: &Event) -> Option { for tag in event.tags.iter() { @@ -1485,24 +1532,41 @@ async fn ingest_event_inner( } let event = std::sync::Arc::try_unwrap(event).unwrap_or_else(|arc| (*arc).clone()); + // Whether the authenticated caller is a community owner/admin. Looked up + // once and reused for the import carve-out and the identity-binding gate. + let caller_is_community_admin = matches!( + state + .db + .get_relay_member(tenant.community(), &auth.pubkey().to_hex()) + .await + .map_err(|e| IngestError::Internal(format!("error: {e}")))? + .as_ref() + .map(|m| m.role.as_str()), + Some("owner") | Some("admin") + ); + // Authorized-import carve-out: an event carrying an `import` provenance - // tag, submitted by an authenticated community owner/admin, may (a) be - // backdated past the drift envelope and (b) be signed by a key other - // than the submitter — the Schnorr signature already proves authorship, - // and the admin's own auth answers "who is allowed to write history - // here". The trust model matches BUZZ_MAX_PAST_DRIFT_SECS (trust the + // tag, submitted by an authenticated community owner/admin, may be + // backdated past the drift envelope so history replays with its original + // timestamps. The trust model matches BUZZ_MAX_PAST_DRIFT_SECS (trust the // operator), scoped per event instead of relay-wide, with no restart. - let import_exempt = has_import_tag(&event) - && matches!( - state - .db - .get_relay_member(tenant.community(), &auth.pubkey().to_hex()) - .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))? - .as_ref() - .map(|m| m.role.as_str()), - Some("owner") | Some("admin") - ); + // Note: the event is still signed by the submitter (bot mode) — the + // pubkey==submitter check below is NOT waived; attribution to real people + // is carried by `import_author` tags plus owner-signed identity bindings + // (kind KIND_IMPORT_IDENTITY_BINDING), never by third-party signatures. + let import_exempt = has_import_tag(&event) && caller_is_community_admin; + + // Identity bindings are owner/admin-only: this is what stops a member from + // claiming someone else's imported history (`d = slack:` → their own + // pubkey). Reject before storage if the caller is not owner/admin. + if kind_u32 == KIND_IMPORT_IDENTITY_BINDING { + if !caller_is_community_admin { + return Err(IngestError::AuthFailed( + "restricted: identity bindings require a community owner or admin".into(), + )); + } + validate_import_identity_binding(&event)?; + } // Future drift is a fixed bound — a future timestamp is always a clock // error or a forgery. Past drift is operator-tunable @@ -1528,8 +1592,12 @@ async fn ingest_event_inner( ))); } + // Every stored event must be signed by the authenticated submitter (only + // NIP-59 gift wraps, which deliberately use an ephemeral pubkey, are + // exempt). Imports are bot-signed, so this holds for them too — there is + // no third-party-signature path into the relay. let is_gift_wrap = kind_u32 == KIND_GIFT_WRAP; - if event.pubkey != *auth.pubkey() && !is_gift_wrap && !import_exempt { + if event.pubkey != *auth.pubkey() && !is_gift_wrap { return Err(IngestError::AuthFailed( "invalid: event pubkey does not match authenticated identity".into(), )); @@ -2683,6 +2751,62 @@ mod tests { ); } + #[test] + fn identity_binding_shape_validation() { + use buzz_core::kind::KIND_IMPORT_IDENTITY_BINDING; + let pk = "8f3904246ba9d9cc7e821e7752e123d435234d17c2513d85785f4a0b1ca07e56"; + let keys = nostr::Keys::generate(); + let build = |tags: Vec| { + EventBuilder::new(Kind::Custom(KIND_IMPORT_IDENTITY_BINDING as u16), "") + .tags(tags) + .sign_with_keys(&keys) + .expect("sign binding") + }; + let tag = |parts: &[&str]| nostr::Tag::parse(parts.iter().copied()).expect("tag"); + + // Well-formed: d = slack:, one 64-hex p tag. + let ok = build(vec![tag(&["d", "slack:U1"]), tag(&["p", pk])]); + assert!(validate_import_identity_binding(&ok).is_ok()); + + // Missing d tag. + let no_d = build(vec![tag(&["p", pk])]); + assert!(validate_import_identity_binding(&no_d).is_err()); + + // Empty d tag. + let empty_d = build(vec![tag(&["d", ""]), tag(&["p", pk])]); + assert!(validate_import_identity_binding(&empty_d).is_err()); + + // Missing p tag. + let no_p = build(vec![tag(&["d", "slack:U1"])]); + assert!(validate_import_identity_binding(&no_p).is_err()); + + // Non-hex / wrong-length p tag. + let bad_p = build(vec![tag(&["d", "slack:U1"]), tag(&["p", "notapubkey"])]); + assert!(validate_import_identity_binding(&bad_p).is_err()); + + // Two p tags — ambiguous, rejected. + let two_p = build(vec![ + tag(&["d", "slack:U1"]), + tag(&["p", pk]), + tag(&["p", pk]), + ]); + assert!(validate_import_identity_binding(&two_p).is_err()); + } + + #[test] + fn import_tag_detection() { + let keys = nostr::Keys::generate(); + let with = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "hi") + .tags(vec![nostr::Tag::parse(["import", "slack"]).expect("tag")]) + .sign_with_keys(&keys) + .expect("sign"); + assert!(has_import_tag(&with)); + let without = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "hi") + .sign_with_keys(&keys) + .expect("sign"); + assert!(!has_import_tag(&without)); + } + #[test] fn long_form_does_not_require_h_tag() { // kind:30023 is global (author-owned, not channel-scoped) diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index edad401bf9..265eb27a93 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -10,9 +10,9 @@ use buzz_core::{ KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, - KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, - KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, - KIND_WORKFLOW_TRIGGER, + KIND_IMPORT_IDENTITY_BINDING, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, + KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, + KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -531,6 +531,37 @@ pub fn build_set_canvas(channel_id: Uuid, content: &str) -> Result`. +pub fn slack_identity_binding_d_tag(slack_user_id: &str) -> String { + format!("slack:{slack_user_id}") +} + +/// Build an owner/admin-signed import identity binding (kind 30623). +/// +/// Attests that the foreign workspace identity keyed by `d_tag` (e.g. +/// `slack:U060976D0QN`, via [`slack_identity_binding_d_tag`]) belongs to +/// `bound_pubkey`. Carries **public keys only** — no secret transits. The +/// relay accepts this event solely from a community owner/admin, so a member +/// cannot claim another person's imported history. Parameterized-replaceable: +/// re-binding the same `d_tag` supersedes the prior binding. +pub fn build_import_identity_binding( + d_tag: &str, + bound_pubkey: &str, +) -> Result { + if d_tag.trim().is_empty() { + return Err(SdkError::InvalidInput( + "identity binding d tag must not be empty".into(), + )); + } + let bound_pubkey = check_pubkey_hex(bound_pubkey, "bound_pubkey")?; + let tags = vec![ + tag(&["d", d_tag])?, + tag(&["p", &bound_pubkey])?, + tag(&["import", "slack"])?, + ]; + Ok(EventBuilder::new(Kind::Custom(KIND_IMPORT_IDENTITY_BINDING as u16), "").tags(tags)) +} + /// Build a NIP-01 profile metadata event (kind 0). /// /// Only present (Some) fields are included in the JSON object. From ebd6fbae39263f4d6f1d1851cb0cf6d5040e155c Mon Sep 17 00:00:00 2001 From: Ren Koya Date: Fri, 24 Jul 2026 14:48:50 +0900 Subject: [PATCH 06/23] feat(desktop): render imported history under the real author MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Imported (bot-signed) messages carry an `import_author` tag; owner-signed identity bindings (kind 30623) map that foreign id to the person's Buzz pubkey. The timeline now: - shows the original author's name from `import_author` (always, no binding needed), and strips the redundant `**Name**: ` body prefix the importer adds for search / non-Buzz clients; - when a binding exists, attributes the row to the bound pubkey and renders that person's profile name + avatar (their profile is added to the batch fetch so it loads even in channels where they only have imported history); - flags rows `imported` so they're distinguishable from live self-signed messages. useImportIdentityBindings fetches kind 30623 and builds a `:` → pubkey map; ChannelScreen threads it into formatTimelineMessages. Name attribution works on every surface that passes undefined; the binding/avatar path is wired into the main channel timeline. Tests: import name override + prefix strip, bound-profile attribution, native `**bold**:` untouched. 24 timeline tests green; tsc + biome clean. Co-Authored-By: Claude Fable 5 Signed-off-by: Ren Koya --- .../features/channels/ui/ChannelScreen.tsx | 13 +++ .../lib/formatTimelineMessages.test.mjs | 64 ++++++++++++++ .../messages/lib/formatTimelineMessages.ts | 84 +++++++++++++++++-- desktop/src/features/messages/types.ts | 8 ++ .../messages/useImportIdentityBindings.ts | 46 ++++++++++ desktop/src/shared/constants/kinds.ts | 6 ++ 6 files changed, 212 insertions(+), 9 deletions(-) create mode 100644 desktop/src/features/messages/useImportIdentityBindings.ts diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 52b3ae7fb7..522ace6102 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -55,6 +55,7 @@ import { selectTimelineLoadingState, } from "@/features/messages/lib/timelineLoadingState"; import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; +import { useImportIdentityBindings } from "@/features/messages/useImportIdentityBindings"; import { useIndependentThreadPanel } from "@/features/messages/useIndependentThreadPanel"; import { useThreadReplies } from "@/features/messages/useThreadReplies"; import { useChannelTyping } from "@/features/messages/useChannelTyping"; @@ -304,6 +305,14 @@ export function ChannelScreen({ mergeChannelKnownAgentPubkeys(channelMembers, managedAgents, relayAgents), [channelMembers, managedAgents, relayAgents], ); + // Owner-signed import identity bindings; also fetch the bound people's + // profiles so imported history can render under their avatar even when they + // never natively authored an event in this channel. + const importIdentityBindings = useImportIdentityBindings().data; + const boundImportPubkeys = React.useMemo( + () => (importIdentityBindings ? [...importIdentityBindings.values()] : []), + [importIdentityBindings], + ); const messageProfilePubkeys = React.useMemo( () => [ ...new Set([ @@ -311,6 +320,7 @@ export function ChannelScreen({ ...activeDmParticipantPubkeys, ...knownAgentPubkeys, ...typingEntries.map((entry) => entry.pubkey), + ...boundImportPubkeys, ]), ], [ @@ -318,6 +328,7 @@ export function ChannelScreen({ knownAgentPubkeys, messageEventProfilePubkeys, typingEntries, + boundImportPubkeys, ], ); const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, { @@ -398,6 +409,7 @@ export function ChannelScreen({ respondToLookup, relaySelfPubkey, messageOwnerProfiles, + importIdentityBindings, ), [ activeChannel, @@ -410,6 +422,7 @@ export function ChannelScreen({ relaySelfPubkey, respondToLookup, resolvedMessages, + importIdentityBindings, ], ); const threadSummaries: ReadonlyMap = diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs index 926738a60b..d4d25cf3a5 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs +++ b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs @@ -97,6 +97,70 @@ function huddleStarted(overrides = {}) { // late edit/delete for a visible old message would silently render stale. // --------------------------------------------------------------------------- +test("imported message shows import_author name and strips the body prefix", () => { + const imported = streamMessage({ + content: "**Ren Koya**: hello from slack", + tags: [ + ["h", CHANNEL_ID], + ["import", "slack"], + ["import_author", "U060", "Ren Koya"], + ["import_ts", "1700000000.000100"], + ], + }); + const out = formatTimelineMessages([imported], null, undefined, null); + assert.equal(out.length, 1); + assert.equal(out[0].imported, true, "row is flagged imported"); + assert.equal(out[0].author, "Ren Koya", "author from import_author tag"); + assert.equal( + out[0].body, + "hello from slack", + "redundant **Name**: prefix stripped for display", + ); +}); + +test("bound import identity renders under the person's profile", () => { + const imported = streamMessage({ + content: "**Ren Koya**: bound message", + tags: [ + ["h", CHANNEL_ID], + ["import", "slack"], + ["import_author", "U060", "Ren Koya"], + ], + }); + const profiles = { + [PUBKEY_B]: { displayName: "Ren (verified)", avatarUrl: "https://x/a.png" }, + }; + // Binding key is `:`; the `import` tag says source=slack. + const bindings = new Map([["slack:U060", PUBKEY_B]]); + const out = formatTimelineMessages( + [imported], + null, + undefined, + null, + profiles, + undefined, + undefined, + undefined, + undefined, + undefined, + bindings, + ); + assert.equal(out[0].pubkey, PUBKEY_B, "row attributed to the bound pubkey"); + assert.equal(out[0].author, "Ren (verified)", "name from bound profile"); + assert.equal( + out[0].avatarUrl, + "https://x/a.png", + "avatar from bound profile", + ); +}); + +test("native **bold**: content is not stripped on non-imported messages", () => { + const native = streamMessage({ content: "**heads up**: read this" }); + const out = formatTimelineMessages([native], null, undefined, null); + assert.ok(!out[0].imported, "not flagged imported"); + assert.equal(out[0].body, "**heads up**: read this", "native body untouched"); +}); + test("a far-future edit still rewrites the body of an old message", () => { const old = streamMessage({ created_at: 1_700_000_000 }); const lateEdit = streamEdit(HEX64_A, "edited body", { diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 640c12bb75..b8ec0ea640 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -74,6 +74,41 @@ function getDeletionTargets(tags: string[][]) { .map((tag) => tag[1]); } +/** + * The original author of an imported (bot-signed) message, from its + * `["import", ]` and `["import_author", , ]` + * provenance tags. Returns `null` for natively authored messages. + * + * `bindingKey` is `:` (e.g. `slack:U060`), the exact key + * an owner-signed identity binding uses, so callers can look up the bound + * pubkey without knowing the source scheme. + */ +export function getImportAuthor( + tags: string[][], +): { foreignId: string; displayName: string; bindingKey: string } | null { + const tag = tags.find((t) => t[0] === "import_author"); + if (!tag) return null; + const foreignId = typeof tag[1] === "string" ? tag[1] : ""; + if (!foreignId) return null; + const displayName = typeof tag[2] === "string" && tag[2] ? tag[2] : foreignId; + const source = tags.find((t) => t[0] === "import")?.[1]?.trim() || "import"; + return { foreignId, displayName, bindingKey: `${source}:${foreignId}` }; +} + +/** + * Strip a leading `****: ` attribution prefix from an imported body. + * Only strips when the message is imported and the prefix matches the import + * author's name, so native `**bold**:` content is never touched. + */ +export function stripImportAuthorPrefix( + body: string, + importAuthor: { displayName: string } | null, +): string { + if (!importAuthor) return body; + const prefix = `**${importAuthor.displayName}**: `; + return body.startsWith(prefix) ? body.slice(prefix.length) : body; +} + /** * Count the *visible top-level rows* a raw event window would render in the * main channel timeline — the same unit `buildMainTimelineEntries` produces. @@ -196,6 +231,13 @@ export function formatTimelineMessages( relaySelfPubkey?: string | null, /** Profiles for verified agent owners, fetched in one batch by the surface. */ ownerProfiles?: UserProfileLookup, + /** + * Owner/admin-signed import identity bindings: foreign id (e.g. + * `slack:U060…`) → bound Buzz pubkey (lowercase hex). Lets imported history + * render under the real person's profile. Absent → imported rows still show + * the name from their `import_author` tag, just without the bound avatar. + */ + importIdentityBindings?: Map, ): TimelineMessage[] { const currentPubkeyLower = currentPubkey?.toLowerCase(); const roleByPubkey = new Map(); @@ -429,24 +471,42 @@ export function formatTimelineMessages( const authorProfile = profiles?.[authorPubkey.toLowerCase()]; const isAgent = role === "bot" || authorProfile?.isAgent === true; const ownerPubkey = isAgent ? (authorProfile?.ownerPubkey ?? null) : null; + + // Imported (bot-signed) history: attribute to the original person. The + // display name always comes from the `import_author` tag; when an + // owner-signed binding maps that foreign id to a Buzz pubkey, prefer that + // person's profile name/avatar so the row renders as truly theirs. + const importAuthor = getImportAuthor(event.tags); + const boundPubkey = importAuthor + ? importIdentityBindings?.get(importAuthor.bindingKey)?.toLowerCase() + : undefined; + const boundProfile = boundPubkey ? profiles?.[boundPubkey] : undefined; + const displayAuthor = importAuthor + ? boundProfile?.displayName?.trim() || importAuthor.displayName + : author; + const displayAvatarUrl = importAuthor + ? (boundProfile?.avatarUrl ?? null) + : getAuthorAvatarUrl({ + authorPubkey, + currentPubkey, + currentUserAvatarUrl, + profiles, + }); + return { id: event.id, renderKey: event.localKey ?? event.id, createdAt: event.created_at, - pubkey: authorPubkey, + pubkey: boundPubkey ?? authorPubkey, signerPubkey: normalizePubkey(event.pubkey), - author, + author: displayAuthor, + imported: importAuthor !== null, isAgent, ownerPubkey, ownerLabel: isAgent ? formatOwnerLabel(ownerPubkey, currentPubkey, ownerProfiles) : null, - avatarUrl: getAuthorAvatarUrl({ - authorPubkey, - currentPubkey, - currentUserAvatarUrl, - profiles, - }), + avatarUrl: displayAvatarUrl, role, personaDisplayName: role === "bot" @@ -457,7 +517,13 @@ export function formatTimelineMessages( ? respondToLookup?.get(authorPubkey.toLowerCase()) : undefined, time: formatTime(event.created_at), - body: edit ? edit.content : event.content, + // Imported bodies carry a `**Name**: ` prefix for search and for clients + // that don't render `import_author`; strip it here since the row header + // already shows the author. + body: stripImportAuthorPrefix( + edit ? edit.content : event.content, + importAuthor, + ), parentId: thread.parentId, rootId: thread.rootId, depth: getDepth(event), diff --git a/desktop/src/features/messages/types.ts b/desktop/src/features/messages/types.ts index ec656f2236..216b78510d 100644 --- a/desktop/src/features/messages/types.ts +++ b/desktop/src/features/messages/types.ts @@ -49,4 +49,12 @@ export type TimelineMessage = { kind?: number; tags?: string[][]; reactions?: TimelineReaction[]; + /** + * Present on history imported from another workspace (bot-signed, carrying + * an `import_author` tag). `author`/`avatarUrl` are overridden to the + * original person — via an owner-signed identity binding when one exists, + * otherwise the name recorded in the import tag. Clients mark these rows so + * imported history is never mistaken for a live, self-signed message. + */ + imported?: boolean; }; diff --git a/desktop/src/features/messages/useImportIdentityBindings.ts b/desktop/src/features/messages/useImportIdentityBindings.ts new file mode 100644 index 0000000000..3ae20a5981 --- /dev/null +++ b/desktop/src/features/messages/useImportIdentityBindings.ts @@ -0,0 +1,46 @@ +import { useQuery } from "@tanstack/react-query"; + +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_IMPORT_IDENTITY_BINDING } from "@/shared/constants/kinds"; + +/** + * Owner/admin-signed import identity bindings for the active community: + * `:` (e.g. `slack:U060`) → the bound Buzz pubkey + * (lowercase hex). Feeds `formatTimelineMessages` so bot-signed imported + * history renders under the real person's profile. + * + * The relay only stores this kind when signed by an owner/admin, so every + * binding served here is already authoritative — no client-side trust check + * is needed beyond taking the newest binding per key. + */ +const importIdentityBindingsQueryKey = ["import-identity-bindings"] as const; + +function buildBindingMap(events: RelayEvent[]): Map { + // Newest binding per key wins: sort ascending so later writes overwrite. + const ordered = [...events].sort((a, b) => a.created_at - b.created_at); + const map = new Map(); + for (const event of ordered) { + const dTag = event.tags.find((t) => t[0] === "d")?.[1]; + const pubkey = event.tags.find((t) => t[0] === "p")?.[1]; + if (!dTag || !pubkey) continue; + if (pubkey.length !== 64) continue; + map.set(dTag, pubkey.toLowerCase()); + } + return map; +} + +export function useImportIdentityBindings() { + return useQuery({ + queryKey: importIdentityBindingsQueryKey, + queryFn: async () => { + const events = await relayClient.fetchEvents({ + kinds: [KIND_IMPORT_IDENTITY_BINDING], + limit: 1000, + }); + return buildBindingMap(events); + }, + // Bindings change rarely (only when an operator attributes an import). + staleTime: 5 * 60_000, + }); +} diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index ef3234f4c5..73d378c72c 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -70,6 +70,12 @@ export const KIND_GIT_STATUS_DRAFT = 1633; // h-tags = currently-hidden DM channel ids). export const KIND_DM_VISIBILITY = 30622; +// Owner/admin-signed import identity binding: d = `:` +// (e.g. `slack:U060`), one `p` tag = the bound Buzz pubkey. Maps imported +// (bot-signed) history to the real person. Relay accepts it only from a +// community owner/admin, so it can't be used to claim another's history. +export const KIND_IMPORT_IDENTITY_BINDING = 30623; + // Human-visible "new content" message kinds. Used as the unread trigger set // (sidebar badges, catch-up queries) and as the Home-feed mention query. // Reactions, edits, diffs, deletions, and system messages are deliberately From 8ad57ed97aa354b4a1b9781423baa6f617e50673 Mon Sep 17 00:00:00 2001 From: Ren Koya Date: Fri, 24 Jul 2026 14:50:49 +0900 Subject: [PATCH 07/23] docs(slack-import): rewrite for zero-custody binding attribution model Replace mapping-mode / claim-mode sections with the owner-signed identity binding model (kind 30623, public keys only, no takeover), the bind CLI, and the updated relay requirements and limitations. Co-Authored-By: Claude Fable 5 Signed-off-by: Ren Koya --- .../features/channels/ui/ChannelScreen.tsx | 39 ++--- .../channels/useMessageProfilePubkeys.ts | 42 +++++ .../messages/useImportIdentityBindings.ts | 21 ++- docs/slack-import.md | 150 ++++++++---------- 4 files changed, 137 insertions(+), 115 deletions(-) create mode 100644 desktop/src/features/channels/useMessageProfilePubkeys.ts diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 522ace6102..0714d80e2d 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -56,6 +56,7 @@ import { } from "@/features/messages/lib/timelineLoadingState"; import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; import { useImportIdentityBindings } from "@/features/messages/useImportIdentityBindings"; +import { useMessageProfilePubkeys } from "@/features/channels/useMessageProfilePubkeys"; import { useIndependentThreadPanel } from "@/features/messages/useIndependentThreadPanel"; import { useThreadReplies } from "@/features/messages/useThreadReplies"; import { useChannelTyping } from "@/features/messages/useChannelTyping"; @@ -305,32 +306,18 @@ export function ChannelScreen({ mergeChannelKnownAgentPubkeys(channelMembers, managedAgents, relayAgents), [channelMembers, managedAgents, relayAgents], ); - // Owner-signed import identity bindings; also fetch the bound people's - // profiles so imported history can render under their avatar even when they - // never natively authored an event in this channel. - const importIdentityBindings = useImportIdentityBindings().data; - const boundImportPubkeys = React.useMemo( - () => (importIdentityBindings ? [...importIdentityBindings.values()] : []), - [importIdentityBindings], - ); - const messageProfilePubkeys = React.useMemo( - () => [ - ...new Set([ - ...messageEventProfilePubkeys, - ...activeDmParticipantPubkeys, - ...knownAgentPubkeys, - ...typingEntries.map((entry) => entry.pubkey), - ...boundImportPubkeys, - ]), - ], - [ - activeDmParticipantPubkeys, - knownAgentPubkeys, - messageEventProfilePubkeys, - typingEntries, - boundImportPubkeys, - ], - ); + // Owner-signed import identity bindings + the bound people's pubkeys, so + // imported history renders under their avatar even where they never natively + // authored an event in this channel. + const { bindings: importIdentityBindings, boundPubkeys: boundImportPubkeys } = + useImportIdentityBindings(); + const messageProfilePubkeys = useMessageProfilePubkeys({ + messageEventProfilePubkeys, + activeDmParticipantPubkeys, + knownAgentPubkeys, + typingEntries, + boundImportPubkeys, + }); const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, { enabled: messageProfilePubkeys.length > 0, }); diff --git a/desktop/src/features/channels/useMessageProfilePubkeys.ts b/desktop/src/features/channels/useMessageProfilePubkeys.ts new file mode 100644 index 0000000000..b3538116b8 --- /dev/null +++ b/desktop/src/features/channels/useMessageProfilePubkeys.ts @@ -0,0 +1,42 @@ +import React from "react"; + +/** + * The deduped set of pubkeys a channel surface needs profiles for: message + * authors, active-DM participants, known agents, currently-typing users, and + * people bound to imported history (so imported rows can show their avatar + * even where they never natively authored an event). Extracted from + * ChannelScreen to keep that file within its size budget. + */ +export function useMessageProfilePubkeys(input: { + messageEventProfilePubkeys: Iterable; + activeDmParticipantPubkeys: Iterable; + knownAgentPubkeys: Iterable; + typingEntries: Array<{ pubkey: string }>; + boundImportPubkeys: Iterable; +}): string[] { + const { + messageEventProfilePubkeys, + activeDmParticipantPubkeys, + knownAgentPubkeys, + typingEntries, + boundImportPubkeys, + } = input; + return React.useMemo( + () => [ + ...new Set([ + ...messageEventProfilePubkeys, + ...activeDmParticipantPubkeys, + ...knownAgentPubkeys, + ...typingEntries.map((entry) => entry.pubkey), + ...boundImportPubkeys, + ]), + ], + [ + activeDmParticipantPubkeys, + knownAgentPubkeys, + messageEventProfilePubkeys, + typingEntries, + boundImportPubkeys, + ], + ); +} diff --git a/desktop/src/features/messages/useImportIdentityBindings.ts b/desktop/src/features/messages/useImportIdentityBindings.ts index 3ae20a5981..fc864209d3 100644 --- a/desktop/src/features/messages/useImportIdentityBindings.ts +++ b/desktop/src/features/messages/useImportIdentityBindings.ts @@ -1,4 +1,5 @@ import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; import { relayClient } from "@/shared/api/relayClient"; import type { RelayEvent } from "@/shared/api/types"; @@ -30,8 +31,18 @@ function buildBindingMap(events: RelayEvent[]): Map { return map; } -export function useImportIdentityBindings() { - return useQuery({ +const EMPTY_PUBKEYS: string[] = []; + +/** + * Returns the binding map plus the deduped list of bound pubkeys — the latter + * so callers can add those people to their profile batch fetch and render + * imported history under the right avatar. Both are stable across renders. + */ +export function useImportIdentityBindings(): { + bindings: Map | undefined; + boundPubkeys: string[]; +} { + const query = useQuery({ queryKey: importIdentityBindingsQueryKey, queryFn: async () => { const events = await relayClient.fetchEvents({ @@ -43,4 +54,10 @@ export function useImportIdentityBindings() { // Bindings change rarely (only when an operator attributes an import). staleTime: 5 * 60_000, }); + const bindings = query.data; + const boundPubkeys = useMemo( + () => (bindings ? [...bindings.values()] : EMPTY_PUBKEYS), + [bindings], + ); + return { bindings, boundPubkeys }; } diff --git a/docs/slack-import.md b/docs/slack-import.md index 27a62baa96..f6dd96ee8b 100644 --- a/docs/slack-import.md +++ b/docs/slack-import.md @@ -7,9 +7,11 @@ reactions) onto a relay you own, so agents and people can search it as one record from day one. ```bash -buzz import slack --export-dir ./my-workspace-export # bot mode -buzz import slack --export-dir ./export --mapping keys.json # mapping mode -buzz import slack --export-dir ./export --dry-run # plan only +buzz import slack --export-dir ./my-workspace-export # import history +buzz import slack --export-dir ./export --dry-run # plan only +buzz import slack --export-dir ./export \ + --identity-map U060=npub1abc,U081=npub1def # attribute people +buzz import bind --slack-id U060 --pubkey npub1abc # attribute one, later ``` ## What gets imported @@ -19,102 +21,72 @@ buzz import slack --export-dir ./export --dry-run # plan only | Channels (`channels.json`) | kind `9007` create + `9002` topic/purpose | UUID generated per channel, recorded in the state file | | Messages (per-day JSON) | kind `9` stream message, `h`-tagged | `created_at` backdated to the original Slack `ts` | | Threads (`thread_ts`) | NIP-10 `e` reply tags | Slack threads are flat; every reply is a direct reply to the root | -| Reactions | kind `7` | Common shortcodes mapped to Unicode, otherwise `:shortcode:` | -| Users (`users.json`) | kind `0` profiles | Mapping mode only — signed by each user's key | +| Reactions | kind `7` | One bot-signed reaction per distinct emoji (per-reactor identity isn't reproduced) | | Files | Links appended to message content | Blobs are **not** downloaded/re-hosted (see Limitations) | | Custom emoji | — | Use `scripts/grab-emoji.sh` (separate tool, needs a Slack API token) | Every imported event carries provenance tags: -- `["import", "slack"]` — marks the event as imported +- `["import", "slack"]` — marks the event as imported (the ``) - `["import_author", "", ""]` — original author - `["import_ts", ""]` — original microsecond-precision timestamp (Nostr `created_at` is seconds, so this preserves sub-second ordering data) -## Identity modes +## Attribution model — zero key custody, no impersonation -### Bot mode (default) +Every imported event is signed by the **CLI identity** (bot mode). No +private key is ever generated for, or distributed to, anyone. Message +bodies keep a `**Name**: ` prefix (for search and non-Buzz clients) and the +`import_author` tag records the original person. -Everything is signed by the CLI identity (`BUZZ_PRIVATE_KEY`). Message -content is prefixed with the original author's display name -(`**Alice**: …`) so history stays readable; machine-readable attribution -lives in the `import_author` tag. +Real people are attributed by **owner/admin-signed identity bindings** +(kind `30623`, `KIND_IMPORT_IDENTITY_BINDING`) mapping `slack:` to +that person's **public key** (npub or hex): -- Zero key custody — no keys are generated or distributed. -- History is attributed to the importer identity, not to individual people. - -### Mapping mode (`--mapping keys.json`) - -A JSON file maps Slack user IDs to Nostr private keys: - -```json -{ - "U01ABCDEF": { "private_key": "nsec1..." }, - "U02GHIJKL": { "private_key": "<64-char hex>" } -} +```bash +# after people have onboarded and shared their npub (public — not a secret): +buzz import slack --export-dir ./export --identity-map U060=npub1abc,U081=npub1def +# or one at a time, any time later: +buzz import bind --slack-id U060 --pubkey npub1abc ``` -Messages and reactions from mapped users are signed with *their* keys, so -imported history is natively attributable — six months from now, "my -messages" really are that pubkey's messages. Unmapped users (departed -members, bots) fall back to bot-mode signing with the author-name prefix. - -Requirements and behavior: - -- Every event is signed locally with the mapped user's key and submitted - over the single CLI connection. The relay accepts the author/submitter - mismatch because the events carry `import` provenance tags and the CLI - identity is a community owner/admin (see the exemption below) — the - event's own Schnorr signature proves authorship. Mapped keys never need - to be live relay members to import. -- The importer still best-effort registers mapped users as relay members - (kind `9030`) and channel members (kind `9000`) so their history is - readable to them the moment they log in with their key. -- A kind `0` profile (display name, avatar URL from `users.json`) is - published for each mapped user unless `--skip-profiles` is set. - -**Key custody warning:** whoever produces `keys.json` holds every mapped -user's private key until it is handed over. Generate keys on one machine, -deliver each `nsec` to its person over a secure channel — Buzz's NIP-AB -pairing (`buzz-pair-relay`) is designed for exactly this one-time key -transfer — and destroy the mapping file after import. Prefer generating the -mapping *with* each user present when the team is small. - -### Claim mode (future work) - -The zero-custody end state, not yet implemented: - -1. Each person onboards in Buzz normally (key generated on-device, never - leaves it). -2. The importer (as a Slack app) DMs each member a one-time claim token — - receiving the token proves control of the Slack account; signing the - claim proves control of the Buzz key. Neither email infrastructure nor - key distribution is required. -3. Each person runs `buzz import slack --claim ` against the - shared export, signing only their own messages locally. - -This needs a shared cross-run message-ID ledger (replies must reference -event IDs of messages signed by *other* users' claims) — the state-file -design below anticipates it, but multi-party coordination is out of scope -for v1. Fallback hierarchy for unclaimed users stays the same: bot-signed -with attribution tags. +The Buzz client reads these bindings and renders imported history under the +bound person's profile (name + avatar); unbound history still shows the +`import_author` name. + +Why this is safe: + +- **Zero custody.** Only public keys (npubs) are handled. Nothing secret is + generated or distributed, so there is no `keys.json` to leak. +- **No account takeover.** The relay stores a binding **only when signed by + a community owner or admin**. A member cannot publish + `slack:U060 → their own pubkey` to seize someone else's history — the + exact migration risk this design closes. +- **No third-party signatures.** Every stored event is signed by its + submitter; there is no path for one key to post as another. +- Display names remain freely editable (Slack-like); the binding ties a + Slack id to a **pubkey**, independent of display name. Verified handles + are a separate layer (NIP-05). + +How each person's own key comes to exist (no distribution): they onboard in +Buzz via an invite link — the key is generated on their device and never +leaves it — then share their **npub** (public) with the operator, who +publishes the binding. ## Relay requirements -**The CLI identity must be a community owner or admin.** The relay -normally rejects events whose `created_at` is more than 15 minutes in the -past, and events whose author differs from the authenticated submitter. -Both checks carry an authorized-import exemption: an event with an -`import` provenance tag, submitted by an authenticated community -owner/admin, may be backdated and may be third-party-signed (its Schnorr -signature proves authorship). No relay restart or configuration change is -needed — the operator's own auth *is* the authorization, scoped per event. - -Under the hood the exemption also disarms the DB commit-time floor guard -(migration 0021) for exactly those inserts, and on read-replica -deployments it closes the replica fence until a fresh handshake provably -covers the backfilled rows — degraded read capacity during the import, -never missing rows. Single-instance deployments are unaffected. +**The CLI identity must be a community owner or admin.** Two relay checks +matter for imports: + +- Backdating: the relay rejects `created_at` more than 15 minutes in the + past, *except* for `import`-tagged events submitted by an owner/admin — + no restart or config change; the operator's auth is the authorization, + scoped per event. (Under the hood this also disarms the DB commit-time + floor guard for those inserts, and on read-replica deployments closes the + replica fence until a fresh handshake covers the backfilled rows — + degraded read capacity during the import, never missing rows. + Single-instance deployments are unaffected.) +- Identity bindings (kind `30623`): accepted **only** from an owner/admin. Two optional knobs for the import window: @@ -171,8 +143,9 @@ who was ever @-mentioned in Slack. Imported mentions render as plain are appended as links. A future `--download-files` could fetch blobs with a Slack token and re-upload via Blossom, rewriting links. - **DMs and private channels are not imported.** Standard Slack exports - only contain public channels; DM import also raises consent questions - that belong with the claim-mode design. + only contain public channels. +- **Per-reactor identity in reactions is not preserved.** Bot mode signs + one reaction per distinct emoji (a key can react to a target only once). - **Reaction timestamps are synthetic** (message `ts + 1s`) — Slack exports don't record when a reaction was added. - **Sub-second ordering may flatten.** Two messages inside the same second @@ -186,14 +159,17 @@ who was ever @-mentioned in Slack. Imported mentions render as plain ``` buzz import slack --export-dir unzipped Slack export directory (required) - --mapping Slack user id → private key JSON (mapping mode) --state state file (default: /buzz-import-state.json) --channels import only these channel names --dry-run parse and report what would be imported; no writes --skip-reactions do not import reactions - --skip-profiles do not publish kind 0 profiles for mapped users + --identity-map SLACKID=npub-or-hex,… owner-signed bindings (public keys only) + +buzz import bind + --slack-id Slack user id (e.g. U060976D0QN) + --pubkey the person's PUBLIC key (never an nsec) ``` Output follows CLI conventions: progress on stderr, a final JSON summary on stdout (`channels_created`, `messages_imported`, `reactions_imported`, -`skipped`, `warnings`). +`bindings_published`, `skipped`, `warnings`). From 6d918cd26db141f32372ede956c2a440d5bd3704 Mon Sep 17 00:00:00 2001 From: Ren Koya Date: Fri, 24 Jul 2026 15:37:03 +0900 Subject: [PATCH 08/23] feat: two-party consent for import identity bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attributing imported Slack history to a real person previously took a single owner/admin-signed attestation (kind 30623). That fully trusts the admin: an admin could bind any Slack id to any pubkey — including making an existing member appear to have authored history they never wrote. "No account takeover" was really only "no takeover by a regular member." Attribution now requires TWO signatures that must agree: - Attestation (kind 30623, owner/admin): slack: -> pubkey. Unchanged authz. - Claim (kind 30624, NEW, self-signed by the subject): consent for the same slack:. No p tag — the signer IS the subject, so the relay's signer==author rule means you can only ever claim for yourself; any member may publish one (no admin role), but it is inert without a matching attestation. A binding is confirmed only when attestation.p == claim.author for the same key. Either half alone attributes nothing: - member alone can't seize history (no admin attestation), and - admin alone can't forge authorship onto a member (no subject claim) — the vector a single signature couldn't close. Enforcement is the desktop join (attribution is display-only; no relay/mobile/ web materialization consumes bindings). Residual, documented: a colluding admin + consenting pubkey can still attribute orphan history; inherent without a Slack-side oracle, and import_author provenance stays immutable regardless. - buzz-core: KIND_IMPORT_IDENTITY_CLAIM (30624) + all-kinds list. - buzz-sdk: build_import_identity_claim; two-party doc on the binding builder. - buzz-relay: accept 30624 at members-write scope (no admin gate), validate_import_identity_claim (non-empty d, reject p tag). Comments updated. - buzz-cli: 'buzz import claim --slack-id'; help/module docs for both halves. - desktop: pure buildConfirmedImportBindings join (unit-tested) behind the hook, which now fetches both kinds and returns confirmed pairs only — same Map shape, so formatTimelineMessages is unchanged. - docs/slack-import.md: two-party model, residual trust, CLI reference. Gates: fmt + clippy clean; buzz-core/sdk/cli/relay unit tests green (new identity_claim shape/scope + sdk builder + desktop join tests); desktop 726 message tests, biome, tsc clean. Co-Authored-By: Claude Fable 5 Signed-off-by: Ren Koya --- crates/buzz-cli/src/commands/import.rs | 45 ++++++-- crates/buzz-cli/src/lib.rs | 35 ++++-- crates/buzz-core/src/kind.rs | 35 ++++-- crates/buzz-relay/src/handlers/ingest.rs | 108 ++++++++++++++++-- crates/buzz-sdk/src/builders.rs | 81 ++++++++++++- .../lib/confirmImportBindings.test.mjs | 92 +++++++++++++++ .../messages/lib/confirmImportBindings.ts | 55 +++++++++ .../messages/useImportIdentityBindings.ts | 46 +++----- desktop/src/shared/constants/kinds.ts | 15 ++- docs/slack-import.md | 70 ++++++++---- 10 files changed, 490 insertions(+), 92 deletions(-) create mode 100644 desktop/src/features/messages/lib/confirmImportBindings.test.mjs create mode 100644 desktop/src/features/messages/lib/confirmImportBindings.ts diff --git a/crates/buzz-cli/src/commands/import.rs b/crates/buzz-cli/src/commands/import.rs index ff6338bea4..db7a0bcd4b 100644 --- a/crates/buzz-cli/src/commands/import.rs +++ b/crates/buzz-cli/src/commands/import.rs @@ -3,15 +3,25 @@ //! v1 supports Slack workspace exports; see `docs/slack-import.md` for the //! full design (attribution model, security, limitations). //! -//! ## Attribution model (zero key custody) +//! ## Attribution model (zero key custody, two-party consent) //! //! Every imported event is signed by the CLI identity (bot mode) and carries //! `import`/`import_author`/`import_ts` provenance tags. Real people are -//! attributed by **owner/admin-signed identity bindings** (kind -//! `KIND_IMPORT_IDENTITY_BINDING`) that map a Slack user id to that person's -//! own Buzz pubkey — using **public keys only**. No private key is ever -//! generated for or distributed to anyone, and because only an owner/admin -//! can publish a binding, nobody can claim another person's imported history. +//! attributed by a **two-party identity binding**, using **public keys only** — +//! no private key is ever generated for or distributed to anyone: +//! +//! 1. An owner/admin **attestation** (kind `KIND_IMPORT_IDENTITY_BINDING`) +//! mapping a Slack user id to a person's Buzz pubkey — `buzz import bind` / +//! `--identity-map`. +//! 2. The subject's own **claim** (kind `KIND_IMPORT_IDENTITY_CLAIM`), +//! self-signed with their key — `buzz import claim`. +//! +//! History renders under the real person only when both exist for the same +//! Slack id and the attestation's pubkey equals the claim's signer. So a member +//! cannot claim another person's history (no admin attestation), and an admin +//! cannot make someone appear to author history they never wrote (no subject +//! claim). See `docs/slack-import.md` for the residual trust in a colluding +//! admin + subject. mod export; mod mrkdwn; @@ -116,7 +126,9 @@ pub async fn cmd_import_slack(client: &BuzzClient, p: ImportSlackParams) -> Resu importer.finish() } -/// Publish a single owner/admin-signed identity binding. +/// Publish the owner/admin half of a two-party binding: an attestation that +/// `slack_id` maps to `pubkey`. Inert until the subject also runs +/// `cmd_import_claim` with their own key. pub async fn cmd_import_bind( client: &BuzzClient, slack_id: &str, @@ -132,6 +144,24 @@ pub async fn cmd_import_bind( })) } +/// Publish the subject half of a two-party binding: the caller's self-signed +/// consent to being attributed `slack_id`. Signed by the CLI identity, so the +/// person whose history it is runs this with their own key. Inert until a +/// community owner/admin has published the matching attestation for this +/// pubkey. +pub async fn cmd_import_claim(client: &BuzzClient, slack_id: &str) -> Result<(), CliError> { + let d_tag = buzz_sdk::slack_identity_binding_d_tag(slack_id); + let builder = buzz_sdk::build_import_identity_claim(&d_tag) + .map_err(|e| CliError::Other(format!("build_import_identity_claim failed: {e}")))?; + let event_id = submit(client, builder).await?; + print_json(&serde_json::json!({ + "event_id": event_id, + "slack_id": slack_id, + "pubkey": client.keys().public_key().to_hex(), + "accepted": true, + })) +} + /// Parse a `SLACKID=key,SLACKID=key` list into `(slack_id, pubkey_hex)` pairs. /// Each key may be an `npub1…` or a 64-char hex pubkey — **public keys only**. fn parse_identity_map(spec: Option<&str>) -> Result, CliError> { @@ -707,6 +737,7 @@ pub async fn dispatch(cmd: crate::ImportCmd, client: &BuzzClient) -> Result<(), crate::ImportCmd::Bind { slack_id, pubkey } => { cmd_import_bind(client, &slack_id, &pubkey).await } + crate::ImportCmd::Claim { slack_id } => cmd_import_claim(client, &slack_id).await, } } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index e4bf3d725f..55290f9cab 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -930,10 +930,11 @@ pub enum ImportCmd { after_help = "History is signed by the CLI identity (bot mode) and tagged with the \ original author (import_author) and timestamp. No private key is ever \ generated for or distributed to anyone.\n\n\ -To attribute history to real people, publish owner/admin-signed identity \ -bindings mapping a Slack user id to that person's own PUBLIC key (npub or \ -hex) — via --identity-map here, or `buzz import bind` later. A member cannot \ -claim another person's history: only an owner/admin can publish a binding.\n\n\ +Attributing history to a real person takes TWO signatures: an owner/admin \ +attestation (Slack id → that person's PUBLIC key) via --identity-map here or \ +`buzz import bind`, AND the person's own consent via `buzz import claim`, run \ +by them with their key. An attestation alone does not attribute history, so an \ +admin cannot make someone appear to author messages they never wrote.\n\n\ Re-running resumes from the state file — completed writes are skipped.\n\n\ Examples:\n \ buzz import slack --export-dir ./export --dry-run\n \ @@ -961,11 +962,12 @@ buzz import slack --export-dir ./export --identity-map U060=npub1abc,U081=npub1d #[arg(long)] identity_map: Option, }, - /// Publish one owner/admin-signed identity binding (Slack id → public key) + /// Attest that a Slack id maps to a person's public key (owner/admin half) #[command( - after_help = "Attributes imported history to a real person. The pubkey is PUBLIC \ -(npub or hex) — never an nsec. Requires the CLI identity to be a community \ -owner or admin.\n\nExample:\n \ + after_help = "The owner/admin half of a two-party identity binding. The pubkey is \ +PUBLIC (npub or hex) — never an nsec. Requires the CLI identity to be a \ +community owner or admin. History is attributed only once the person also runs \ +`buzz import claim` with their own key.\n\nExample:\n \ buzz import bind --slack-id U060976D0QN --pubkey npub1abc..." )] Bind { @@ -976,6 +978,19 @@ buzz import bind --slack-id U060976D0QN --pubkey npub1abc..." #[arg(long)] pubkey: String, }, + /// Consent to being attributed a Slack id — the subject half of a binding + #[command( + after_help = "The subject half of a two-party identity binding. Run this yourself, \ +with your own key: it self-signs your consent that the Slack id is you. It \ +attributes history only once a community owner/admin has published the \ +matching `buzz import bind` attestation for your pubkey.\n\nExample:\n \ +buzz import claim --slack-id U060976D0QN" + )] + Claim { + /// Your Slack user id (e.g. U060976D0QN) + #[arg(long)] + slack_id: String, + }, } #[derive(Subcommand)] @@ -1989,7 +2004,7 @@ mod tests { vec!["approve", "create", "delete", "get", "list", "runs", "trigger", "update"] ); assert_eq!(names(&cmd, "feed"), vec!["get"]); - assert_eq!(names(&cmd, "import"), vec!["bind", "slack"]); + assert_eq!(names(&cmd, "import"), vec!["bind", "claim", "slack"]); assert_eq!( names(&cmd, "social"), vec![ @@ -2060,7 +2075,7 @@ mod tests { ("dms", 4), ("emoji", 5), ("feed", 1), - ("import", 2), + ("import", 3), ("issues", 4), ("media", 1), ("messages", 8), diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index fcd70dd72c..ed92042faa 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -312,17 +312,33 @@ pub const KIND_WORKFLOW_DEF: u32 = 30620; /// `hidden_at` per viewer; this is the only Nostr-visible projection of it. pub const KIND_DM_VISIBILITY: u32 = 30622; -/// Import identity binding: an owner/admin-signed attestation that a foreign -/// workspace identity (e.g. a Slack user id) belongs to a given Buzz pubkey. -/// Parameterized-replaceable, `d = :` (e.g. -/// `slack:U060976D0QN`), with a single `["p", ]` naming the bound -/// identity. The relay accepts this kind ONLY from a community owner or admin -/// (mirrors the kind:9030 relay-admin authorization), so a member cannot claim -/// another person's imported history — the whole point of the binding. Clients -/// read these to render `import_author`-tagged history under the bound pubkey's -/// profile. It carries public keys only; no secret ever transits. +/// Import identity binding: an owner/admin-signed **attestation** that a +/// foreign workspace identity (e.g. a Slack user id) belongs to a given Buzz +/// pubkey. Parameterized-replaceable, `d = :` (e.g. +/// `slack:U060976D0QN`), with a single `["p", ]` naming the +/// attested identity. The relay accepts this kind ONLY from a community owner +/// or admin (mirrors the kind:9030 relay-admin authorization). +/// +/// This is one half of a two-party binding: the attestation alone does NOT +/// attribute history. Attribution requires a matching [`KIND_IMPORT_IDENTITY_CLAIM`] +/// self-signed by the attested pubkey, so an admin cannot unilaterally make a +/// member appear to author imported history. It carries public keys only; no +/// secret ever transits. pub const KIND_IMPORT_IDENTITY_BINDING: u32 = 30623; +/// Import identity claim: the **subject's** self-signed consent to being +/// attributed a foreign workspace identity. Parameterized-replaceable, +/// `d = :` (same key as the matching +/// [`KIND_IMPORT_IDENTITY_BINDING`] attestation). The signer's own pubkey IS +/// the consent — there is no `p` tag — so the relay's signer==author rule means +/// a pubkey can only ever claim on its own behalf; no special role is required. +/// +/// A binding is *confirmed* (and history rendered under the real person) only +/// when an owner/admin attestation and a subject claim exist for the same +/// `d` key and the attestation's `p` equals the claim's author. Either half +/// alone is inert. +pub const KIND_IMPORT_IDENTITY_CLAIM: u32 = 30624; + /// Lower bound of the NIP-33 parameterized replaceable range (30000–39999). pub const PARAM_REPLACEABLE_KIND_MIN: u32 = 30000; /// Upper bound of the NIP-33 parameterized replaceable range (30000–39999). @@ -627,6 +643,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_IMPORT_IDENTITY_BINDING, + KIND_IMPORT_IDENTITY_CLAIM, ]; /// Returns `true` if `kind` is in the ephemeral range (20000–29999). diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index f8c2f1909b..8fd1fbca51 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -21,15 +21,16 @@ use buzz_core::kind::{ KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, - KIND_IA_UNARCHIVE_REQUEST, KIND_IMPORT_IDENTITY_BINDING, KIND_LONG_FORM, KIND_MANAGED_AGENT, - KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, - KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, - KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, - KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, - KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, - KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, - KIND_PRESENCE_UPDATE, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, - KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, + KIND_IA_UNARCHIVE_REQUEST, KIND_IMPORT_IDENTITY_BINDING, KIND_IMPORT_IDENTITY_CLAIM, + KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, + KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, + KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, + KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, + KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, + KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, + KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, + KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, + KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, @@ -304,6 +305,10 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::AdminUsers), + // Import identity claim — the subject's self-signed consent. Any + // authenticated member may publish one, but only for themselves: the + // relay's signer==author rule means the claim's author IS the consent. + KIND_IMPORT_IDENTITY_CLAIM => Ok(Scope::MessagesWrite), _ => Err("restricted: unknown event kind"), } } @@ -359,6 +364,38 @@ fn validate_import_identity_binding(event: &Event) -> Result<(), IngestError> { } } +/// Validate the shape of a `KIND_IMPORT_IDENTITY_CLAIM` event before storage. +/// +/// Requires a non-empty `d` tag (the `:` key matching the +/// attestation) and **no** `p` tag: a claim's consent is its own signature, so +/// the signer's author pubkey is the bound identity. Rejecting a `p` tag keeps +/// the wire form unambiguous — a claim can never appear to speak for a pubkey +/// other than its signer. No role check: any member may claim on their own +/// behalf (the caller enforces signer==author). +fn validate_import_identity_claim(event: &Event) -> Result<(), IngestError> { + let has_nonempty_d = event.tags.iter().any(|t| { + let parts = t.as_slice(); + parts.first().map(|s| s.as_str()) == Some("d") + && parts.get(1).is_some_and(|s| !s.is_empty()) + }); + if !has_nonempty_d { + return Err(IngestError::Rejected( + "invalid: identity claim requires a non-empty d tag (e.g. slack:U123)".into(), + )); + } + if event + .tags + .iter() + .any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("p")) + { + return Err(IngestError::Rejected( + "invalid: identity claim must not carry a p tag — the signer is the bound identity" + .into(), + )); + } + Ok(()) +} + /// Extract a channel UUID from the `"h"` NIP-29 group tag. pub(crate) fn extract_channel_id(event: &Event) -> Option { for tag in event.tags.iter() { @@ -1552,8 +1589,9 @@ async fn ingest_event_inner( // operator), scoped per event instead of relay-wide, with no restart. // Note: the event is still signed by the submitter (bot mode) — the // pubkey==submitter check below is NOT waived; attribution to real people - // is carried by `import_author` tags plus owner-signed identity bindings - // (kind KIND_IMPORT_IDENTITY_BINDING), never by third-party signatures. + // is carried by `import_author` tags plus a two-party binding (an + // owner/admin KIND_IMPORT_IDENTITY_BINDING attestation AND the subject's + // own KIND_IMPORT_IDENTITY_CLAIM), never by third-party signatures. let import_exempt = has_import_tag(&event) && caller_is_community_admin; // Identity bindings are owner/admin-only: this is what stops a member from @@ -1568,6 +1606,15 @@ async fn ingest_event_inner( validate_import_identity_binding(&event)?; } + // Identity claims are the subject's own consent: no role gate (the + // signer==author check below guarantees a claim only ever speaks for its + // signer), just a shape check. A binding attributes history only when an + // owner/admin attestation and a matching subject claim agree — neither + // half alone does anything. + if kind_u32 == KIND_IMPORT_IDENTITY_CLAIM { + validate_import_identity_claim(&event)?; + } + // Future drift is a fixed bound — a future timestamp is always a clock // error or a forgery. Past drift is operator-tunable // (BUZZ_MAX_PAST_DRIFT_SECS, default 900) so history imports can replay @@ -2793,6 +2840,45 @@ mod tests { assert!(validate_import_identity_binding(&two_p).is_err()); } + #[test] + fn identity_claim_shape_validation() { + use buzz_core::kind::KIND_IMPORT_IDENTITY_CLAIM; + let pk = "8f3904246ba9d9cc7e821e7752e123d435234d17c2513d85785f4a0b1ca07e56"; + let keys = nostr::Keys::generate(); + let build = |tags: Vec| { + EventBuilder::new(Kind::Custom(KIND_IMPORT_IDENTITY_CLAIM as u16), "") + .tags(tags) + .sign_with_keys(&keys) + .expect("sign claim") + }; + let tag = |parts: &[&str]| nostr::Tag::parse(parts.iter().copied()).expect("tag"); + + // Well-formed: non-empty d, no p tag. + let ok = build(vec![tag(&["d", "slack:U1"])]); + assert!(validate_import_identity_claim(&ok).is_ok()); + + // Missing / empty d tag. + assert!(validate_import_identity_claim(&build(vec![])).is_err()); + assert!(validate_import_identity_claim(&build(vec![tag(&["d", ""])])).is_err()); + + // A p tag is rejected: a claim must never appear to speak for another + // pubkey — the signer is the bound identity. + let with_p = build(vec![tag(&["d", "slack:U1"]), tag(&["p", pk])]); + assert!(validate_import_identity_claim(&with_p).is_err()); + } + + #[test] + fn identity_claim_uses_members_write_scope() { + use buzz_core::kind::KIND_IMPORT_IDENTITY_CLAIM; + let dummy = make_dummy_event(); + // Any member may claim on their own behalf — no admin scope, unlike the + // owner/admin-only attestation (KIND_IMPORT_IDENTITY_BINDING). + assert_eq!( + required_scope_for_kind(KIND_IMPORT_IDENTITY_CLAIM, &dummy).unwrap(), + Scope::MessagesWrite, + ); + } + #[test] fn import_tag_detection() { let keys = nostr::Keys::generate(); diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 265eb27a93..10a2dcecee 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -10,9 +10,9 @@ use buzz_core::{ KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, - KIND_IMPORT_IDENTITY_BINDING, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, - KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, - KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + KIND_IMPORT_IDENTITY_BINDING, KIND_IMPORT_IDENTITY_CLAIM, KIND_MODERATION_BAN, + KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, + KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -562,6 +562,25 @@ pub fn build_import_identity_binding( Ok(EventBuilder::new(Kind::Custom(KIND_IMPORT_IDENTITY_BINDING as u16), "").tags(tags)) } +/// Build a subject-signed import identity claim (kind 30624). +/// +/// The consent half of a two-party binding: the signer asserts that the +/// foreign workspace identity keyed by `d_tag` (e.g. `slack:U060976D0QN`, via +/// [`slack_identity_binding_d_tag`]) is them. The signer's own pubkey is the +/// consent, so there is no `p` tag — sign this with the subject's key. It only +/// takes effect paired with a matching owner/admin +/// [`build_import_identity_binding`] attestation naming the same pubkey. +/// Parameterized-replaceable on `d_tag`. +pub fn build_import_identity_claim(d_tag: &str) -> Result { + if d_tag.trim().is_empty() { + return Err(SdkError::InvalidInput( + "identity claim d tag must not be empty".into(), + )); + } + let tags = vec![tag(&["d", d_tag])?, tag(&["import", "slack"])?]; + Ok(EventBuilder::new(Kind::Custom(KIND_IMPORT_IDENTITY_CLAIM as u16), "").tags(tags)) +} + /// Build a NIP-01 profile metadata event (kind 0). /// /// Only present (Some) fields are included in the JSON object. @@ -3815,4 +3834,60 @@ mod tests { .iter() .any(|t| t.as_slice().first().map(String::as_str) == Some("replaced-by"))); } + + #[test] + fn identity_binding_carries_d_and_p_but_no_secret() { + let pk = keys().public_key().to_hex(); + let d = slack_identity_binding_d_tag("U060976D0QN"); + assert_eq!(d, "slack:U060976D0QN"); + let ev = sign(build_import_identity_binding(&d, &pk).unwrap()); + assert_eq!(ev.kind.as_u16() as u32, KIND_IMPORT_IDENTITY_BINDING); + let get = |k: &str| { + ev.tags + .iter() + .find(|t| t.as_slice().first().map(String::as_str) == Some(k)) + .and_then(|t| t.as_slice().get(1).cloned()) + }; + assert_eq!(get("d").as_deref(), Some(d.as_str())); + assert_eq!(get("p").as_deref(), Some(pk.as_str())); + } + + #[test] + fn identity_binding_rejects_empty_d_and_bad_pubkey() { + let pk = keys().public_key().to_hex(); + assert!(matches!( + build_import_identity_binding("", &pk), + Err(SdkError::InvalidInput(_)) + )); + assert!(build_import_identity_binding("slack:U1", "not-a-pubkey").is_err()); + } + + #[test] + fn identity_claim_is_self_signed_with_no_p_tag() { + // The claim's consent is the signature itself — it must NOT name a + // pubkey, so nobody can craft a claim "for" someone else. + let d = slack_identity_binding_d_tag("U1"); + let ev = sign(build_import_identity_claim(&d).unwrap()); + assert_eq!(ev.kind.as_u16() as u32, KIND_IMPORT_IDENTITY_CLAIM); + assert_eq!( + ev.tags + .iter() + .find(|t| t.as_slice().first().map(String::as_str) == Some("d")) + .and_then(|t| t.as_slice().get(1).cloned()) + .as_deref(), + Some(d.as_str()) + ); + assert!(!ev + .tags + .iter() + .any(|t| t.as_slice().first().map(String::as_str) == Some("p"))); + } + + #[test] + fn identity_claim_rejects_empty_d() { + assert!(matches!( + build_import_identity_claim(" "), + Err(SdkError::InvalidInput(_)) + )); + } } diff --git a/desktop/src/features/messages/lib/confirmImportBindings.test.mjs b/desktop/src/features/messages/lib/confirmImportBindings.test.mjs new file mode 100644 index 0000000000..207c0a403c --- /dev/null +++ b/desktop/src/features/messages/lib/confirmImportBindings.test.mjs @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildConfirmedImportBindings } from "./confirmImportBindings.ts"; +import { + KIND_IMPORT_IDENTITY_BINDING, + KIND_IMPORT_IDENTITY_CLAIM, +} from "@/shared/constants/kinds"; + +const ALICE = + "1111111111111111111111111111111111111111111111111111111111111111"; +const MALLORY = + "2222222222222222222222222222222222222222222222222222222222222222"; + +let clock = 1000; +function attestation(dTag, pubkey) { + return { + kind: KIND_IMPORT_IDENTITY_BINDING, + pubkey: "admin".padEnd(64, "0"), + created_at: clock++, + tags: [ + ["d", dTag], + ["p", pubkey], + ], + }; +} +function claim(dTag, author) { + return { + kind: KIND_IMPORT_IDENTITY_CLAIM, + pubkey: author, + created_at: clock++, + tags: [["d", dTag]], + }; +} + +test("confirms only when attestation and claim agree", () => { + const map = buildConfirmedImportBindings([ + attestation("slack:U1", ALICE), + claim("slack:U1", ALICE), + ]); + assert.deepEqual([...map], [["slack:U1", ALICE]]); +}); + +test("attestation alone does not attribute (admin cannot forge authorship)", () => { + const map = buildConfirmedImportBindings([attestation("slack:U1", ALICE)]); + assert.equal(map.size, 0); +}); + +test("claim alone does not attribute (member cannot grab unvouched history)", () => { + const map = buildConfirmedImportBindings([claim("slack:U1", MALLORY)]); + assert.equal(map.size, 0); +}); + +test("a claim by a different pubkey than the attestation is rejected", () => { + // Admin attests U1 -> ALICE, but MALLORY is the one who claimed U1. + const map = buildConfirmedImportBindings([ + attestation("slack:U1", ALICE), + claim("slack:U1", MALLORY), + ]); + assert.equal(map.size, 0); +}); + +test("newest attestation wins, and needs a claim matching the new pubkey", () => { + // Admin re-attests U1 from ALICE to MALLORY; only ALICE had claimed it, so + // the superseded pubkey's claim must not confirm the new attestation. + const map = buildConfirmedImportBindings([ + attestation("slack:U1", ALICE), + claim("slack:U1", ALICE), + attestation("slack:U1", MALLORY), + ]); + assert.equal(map.size, 0); + + // Once MALLORY also claims, it confirms under the newest pubkey. + const map2 = buildConfirmedImportBindings([ + attestation("slack:U1", ALICE), + claim("slack:U1", ALICE), + attestation("slack:U1", MALLORY), + claim("slack:U1", MALLORY), + ]); + assert.deepEqual([...map2], [["slack:U1", MALLORY]]); +}); + +test("bound pubkey is lowercased on both sides before matching", () => { + const upper = "ABCDEF".padEnd(64, "0"); // contains hex letters + const lower = upper.toLowerCase(); + const map = buildConfirmedImportBindings([ + attestation("slack:U1", upper), // attestation p tag upper-cased + claim("slack:U1", lower), // claim author lower-cased + ]); + // Case must not defeat the match, and the stored key is lowercase. + assert.deepEqual([...map], [["slack:U1", lower]]); +}); diff --git a/desktop/src/features/messages/lib/confirmImportBindings.ts b/desktop/src/features/messages/lib/confirmImportBindings.ts new file mode 100644 index 0000000000..a7a9ffa009 --- /dev/null +++ b/desktop/src/features/messages/lib/confirmImportBindings.ts @@ -0,0 +1,55 @@ +import type { RelayEvent } from "@/shared/api/types"; +import { + KIND_IMPORT_IDENTITY_BINDING, + KIND_IMPORT_IDENTITY_CLAIM, +} from "@/shared/constants/kinds"; + +/** + * Two-party import identity binding join (pure — no React, so it is unit + * tested directly). + * + * Given a mix of owner/admin **attestations** (kind + * `KIND_IMPORT_IDENTITY_BINDING`, `p` = attested pubkey) and subject + * **claims** (kind `KIND_IMPORT_IDENTITY_CLAIM`, self-signed, no `p`), returns + * `:` (e.g. `slack:U060`) → bound pubkey (lowercase hex) + * ONLY where an attestation and a claim agree: the attested pubkey must equal + * the claim's author for the same key. + * + * This is the trust boundary. An attestation with no matching claim (admin + * asserting an unconsented mapping) and a claim with no matching attestation + * (a member asserting an unvouched one) are both dropped — neither can + * unilaterally attribute imported history to a person. + */ +export function buildConfirmedImportBindings( + events: RelayEvent[], +): Map { + // Attestations are parameterized-replaceable: newest per key wins, so sort + // ascending and let later writes overwrite. + const ordered = [...events].sort((a, b) => a.created_at - b.created_at); + + // slack: -> attested pubkey (owner/admin-signed, relay-gated). + const attested = new Map(); + // Self-signed consents, keyed `slack:#`. + const claimed = new Set(); + + for (const event of ordered) { + const dTag = event.tags.find((t) => t[0] === "d")?.[1]; + if (!dTag) continue; + + if (event.kind === KIND_IMPORT_IDENTITY_BINDING) { + const pubkey = event.tags.find((t) => t[0] === "p")?.[1]; + if (pubkey?.length !== 64) continue; + attested.set(dTag, pubkey.toLowerCase()); + } else if (event.kind === KIND_IMPORT_IDENTITY_CLAIM) { + // The claim's consent is its signature: the author pubkey is the subject. + if (event.pubkey.length !== 64) continue; + claimed.add(`${dTag}#${event.pubkey.toLowerCase()}`); + } + } + + const confirmed = new Map(); + for (const [dTag, pubkey] of attested) { + if (claimed.has(`${dTag}#${pubkey}`)) confirmed.set(dTag, pubkey); + } + return confirmed; +} diff --git a/desktop/src/features/messages/useImportIdentityBindings.ts b/desktop/src/features/messages/useImportIdentityBindings.ts index fc864209d3..cf0e387f63 100644 --- a/desktop/src/features/messages/useImportIdentityBindings.ts +++ b/desktop/src/features/messages/useImportIdentityBindings.ts @@ -2,41 +2,32 @@ import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { relayClient } from "@/shared/api/relayClient"; -import type { RelayEvent } from "@/shared/api/types"; -import { KIND_IMPORT_IDENTITY_BINDING } from "@/shared/constants/kinds"; +import { + KIND_IMPORT_IDENTITY_BINDING, + KIND_IMPORT_IDENTITY_CLAIM, +} from "@/shared/constants/kinds"; +import { buildConfirmedImportBindings } from "./lib/confirmImportBindings"; /** - * Owner/admin-signed import identity bindings for the active community: + * Confirmed import identity bindings for the active community: * `:` (e.g. `slack:U060`) → the bound Buzz pubkey * (lowercase hex). Feeds `formatTimelineMessages` so bot-signed imported * history renders under the real person's profile. * - * The relay only stores this kind when signed by an owner/admin, so every - * binding served here is already authoritative — no client-side trust check - * is needed beyond taking the newest binding per key. + * Attribution is two-party (see {@link buildConfirmedImportBindings}): a key is + * confirmed only when an owner/admin attestation and the subject's own claim + * agree. So a member can't attest another person's history, and an admin can't + * make someone appear to author history they never wrote. */ const importIdentityBindingsQueryKey = ["import-identity-bindings"] as const; -function buildBindingMap(events: RelayEvent[]): Map { - // Newest binding per key wins: sort ascending so later writes overwrite. - const ordered = [...events].sort((a, b) => a.created_at - b.created_at); - const map = new Map(); - for (const event of ordered) { - const dTag = event.tags.find((t) => t[0] === "d")?.[1]; - const pubkey = event.tags.find((t) => t[0] === "p")?.[1]; - if (!dTag || !pubkey) continue; - if (pubkey.length !== 64) continue; - map.set(dTag, pubkey.toLowerCase()); - } - return map; -} - const EMPTY_PUBKEYS: string[] = []; /** - * Returns the binding map plus the deduped list of bound pubkeys — the latter - * so callers can add those people to their profile batch fetch and render - * imported history under the right avatar. Both are stable across renders. + * Returns the confirmed binding map plus the deduped list of bound pubkeys — + * the latter so callers can add those people to their profile batch fetch and + * render imported history under the right avatar. Both are stable across + * renders. */ export function useImportIdentityBindings(): { bindings: Map | undefined; @@ -46,12 +37,13 @@ export function useImportIdentityBindings(): { queryKey: importIdentityBindingsQueryKey, queryFn: async () => { const events = await relayClient.fetchEvents({ - kinds: [KIND_IMPORT_IDENTITY_BINDING], - limit: 1000, + kinds: [KIND_IMPORT_IDENTITY_BINDING, KIND_IMPORT_IDENTITY_CLAIM], + limit: 2000, }); - return buildBindingMap(events); + return buildConfirmedImportBindings(events); }, - // Bindings change rarely (only when an operator attributes an import). + // Bindings change rarely (only when an operator attributes an import or a + // person consents). staleTime: 5 * 60_000, }); const bindings = query.data; diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 73d378c72c..683156c691 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -70,12 +70,19 @@ export const KIND_GIT_STATUS_DRAFT = 1633; // h-tags = currently-hidden DM channel ids). export const KIND_DM_VISIBILITY = 30622; -// Owner/admin-signed import identity binding: d = `:` -// (e.g. `slack:U060`), one `p` tag = the bound Buzz pubkey. Maps imported -// (bot-signed) history to the real person. Relay accepts it only from a -// community owner/admin, so it can't be used to claim another's history. +// Import identity binding — the owner/admin attestation half: d = +// `:` (e.g. `slack:U060`), one `p` tag = the attested Buzz +// pubkey. Relay accepts it only from a community owner/admin. On its own it +// does NOT attribute history — a matching subject claim is also required. export const KIND_IMPORT_IDENTITY_BINDING = 30623; +// Import identity claim — the subject's self-signed consent half: d = +// `:` (same key as the attestation), no `p` tag. The +// signer's own pubkey is the consent. Imported history renders under the real +// person only when an attestation and a claim agree (attestation `p` === claim +// author) for the same key. +export const KIND_IMPORT_IDENTITY_CLAIM = 30624; + // Human-visible "new content" message kinds. Used as the unread trigger set // (sidebar badges, catch-up queries) and as the Home-feed mention query. // Reactions, edits, diffs, deletions, and system messages are deliberately diff --git a/docs/slack-import.md b/docs/slack-import.md index f6dd96ee8b..e08f2782bf 100644 --- a/docs/slack-import.md +++ b/docs/slack-import.md @@ -10,8 +10,9 @@ record from day one. buzz import slack --export-dir ./my-workspace-export # import history buzz import slack --export-dir ./export --dry-run # plan only buzz import slack --export-dir ./export \ - --identity-map U060=npub1abc,U081=npub1def # attribute people -buzz import bind --slack-id U060 --pubkey npub1abc # attribute one, later + --identity-map U060=npub1abc,U081=npub1def # admin attests people +buzz import bind --slack-id U060 --pubkey npub1abc # admin attests one, later +buzz import claim --slack-id U060 # the person consents (their key) ``` ## What gets imported @@ -32,46 +33,67 @@ Every imported event carries provenance tags: - `["import_ts", ""]` — original microsecond-precision timestamp (Nostr `created_at` is seconds, so this preserves sub-second ordering data) -## Attribution model — zero key custody, no impersonation +## Attribution model — zero key custody, two-party consent Every imported event is signed by the **CLI identity** (bot mode). No private key is ever generated for, or distributed to, anyone. Message bodies keep a `**Name**: ` prefix (for search and non-Buzz clients) and the `import_author` tag records the original person. -Real people are attributed by **owner/admin-signed identity bindings** -(kind `30623`, `KIND_IMPORT_IDENTITY_BINDING`) mapping `slack:` to -that person's **public key** (npub or hex): +Attributing history to a real person takes **two signatures** — an admin +and the person — so neither side can do it alone: + +1. **Attestation** (kind `30623`, `KIND_IMPORT_IDENTITY_BINDING`) — a + community owner/admin signs `slack:`. The relay + accepts this kind only from an owner/admin. +2. **Claim** (kind `30624`, `KIND_IMPORT_IDENTITY_CLAIM`) — the person signs + their own consent for the same `slack:` with their key. It has + no `p` tag: the signer *is* the subject, and the relay's signer==author + rule means you can only ever claim for yourself. ```bash -# after people have onboarded and shared their npub (public — not a secret): +# admin attests (npub is public — not a secret): buzz import slack --export-dir ./export --identity-map U060=npub1abc,U081=npub1def -# or one at a time, any time later: -buzz import bind --slack-id U060 --pubkey npub1abc +buzz import bind --slack-id U060 --pubkey npub1abc # or one at a time, later + +# the person consents, run by them with their own key: +buzz import claim --slack-id U060 ``` -The Buzz client reads these bindings and renders imported history under the -bound person's profile (name + avatar); unbound history still shows the -`import_author` name. +The Buzz client renders imported history under the real person's profile +(name + avatar) **only when both exist and agree** — the attestation's +pubkey equals the claim's signer for the same `slack:`. Either half +alone is inert; unconfirmed history still shows the `import_author` name. Why this is safe: - **Zero custody.** Only public keys (npubs) are handled. Nothing secret is generated or distributed, so there is no `keys.json` to leak. -- **No account takeover.** The relay stores a binding **only when signed by - a community owner or admin**. A member cannot publish - `slack:U060 → their own pubkey` to seize someone else's history — the - exact migration risk this design closes. +- **A member can't seize history.** A claim without a matching owner/admin + attestation attributes nothing — a member cannot map + `slack:U060 → their own pubkey` to grab someone else's history. +- **An admin can't forge authorship.** An attestation without the subject's + own claim attributes nothing either — an admin cannot make an existing + member appear to have written imported messages. This is the vector a + single admin signature could not close. - **No third-party signatures.** Every stored event is signed by its submitter; there is no path for one key to post as another. - Display names remain freely editable (Slack-like); the binding ties a Slack id to a **pubkey**, independent of display name. Verified handles are a separate layer (NIP-05). +**Residual trust.** A *colluding* owner/admin and a consenting pubkey can +still attribute orphaned history to that pubkey (both sign). This is inherent +without a Slack-side oracle to prove who really owned `slack:`; the +consenting party is publicly volunteering, and the immutable `import_author` +provenance on every event records the original Slack identity regardless. +What two-party consent removes is the *unilateral* admin — the realistic +insider risk before production. + How each person's own key comes to exist (no distribution): they onboard in Buzz via an invite link — the key is generated on their device and never -leaves it — then share their **npub** (public) with the operator, who -publishes the binding. +leaves it — then share their **npub** (public) with the operator for the +attestation, and run `buzz import claim` to consent. ## Relay requirements @@ -86,7 +108,10 @@ matter for imports: replica fence until a fresh handshake covers the backfilled rows — degraded read capacity during the import, never missing rows. Single-instance deployments are unaffected.) -- Identity bindings (kind `30623`): accepted **only** from an owner/admin. +- Identity **attestations** (kind `30623`): accepted **only** from an + owner/admin. Identity **claims** (kind `30624`) are self-signed and + accepted from any member — but only for their own pubkey, and inert without + a matching attestation. Two optional knobs for the import window: @@ -163,11 +188,14 @@ buzz import slack --channels import only these channel names --dry-run parse and report what would be imported; no writes --skip-reactions do not import reactions - --identity-map SLACKID=npub-or-hex,… owner-signed bindings (public keys only) + --identity-map SLACKID=npub-or-hex,… admin attestations (public keys only) -buzz import bind +buzz import bind # owner/admin half: attest a Slack id → public key --slack-id Slack user id (e.g. U060976D0QN) --pubkey the person's PUBLIC key (never an nsec) + +buzz import claim # subject half: consent, run by the person with their key + --slack-id your Slack user id (e.g. U060976D0QN) ``` Output follows CLI conventions: progress on stderr, a final JSON summary on From 700fb188845140d1f062bf46b3eebc5c997727bb Mon Sep 17 00:00:00 2001 From: Ren Koya Date: Fri, 24 Jul 2026 15:53:29 +0900 Subject: [PATCH 09/23] =?UTF-8?q?feat(migrate):=20claim-service=20core=20?= =?UTF-8?q?=E2=80=94=20magic-link=20tokens,=20roster,=20attest=20publish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `buzz-migrate` crate: the operator claim-service that automates the owner/admin half of a two-party import identity binding, so a large team migrates with zero per-person operator work and no account-takeover. This first slice is the security-critical, fully-unit-tested core (no web surface yet): - token.rs — email magic-link tokens (HMAC-SHA256, single-use ledger, expiry, constant-time MAC compare). Tokens carry only subject+exp+nonce, never a pubkey: the pubkey is supplied by the recipient's own app on the deep-link, which defeats a phishing-start takeover. Residual bearer risk is bounded to standard magic-link mitigations (short TTL, single use, inbox-only delivery), all documented at the module head. - roster.rs — Slack export users.json → email↔slack: and name lookups (case-insensitive email, deactivated users excluded). - attest.rs — sign+publish the kind 30623 attestation with the operator's admin key via buzz-ws-client. Public-key-only, NIP-33-revocable; inert without each subject's own claim. 15 unit tests, clippy + fmt clean. Email/OIDC HTTP endpoints and the binary land in follow-up commits. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Ren Koya --- Cargo.toml | 1 + crates/buzz-migrate/Cargo.toml | 28 +++ crates/buzz-migrate/src/attest.rs | 55 +++++ crates/buzz-migrate/src/lib.rs | 35 +++ crates/buzz-migrate/src/roster.rs | 138 +++++++++++ crates/buzz-migrate/src/token.rs | 366 ++++++++++++++++++++++++++++++ 6 files changed, 623 insertions(+) create mode 100644 crates/buzz-migrate/Cargo.toml create mode 100644 crates/buzz-migrate/src/attest.rs create mode 100644 crates/buzz-migrate/src/lib.rs create mode 100644 crates/buzz-migrate/src/roster.rs create mode 100644 crates/buzz-migrate/src/token.rs diff --git a/Cargo.toml b/Cargo.toml index 3499285f91..80d8e97f3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "crates/buzz-workflow", "crates/buzz-media", "crates/buzz-cli", + "crates/buzz-migrate", "crates/buzz-pairing-cli", "crates/buzz-sdk", "crates/buzz-persona", diff --git a/crates/buzz-migrate/Cargo.toml b/crates/buzz-migrate/Cargo.toml new file mode 100644 index 0000000000..0c3300bb2e --- /dev/null +++ b/crates/buzz-migrate/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "buzz-migrate" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Operator claim-service for zero-touch, no-takeover Slack→Buzz identity migration" + +[lib] +name = "buzz_migrate" +path = "src/lib.rs" + +[dependencies] +buzz-sdk = { workspace = true } +buzz-ws-client = { workspace = true } +buzz-core = { workspace = true } +nostr = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +hmac = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +subtle = { workspace = true } +rand = { workspace = true } diff --git a/crates/buzz-migrate/src/attest.rs b/crates/buzz-migrate/src/attest.rs new file mode 100644 index 0000000000..eb7315a3a2 --- /dev/null +++ b/crates/buzz-migrate/src/attest.rs @@ -0,0 +1,55 @@ +//! Publishing the owner/admin **attestation** (kind 30623) that a verified +//! claim produces. +//! +//! This is the one place the service uses the operator's admin key. It signs a +//! single `KIND_IMPORT_IDENTITY_BINDING` event mapping the proven +//! `subject` (`slack:`) to the claimant's Buzz pubkey, then publishes it to +//! the relay. The event is public-key-only and parameterized-replaceable, so a +//! mistaken or superseded attestation can be overwritten (NIP-33) or revoked by +//! the operator later — the service never mints anything irreversible. +//! +//! The attestation is only *half* of a binding: nothing is attributed until the +//! claimant's own `KIND_IMPORT_IDENTITY_CLAIM` (self-signed) also exists. So a +//! stolen admin key can, at worst, publish attestations that stay inert without +//! each subject's separate consent. + +use nostr::{Keys, Tag}; + +/// Why publishing an attestation failed. +#[derive(Debug, thiserror::Error)] +pub enum AttestError { + #[error("could not build attestation: {0}")] + Build(String), + #[error("could not sign attestation: {0}")] + Sign(String), + #[error("relay rejected the attestation: {0}")] + Rejected(String), + #[error(transparent)] + Transport(#[from] buzz_ws_client::WsClientError), +} + +/// Sign and publish the attestation `subject → bound_pubkey_hex` with the +/// operator's `admin` key. Returns the published event id on success. +/// +/// `subject` is the binding key (`slack:`); `bound_pubkey_hex` is the +/// claimant's 64-char hex pubkey. `auth_tag` carries the relay's community +/// scope when one is required (same tag the CLI injects). +pub async fn publish_attestation( + relay_url: &str, + admin: &Keys, + subject: &str, + bound_pubkey_hex: &str, + auth_tag: Option<&Tag>, +) -> Result { + let builder = buzz_sdk::build_import_identity_binding(subject, bound_pubkey_hex) + .map_err(|e| AttestError::Build(e.to_string()))?; + let event = builder + .sign_with_keys(admin) + .map_err(|e| AttestError::Sign(e.to_string()))?; + let event_id = event.id.to_hex(); + let ok = buzz_ws_client::publish_event(relay_url, event, admin, auth_tag, 75).await?; + if !ok.accepted { + return Err(AttestError::Rejected(ok.message)); + } + Ok(event_id) +} diff --git a/crates/buzz-migrate/src/lib.rs b/crates/buzz-migrate/src/lib.rs new file mode 100644 index 0000000000..ae060e9209 --- /dev/null +++ b/crates/buzz-migrate/src/lib.rs @@ -0,0 +1,35 @@ +//! `buzz-migrate` — the operator claim-service for zero-touch, no-takeover +//! Slack→Buzz identity migration. +//! +//! # The problem it solves +//! +//! History imported by `buzz import slack` is bot-signed and attributed only by +//! display name. To render each person's imported history under their real Buzz +//! profile, a **two-party binding** must exist for `slack:`: +//! +//! 1. an owner/admin **attestation** (kind `KIND_IMPORT_IDENTITY_BINDING`), and +//! 2. the subject's self-signed **claim** (kind `KIND_IMPORT_IDENTITY_CLAIM`). +//! +//! Doing (1) by hand (`buzz import bind`) is O(N) manual work for a large team, +//! and manual matching is exactly where account-takeover mistakes creep in. +//! This service automates (1): it proves *which* Slack user a person is, then +//! publishes the attestation for them — so the operator does zero per-person +//! work and no one can seize another person's history. +//! +//! # Two proof channels +//! +//! - **Email magic-link** ([`token`], [`roster`]): the operator's export knows +//! each user's email; a single-use, short-TTL token mailed to that address +//! proves control of it, hence of the Slack user. See [`token`] for the exact +//! threat model (why the token carries no pubkey). +//! - **Sign in with Slack (OIDC)**: the person authenticates to Slack live; the +//! service reads back the verified user id. (Built on top of this core in the +//! service layer.) +//! +//! Either way the outcome is one call to [`attest::publish_attestation`], and +//! the person's own client publishes the matching claim. Everything the service +//! signs is public-key-only and NIP-33-revocable. + +pub mod attest; +pub mod roster; +pub mod token; diff --git a/crates/buzz-migrate/src/roster.rs b/crates/buzz-migrate/src/roster.rs new file mode 100644 index 0000000000..5ae7c5ffae --- /dev/null +++ b/crates/buzz-migrate/src/roster.rs @@ -0,0 +1,138 @@ +//! The Slack export roster: the trusted mapping from an email address to a +//! Slack user id, loaded from `users.json`. +//! +//! This mapping is what makes the email channel an *identity* proof rather than +//! just an email-ownership proof: the operator's own export says "this email +//! belongs to Slack user U060", so proving control of the email proves control +//! of U060. The roster is loaded once at service start from the same export the +//! history import used. + +use serde::Deserialize; +use std::collections::HashMap; + +#[derive(Deserialize)] +struct RawUser { + id: String, + #[serde(default)] + deleted: bool, + #[serde(default)] + profile: RawProfile, +} + +#[derive(Default, Deserialize)] +struct RawProfile { + #[serde(default)] + email: String, + #[serde(default)] + display_name: String, + #[serde(default)] + real_name: String, +} + +/// Email→subject and subject→name lookups for the active migration. +#[derive(Debug, Default, Clone)] +pub struct Roster { + /// lowercased, trimmed email → `slack:`. + email_to_subject: HashMap, + /// `slack:` → best human-readable name (for email copy). + subject_to_name: HashMap, +} + +impl Roster { + /// Build a roster from the bytes of a Slack export `users.json`. + /// + /// Deactivated (`deleted`) users are skipped: their email can no longer + /// receive the magic link, so they can only be attributed by the OIDC + /// channel or a manual `buzz import bind`. + pub fn from_users_json(bytes: &[u8]) -> Result { + let users: Vec = serde_json::from_slice(bytes)?; + let mut email_to_subject = HashMap::new(); + let mut subject_to_name = HashMap::new(); + for u in users { + if u.deleted { + continue; + } + let subject = format!("slack:{}", u.id); + let name = if !u.profile.display_name.is_empty() { + u.profile.display_name + } else if !u.profile.real_name.is_empty() { + u.profile.real_name + } else { + u.id.clone() + }; + subject_to_name.insert(subject.clone(), name); + let email = u.profile.email.trim().to_lowercase(); + if !email.is_empty() { + email_to_subject.insert(email, subject); + } + } + Ok(Self { + email_to_subject, + subject_to_name, + }) + } + + /// The `slack:` subject for an email, if the export knows it. Matching + /// is case-insensitive and whitespace-trimmed. + pub fn subject_for_email(&self, email: &str) -> Option<&str> { + self.email_to_subject + .get(email.trim().to_lowercase().as_str()) + .map(String::as_str) + } + + /// The display name for a subject, for personalizing the email. + pub fn name_for_subject(&self, subject: &str) -> Option<&str> { + self.subject_to_name.get(subject).map(String::as_str) + } + + /// Number of mailable (non-deactivated, has-email) users. + pub fn mailable_count(&self) -> usize { + self.email_to_subject.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const USERS: &str = r#"[ + {"id":"U060","profile":{"email":"Alice@Corp.com","display_name":"Alice"}}, + {"id":"U081","profile":{"email":" bob@corp.com ","real_name":"Bob B"}}, + {"id":"U099","deleted":true,"profile":{"email":"ghost@corp.com","display_name":"Ghost"}}, + {"id":"U100","profile":{"display_name":"NoEmail"}} + ]"#; + + #[test] + fn maps_email_to_subject_case_insensitively() { + let r = Roster::from_users_json(USERS.as_bytes()).unwrap(); + assert_eq!(r.subject_for_email("alice@corp.com"), Some("slack:U060")); + // Original casing and surrounding whitespace both normalize. + assert_eq!(r.subject_for_email(" BOB@CORP.COM "), Some("slack:U081")); + } + + #[test] + fn deactivated_users_are_excluded() { + let r = Roster::from_users_json(USERS.as_bytes()).unwrap(); + assert_eq!(r.subject_for_email("ghost@corp.com"), None); + } + + #[test] + fn users_without_email_are_not_mailable_but_named() { + let r = Roster::from_users_json(USERS.as_bytes()).unwrap(); + assert_eq!(r.mailable_count(), 2); // U060, U081 + assert_eq!(r.name_for_subject("slack:U100"), Some("NoEmail")); + } + + #[test] + fn best_name_falls_back_display_then_real_then_id() { + let r = Roster::from_users_json(USERS.as_bytes()).unwrap(); + assert_eq!(r.name_for_subject("slack:U060"), Some("Alice")); + assert_eq!(r.name_for_subject("slack:U081"), Some("Bob B")); + } + + #[test] + fn unknown_email_is_none() { + let r = Roster::from_users_json(USERS.as_bytes()).unwrap(); + assert_eq!(r.subject_for_email("nobody@corp.com"), None); + } +} diff --git a/crates/buzz-migrate/src/token.rs b/crates/buzz-migrate/src/token.rs new file mode 100644 index 0000000000..755659cd10 --- /dev/null +++ b/crates/buzz-migrate/src/token.rs @@ -0,0 +1,366 @@ +//! Email magic-link tokens — the email-channel proof primitive. +//! +//! A token attests one thing: *whoever received this token controls the inbox +//! it was mailed to.* Because the operator mails it to the address Slack has on +//! file for a given user, holding the token proves control of that Slack user's +//! email — the identity proof for the email claim channel. +//! +//! # What a token deliberately does NOT carry +//! +//! A token binds only `subject` (`slack:`), an expiry, and a random +//! nonce. It does **not** carry a Buzz pubkey. The pubkey is supplied later, by +//! the app that opens the deep link — i.e. the recipient's own client, using +//! its own key. This closes a phishing-takeover: an attacker who calls +//! `/email/start` for `victim@corp` only causes an email to land in the +//! victim's inbox; if the victim clicks it, the binding is completed with the +//! *victim's* key (from the victim's app), never a key the attacker chose. The +//! token cannot smuggle an attacker pubkey because it holds no pubkey at all. +//! +//! # Residual risk (accepted, standard for magic links) +//! +//! A token is a bearer secret: anyone who reads it before it is used or expires +//! can complete the email proof. Mitigation is the same as every magic-link +//! login — short TTL, single use, and delivery only to the real inbox. Single +//! use is enforced by the caller (see [`ConsumedNonces`]); expiry and integrity +//! are enforced here. + +use hmac::digest::KeyInit; +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use std::collections::HashMap; +use subtle::ConstantTimeEq; + +type HmacSha256 = Hmac; + +/// A minted, still-opaque token as it travels in a magic-link URL. +/// +/// Wire form (all ASCII, URL-safe, `.`-delimited): +/// `v1....` +/// +/// `subject` is hex-encoded so its bytes can never collide with the `.` +/// delimiter, whatever a foreign workspace allows in a user id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MagicToken(String); + +impl MagicToken { + /// The token's wire string (put this in the magic-link `?token=` query). + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Parse a wire string without verifying it. Verification happens in + /// [`verify`]; this is only for transport. + pub fn from_wire(s: impl Into) -> Self { + Self(s.into()) + } +} + +/// The verified contents of a token: the foreign identity it proves control of. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedToken { + /// `:`, e.g. `slack:U060976D0QN`. + pub subject: String, + /// Unix seconds after which the token is invalid. + pub exp: u64, + /// Per-token random nonce — the single-use key (see [`ConsumedNonces`]). + pub nonce: String, +} + +/// Why a token failed verification. All variants are safe to surface to the +/// clicker (they reveal nothing about the secret). +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum TokenError { + #[error("malformed token")] + Malformed, + #[error("token signature is invalid")] + BadSignature, + #[error("token has expired")] + Expired, + #[error("token has already been used")] + AlreadyUsed, +} + +/// Serializable payload; the exact bytes the MAC is computed over. Field order +/// and JSON shape are the signed message — do not reorder. +#[derive(Serialize, Deserialize)] +struct Payload<'a> { + v: u8, + subject: &'a str, + exp: u64, + nonce: &'a str, +} + +fn mac_hex(secret: &[u8], subject: &str, exp: u64, nonce: &str) -> String { + // Canonical signed message. `serde_json` on a fixed-shape struct is + // deterministic here (fixed field order, no maps), so the bytes are stable. + let payload = Payload { + v: 1, + subject, + exp, + nonce, + }; + let msg = serde_json::to_vec(&payload).expect("payload serializes"); + let mut mac = + ::new_from_slice(secret).expect("hmac accepts any key length"); + mac.update(&msg); + hex::encode(mac.finalize().into_bytes()) +} + +/// Mint a token for `subject`, valid for `ttl_secs` from `now`. +/// +/// `secret` is the service's signing key (keep it out of any public event). +/// `nonce_bytes` are fresh random bytes — 16 is plenty; the caller supplies +/// them so this stays pure and testable. +pub fn mint( + secret: &[u8], + subject: &str, + now: u64, + ttl_secs: u64, + nonce_bytes: &[u8], +) -> MagicToken { + let exp = now.saturating_add(ttl_secs); + let nonce = hex::encode(nonce_bytes); + let mac = mac_hex(secret, subject, exp, &nonce); + MagicToken(format!( + "v1.{}.{}.{}.{}", + hex::encode(subject.as_bytes()), + exp, + nonce, + mac + )) +} + +/// Verify a token's integrity and expiry (NOT single use — that is +/// [`ConsumedNonces::try_consume`]). Returns the proven subject on success. +/// +/// The MAC comparison is constant-time, so a forger cannot learn the correct +/// signature byte-by-byte from timing. +pub fn verify(secret: &[u8], token: &MagicToken, now: u64) -> Result { + let mut parts = token.0.split('.'); + let (Some(v), Some(subject_hex), Some(exp_s), Some(nonce), Some(mac), None) = ( + parts.next(), + parts.next(), + parts.next(), + parts.next(), + parts.next(), + parts.next(), + ) else { + return Err(TokenError::Malformed); + }; + if v != "v1" { + return Err(TokenError::Malformed); + } + let subject_bytes = hex::decode(subject_hex).map_err(|_| TokenError::Malformed)?; + let subject = String::from_utf8(subject_bytes).map_err(|_| TokenError::Malformed)?; + let exp: u64 = exp_s.parse().map_err(|_| TokenError::Malformed)?; + // Nonce must be hex; reject anything else so the single-use key is clean. + if nonce.is_empty() || hex::decode(nonce).is_err() { + return Err(TokenError::Malformed); + } + + let expected = mac_hex(secret, &subject, exp, nonce); + // Constant-time compare over equal-length hex strings. + let ok: bool = expected.as_bytes().ct_eq(mac.as_bytes()).into(); + if !ok { + return Err(TokenError::BadSignature); + } + if now > exp { + return Err(TokenError::Expired); + } + Ok(VerifiedToken { + subject, + exp, + nonce: nonce.to_string(), + }) +} + +/// In-memory single-use ledger keyed by token nonce, with lazy expiry so it +/// cannot grow without bound. +/// +/// This is process-local. A single claim-service instance is the intended +/// deployment; a multi-instance operator must back single use with shared +/// storage (documented in the service README) or the same token could be +/// redeemed once per instance. +#[derive(Debug, Default)] +pub struct ConsumedNonces { + /// nonce -> the token's expiry, so used entries can be swept after expiry. + used: HashMap, +} + +impl ConsumedNonces { + pub fn new() -> Self { + Self::default() + } + + /// Atomically mark a verified token used. Returns `AlreadyUsed` if the + /// nonce was consumed before. Call this only after [`verify`] succeeds. + pub fn try_consume(&mut self, token: &VerifiedToken, now: u64) -> Result<(), TokenError> { + self.sweep(now); + if self.used.contains_key(&token.nonce) { + return Err(TokenError::AlreadyUsed); + } + self.used.insert(token.nonce.clone(), token.exp); + Ok(()) + } + + /// Drop entries whose tokens have expired — a used token past its expiry + /// can never be presented again validly, so its ledger entry is dead weight. + fn sweep(&mut self, now: u64) { + self.used.retain(|_, &mut exp| exp >= now); + } + + #[cfg(test)] + fn len(&self) -> usize { + self.used.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SECRET: &[u8] = b"test-signing-secret-32-bytes-long!!"; + const NONCE: &[u8] = &[7u8; 16]; + const SUBJECT: &str = "slack:U060976D0QN"; + + #[test] + fn roundtrip_verifies_and_returns_subject() { + let t = mint(SECRET, SUBJECT, 1_000, 3_600, NONCE); + let v = verify(SECRET, &t, 1_500).expect("valid"); + assert_eq!(v.subject, SUBJECT); + assert_eq!(v.exp, 4_600); + } + + #[test] + fn wire_form_survives_transport() { + let t = mint(SECRET, SUBJECT, 1_000, 3_600, NONCE); + let reparsed = MagicToken::from_wire(t.as_str().to_string()); + assert!(verify(SECRET, &reparsed, 1_500).is_ok()); + } + + #[test] + fn wrong_secret_is_rejected() { + let t = mint(SECRET, SUBJECT, 1_000, 3_600, NONCE); + assert_eq!( + verify(b"a-different-secret", &t, 1_500), + Err(TokenError::BadSignature) + ); + } + + #[test] + fn tampered_subject_is_rejected() { + let t = mint(SECRET, SUBJECT, 1_000, 3_600, NONCE); + // Re-encode a different subject with the original exp/nonce/mac. + let mut parts: Vec<&str> = t.as_str().split('.').collect(); + let evil = hex::encode("slack:UATTACKER".as_bytes()); + parts[1] = &evil; + let forged = MagicToken::from_wire(parts.join(".")); + assert_eq!( + verify(SECRET, &forged, 1_500), + Err(TokenError::BadSignature) + ); + } + + #[test] + fn tampered_expiry_is_rejected() { + let t = mint(SECRET, SUBJECT, 1_000, 10, NONCE); // exp = 1010 + let mut parts: Vec<&str> = t.as_str().split('.').collect(); + parts[2] = "9999999999"; // extend expiry + let forged = MagicToken::from_wire(parts.join(".")); + assert_eq!( + verify(SECRET, &forged, 1_500), + Err(TokenError::BadSignature) + ); + } + + #[test] + fn expired_token_is_rejected() { + let t = mint(SECRET, SUBJECT, 1_000, 10, NONCE); // exp = 1010 + assert_eq!(verify(SECRET, &t, 1_011), Err(TokenError::Expired)); + // Exactly at expiry is still valid. + assert!(verify(SECRET, &t, 1_010).is_ok()); + } + + #[test] + fn malformed_tokens_are_rejected() { + for bad in [ + "", + "v1", + "v1.aa.bb", + "v2.aa.100.bb.cc", // wrong version + "v1.zz.100.bb.cc", // subject not hex + "v1.aa.notanum.bb.cc", // exp not numeric + "v1.616263.100..cc", // empty nonce + "v1.616263.100.nothex.cc", // nonce not hex + "v1.616263.100.bb.cc.extra", // trailing segment + ] { + let r = verify(SECRET, &MagicToken::from_wire(bad), 50); + assert!( + matches!( + r, + Err(TokenError::Malformed) | Err(TokenError::BadSignature) + ), + "expected reject for {bad:?}, got {r:?}" + ); + } + } + + #[test] + fn single_use_is_enforced_once() { + let t = mint(SECRET, SUBJECT, 1_000, 3_600, NONCE); + let v = verify(SECRET, &t, 1_500).unwrap(); + let mut ledger = ConsumedNonces::new(); + assert_eq!(ledger.try_consume(&v, 1_500), Ok(())); + assert_eq!( + ledger.try_consume(&v, 1_500), + Err(TokenError::AlreadyUsed), + "a token must not redeem twice" + ); + } + + #[test] + fn distinct_nonces_are_independent() { + let a = verify( + SECRET, + &mint(SECRET, SUBJECT, 1_000, 3_600, &[1u8; 16]), + 1_500, + ) + .unwrap(); + let b = verify( + SECRET, + &mint(SECRET, SUBJECT, 1_000, 3_600, &[2u8; 16]), + 1_500, + ) + .unwrap(); + let mut ledger = ConsumedNonces::new(); + assert_eq!(ledger.try_consume(&a, 1_500), Ok(())); + assert_eq!( + ledger.try_consume(&b, 1_500), + Ok(()), + "a different token for the same subject is still usable" + ); + } + + #[test] + fn ledger_sweeps_expired_entries() { + let v = verify(SECRET, &mint(SECRET, SUBJECT, 1_000, 10, NONCE), 1_005).unwrap(); // exp 1010 + let mut ledger = ConsumedNonces::new(); + ledger.try_consume(&v, 1_005).unwrap(); + assert_eq!(ledger.len(), 1); + // A later consume of some other token triggers a sweep that drops the + // now-expired entry. + let other = verify( + SECRET, + &mint(SECRET, "slack:U2", 2_000, 10, &[9u8; 16]), + 2_001, + ) + .unwrap(); + ledger.try_consume(&other, 2_001).unwrap(); + assert_eq!( + ledger.len(), + 1, + "expired entry swept, only the fresh one left" + ); + } +} From 3ff0f91f0b3e28a5d47e926cf23257ce1a3f7b3e Mon Sep 17 00:00:00 2001 From: Ren Koya Date: Fri, 24 Jul 2026 15:59:23 +0900 Subject: [PATCH 10/23] feat(migrate): email-channel HTTP service + buzz-migrate binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the claim-service HTTP surface (email proof channel) and the operator binary that runs it. server.rs — three endpoints: - POST /email/start {email}: mint a magic link for the Slack user that email belongs to and (dev) return it. Takes no pubkey and answers identically for known/unknown addresses, so it neither enumerates the workspace nor lets a caller smuggle an attacker pubkey. - GET /email/verify?token: validate integrity+expiry, hand off to the recipient's app via a buzz://import-claim deep link (no consume). - POST /email/complete {token, pubkey}: the app calls this with its own key; re-verify, atomically consume (single use), publish the kind 30623 attestation subject→pubkey. Inert until the app's own self-claim exists. The redeem path (verify + single-use consume) is factored out and unit tested without a relay; error mapping distinguishes forged (400) from expired/used (410). main.rs — `buzz-migrate` binary: loads the export roster, admin key (hex/nsec), optional NIP-OA auth tag, token secret (random + warn if unset), and serves on --bind. http→ws relay URL conversion. 21 unit tests, clippy + fmt clean. OIDC channel and desktop deep-link handler follow. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Ren Koya --- crates/buzz-migrate/Cargo.toml | 9 + crates/buzz-migrate/src/lib.rs | 1 + crates/buzz-migrate/src/main.rs | 151 ++++++++++++ crates/buzz-migrate/src/server.rs | 392 ++++++++++++++++++++++++++++++ 4 files changed, 553 insertions(+) create mode 100644 crates/buzz-migrate/src/main.rs create mode 100644 crates/buzz-migrate/src/server.rs diff --git a/crates/buzz-migrate/Cargo.toml b/crates/buzz-migrate/Cargo.toml index 0c3300bb2e..38b71dfac4 100644 --- a/crates/buzz-migrate/Cargo.toml +++ b/crates/buzz-migrate/Cargo.toml @@ -11,6 +11,10 @@ description = "Operator claim-service for zero-touch, no-takeover Slack→Buzz i name = "buzz_migrate" path = "src/lib.rs" +[[bin]] +name = "buzz-migrate" +path = "src/main.rs" + [dependencies] buzz-sdk = { workspace = true } buzz-ws-client = { workspace = true } @@ -21,8 +25,13 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +tracing-subscriber = { workspace = true } hmac = { workspace = true } sha2 = { workspace = true } hex = { workspace = true } subtle = { workspace = true } rand = { workspace = true } +axum = { workspace = true } +reqwest = { workspace = true } +url = { workspace = true } +clap = { version = "4", features = ["derive", "env"] } diff --git a/crates/buzz-migrate/src/lib.rs b/crates/buzz-migrate/src/lib.rs index ae060e9209..c843bd7a21 100644 --- a/crates/buzz-migrate/src/lib.rs +++ b/crates/buzz-migrate/src/lib.rs @@ -32,4 +32,5 @@ pub mod attest; pub mod roster; +pub mod server; pub mod token; diff --git a/crates/buzz-migrate/src/main.rs b/crates/buzz-migrate/src/main.rs new file mode 100644 index 0000000000..3228ba1a7f --- /dev/null +++ b/crates/buzz-migrate/src/main.rs @@ -0,0 +1,151 @@ +//! `buzz-migrate serve` — the operator claim-service binary. +//! +//! Loads the Slack export roster, holds the operator's admin key, and serves +//! the claim HTTP surface. It automates the owner/admin attestation half of a +//! two-party import identity binding so a team migrates with no per-person +//! operator work and no account-takeover. See the crate docs for the model. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use buzz_migrate::roster::Roster; +use buzz_migrate::server::{router, AppState, Inner, Mailer}; +use buzz_migrate::token::ConsumedNonces; +use clap::Parser; +use nostr::Keys; + +/// Operator claim-service for Slack→Buzz identity migration. +#[derive(Parser, Debug)] +#[command(name = "buzz-migrate", version, about)] +struct Args { + /// Relay base URL (http/https/ws/wss). The admin key must be a community + /// owner or admin on this relay. + #[arg(long, env = "BUZZ_RELAY_URL", default_value = "http://localhost:3000")] + relay_url: String, + + /// Operator admin private key (hex or nsec). Used only to sign attestations. + #[arg(long, env = "BUZZ_PRIVATE_KEY")] + admin_key: String, + + /// NIP-OA auth tag JSON (community membership delegation), if the relay + /// requires one. + #[arg(long, env = "BUZZ_AUTH_TAG")] + auth_tag: Option, + + /// Unzipped Slack export directory (must contain users.json). + #[arg(long)] + export_dir: PathBuf, + + /// Address to bind the HTTP service to. + #[arg(long, default_value = "127.0.0.1:8787")] + bind: String, + + /// Public base URL of this service, used to build magic links. Defaults to + /// `http://`. + #[arg(long)] + base_url: Option, + + /// Hex secret (>=32 bytes recommended) that signs magic-link tokens. If + /// omitted, a random one is generated — fine for a single run, but tokens + /// minted before a restart stop verifying. Set it to survive restarts. + #[arg(long, env = "BUZZ_MIGRATE_TOKEN_SECRET")] + token_secret: Option, + + /// Magic-link token lifetime in seconds (default 72h). + #[arg(long, default_value_t = 72 * 3600)] + token_ttl_secs: u64, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "buzz_migrate=info,tower_http=info".into()), + ) + .init(); + + let args = Args::parse(); + + let admin = Keys::parse(&args.admin_key) + .map_err(|e| format!("invalid --admin-key (hex or nsec): {e}"))?; + + let auth_tag = match args.auth_tag.as_deref() { + Some(json) => Some( + buzz_sdk::nip_oa::parse_auth_tag(json) + .map_err(|e| format!("invalid BUZZ_AUTH_TAG: {e}"))?, + ), + None => None, + }; + + let users_path = args.export_dir.join("users.json"); + let users_bytes = std::fs::read(&users_path) + .map_err(|e| format!("could not read {}: {e}", users_path.display()))?; + let roster = Roster::from_users_json(&users_bytes) + .map_err(|e| format!("could not parse {}: {e}", users_path.display()))?; + tracing::info!( + mailable = roster.mailable_count(), + "loaded Slack export roster" + ); + + let token_secret = match args.token_secret { + Some(hex_secret) => { + hex::decode(hex_secret.trim()).map_err(|_| "--token-secret must be hex")? + } + None => { + let s = rand::random::<[u8; 32]>().to_vec(); + tracing::warn!( + "no --token-secret set: generated an ephemeral one; links minted now will \ + stop verifying after a restart" + ); + s + } + }; + + let base_url = args + .base_url + .unwrap_or_else(|| format!("http://{}", args.bind)); + + let inner = Inner { + roster, + token_secret, + consumed: Mutex::new(ConsumedNonces::new()), + admin, + relay_url: to_ws_url(&args.relay_url), + auth_tag, + base_url, + token_ttl_secs: args.token_ttl_secs, + mailer: Mailer::Dev, + }; + let state = AppState(Arc::new(inner)); + + let listener = tokio::net::TcpListener::bind(&args.bind).await?; + tracing::info!(bind = %args.bind, "buzz-migrate claim-service listening"); + axum::serve(listener, router(state)).await?; + Ok(()) +} + +/// Convert an http(s) relay URL to its ws(s) equivalent for event publishing. +/// ws/wss URLs pass through unchanged. +fn to_ws_url(url: &str) -> String { + if let Some(rest) = url.strip_prefix("https://") { + format!("wss://{rest}") + } else if let Some(rest) = url.strip_prefix("http://") { + format!("ws://{rest}") + } else { + url.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn http_urls_become_ws() { + assert_eq!(to_ws_url("http://localhost:3000"), "ws://localhost:3000"); + assert_eq!(to_ws_url("https://relay.example"), "wss://relay.example"); + assert_eq!(to_ws_url("ws://x:1"), "ws://x:1"); + assert_eq!(to_ws_url("wss://x"), "wss://x"); + } +} diff --git a/crates/buzz-migrate/src/server.rs b/crates/buzz-migrate/src/server.rs new file mode 100644 index 0000000000..2c6ed0b2bd --- /dev/null +++ b/crates/buzz-migrate/src/server.rs @@ -0,0 +1,392 @@ +//! The claim-service HTTP surface (email channel). +//! +//! Three endpoints implement the email proof: +//! +//! - `POST /email/start {email}` — mint a magic-link token for the Slack user +//! that email belongs to and mail it there. Deliberately takes **no pubkey** +//! and always answers the same way whether or not the email is known, so it +//! can neither be used to enumerate the workspace nor to smuggle an attacker +//! pubkey (see [`crate::token`]). +//! - `GET /email/verify?token=…` — the link target. Validates the token and +//! hands off to the recipient's Buzz app via a `buzz://import-claim` deep +//! link. Does not consume the token (the app completes the claim). +//! - `POST /email/complete {token, pubkey}` — the app calls this with **its +//! own** key. Re-verifies, atomically consumes (single use), then publishes +//! the owner/admin attestation `subject → pubkey`. The app separately +//! publishes the matching self-claim; only then is history attributed. + +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use nostr::{Keys, Tag}; +use serde::{Deserialize, Serialize}; + +use crate::roster::Roster; +use crate::token::{self, ConsumedNonces, MagicToken, TokenError}; + +/// How the service delivers magic links. +#[derive(Clone)] +pub enum Mailer { + /// Development: don't send anything; the link is logged and returned in the + /// `/email/start` response so a local tester can follow it by hand. + Dev, +} + +/// Immutable service configuration + shared mutable single-use ledger. +pub struct Inner { + pub roster: Roster, + pub token_secret: Vec, + pub consumed: Mutex, + pub admin: Keys, + pub relay_url: String, + pub auth_tag: Option, + /// Public base URL of this service, used to build magic links. + pub base_url: String, + pub token_ttl_secs: u64, + pub mailer: Mailer, +} + +/// Cloneable handle to the service state (an `Arc` under the hood). +#[derive(Clone)] +pub struct AppState(pub Arc); + +impl AppState { + fn i(&self) -> &Inner { + &self.0 + } +} + +/// Build the router for the email channel. +pub fn router(state: AppState) -> Router { + Router::new() + .route("/healthz", get(|| async { "ok" })) + .route("/email/start", post(email_start)) + .route("/email/verify", get(email_verify)) + .route("/email/complete", post(email_complete)) + .with_state(state) +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn random_nonce() -> [u8; 16] { + rand::random() +} + +/// Normalize a caller-supplied Buzz pubkey to canonical lowercase hex, or +/// reject it. Only 64-char hex is accepted (the app sends hex, never an nsec). +fn normalize_pubkey(s: &str) -> Option { + let s = s.trim(); + if s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit()) { + Some(s.to_lowercase()) + } else { + None + } +} + +// ---- POST /email/start ------------------------------------------------------- + +#[derive(Deserialize)] +struct EmailStartReq { + email: String, +} + +#[derive(Serialize)] +struct EmailStartResp { + /// Always the same generic message, regardless of whether the email is + /// known — do not leak workspace membership. + message: String, + /// Only populated in [`Mailer::Dev`]: the magic link to follow by hand. + #[serde(skip_serializing_if = "Option::is_none")] + dev_link: Option, +} + +const GENERIC_START_MSG: &str = + "If that address belongs to a workspace member, a migration link has been sent to it."; + +async fn email_start( + State(state): State, + Json(req): Json, +) -> Json { + let inner = state.i(); + let dev_link = match inner.roster.subject_for_email(&req.email) { + Some(subject) => { + let tok = token::mint( + &inner.token_secret, + subject, + now_secs(), + inner.token_ttl_secs, + &random_nonce(), + ); + let link = format!("{}/email/verify?token={}", inner.base_url, tok.as_str()); + match inner.mailer { + Mailer::Dev => { + tracing::info!(subject, %link, "dev mailer: magic link (not emailed)"); + Some(link) + } + } + } + None => { + // Unknown address: do the same amount of visible work, send nothing. + tracing::info!(email = %req.email, "email/start for unknown address (ignored)"); + None + } + }; + Json(EmailStartResp { + message: GENERIC_START_MSG.to_string(), + dev_link, + }) +} + +// ---- GET /email/verify ------------------------------------------------------- + +#[derive(Deserialize)] +struct VerifyQuery { + token: String, +} + +/// The magic-link target. Validates integrity + expiry (not single use) and +/// renders a page that hands the token to the recipient's Buzz app. +async fn email_verify( + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + let inner = state.i(); + let tok = MagicToken::from_wire(q.token.clone()); + match token::verify(&inner.token_secret, &tok, now_secs()) { + Ok(v) => { + let deep_link = format!( + "buzz://import-claim?subject={}&token={}&service={}", + urlencode(&v.subject), + urlencode(tok.as_str()), + urlencode(&inner.base_url), + ); + Html(verify_page(&v.subject, &deep_link)).into_response() + } + Err(e) => (StatusCode::BAD_REQUEST, Html(error_page(&e.to_string()))).into_response(), + } +} + +// ---- POST /email/complete ---------------------------------------------------- + +#[derive(Deserialize)] +struct CompleteReq { + token: String, + /// The claimant's Buzz pubkey (64-hex) — supplied by their own app. + pubkey: String, +} + +#[derive(Serialize)] +struct CompleteResp { + subject: String, + /// The published attestation's event id. + attestation_event_id: String, +} + +async fn email_complete( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let inner = state.i(); + let pubkey = normalize_pubkey(&req.pubkey) + .ok_or_else(|| ApiError::bad("pubkey must be 64-char hex (the app's own key)"))?; + + // Verify + atomically consume (single use). Pure, network-free. + let subject = redeem_email_token(inner, &req.token, now_secs())?; + + // Publish the owner/admin attestation subject → pubkey. + let event_id = crate::attest::publish_attestation( + &inner.relay_url, + &inner.admin, + &subject, + &pubkey, + inner.auth_tag.as_ref(), + ) + .await + .map_err(|e| ApiError::upstream(&e.to_string()))?; + + tracing::info!( + subject, + pubkey, + event_id, + "published attestation for email claim" + ); + Ok(Json(CompleteResp { + subject, + attestation_event_id: event_id, + })) +} + +/// Verify a token and, on success, atomically mark it used, returning the +/// proven subject. Factored out so the security-critical redeem path is unit +/// tested without a relay. +fn redeem_email_token(inner: &Inner, wire: &str, now: u64) -> Result { + let tok = MagicToken::from_wire(wire.to_string()); + let verified = + token::verify(&inner.token_secret, &tok, now).map_err(|e| token_err_to_api(&e))?; + { + let mut ledger = inner.consumed.lock().expect("consumed ledger not poisoned"); + ledger + .try_consume(&verified, now) + .map_err(|e| token_err_to_api(&e))?; + } + Ok(verified.subject) +} + +fn token_err_to_api(e: &TokenError) -> ApiError { + match e { + TokenError::Expired | TokenError::AlreadyUsed => ApiError { + status: StatusCode::GONE, + message: e.to_string(), + }, + _ => ApiError::bad(&e.to_string()), + } +} + +// ---- shared error + small helpers ------------------------------------------- + +#[derive(Debug)] +struct ApiError { + status: StatusCode, + message: String, +} + +impl ApiError { + fn bad(msg: &str) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: msg.to_string(), + } + } + fn upstream(msg: &str) -> Self { + Self { + status: StatusCode::BAD_GATEWAY, + message: msg.to_string(), + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> axum::response::Response { + #[derive(Serialize)] + struct Body { + error: String, + } + ( + self.status, + Json(Body { + error: self.message, + }), + ) + .into_response() + } +} + +/// Minimal percent-encoding for the query values we build (no external dep). +fn urlencode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +fn verify_page(subject: &str, deep_link: &str) -> String { + format!( + "Complete your migration\ + \ +

You're verified

\ +

Open Buzz to finish linking your imported history for {subject}.

\ +

\ + Open in Buzz

\ +

This link is single-use and expires soon. \ + It links your history to the Buzz account on this device — only continue if you \ + started this in Buzz.

" + ) +} + +fn error_page(msg: &str) -> String { + format!( + "Link problem\ + \ +

This link can't be used

{msg}

\ +

Ask your operator to send a fresh migration link.

" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_inner() -> Inner { + let users = + r#"[{"id":"U060","profile":{"email":"alice@corp.com","display_name":"Alice"}}]"#; + Inner { + roster: Roster::from_users_json(users.as_bytes()).unwrap(), + token_secret: b"secret".to_vec(), + consumed: Mutex::new(ConsumedNonces::new()), + admin: Keys::generate(), + relay_url: "ws://127.0.0.1:1".into(), + auth_tag: None, + base_url: "http://localhost:8787".into(), + token_ttl_secs: 3600, + mailer: Mailer::Dev, + } + } + + #[test] + fn redeem_happy_path_returns_subject_and_consumes() { + let inner = test_inner(); + let tok = token::mint(&inner.token_secret, "slack:U060", 1000, 3600, &[3u8; 16]); + let subject = redeem_email_token(&inner, tok.as_str(), 1500).expect("redeems"); + assert_eq!(subject, "slack:U060"); + // Second redeem of the same token is rejected as used. + let err = redeem_email_token(&inner, tok.as_str(), 1500).unwrap_err(); + assert_eq!(err.status, StatusCode::GONE); + } + + #[test] + fn redeem_rejects_forged_token() { + let inner = test_inner(); + let forged = token::mint(b"other-secret", "slack:U060", 1000, 3600, &[3u8; 16]); + let err = redeem_email_token(&inner, forged.as_str(), 1500).unwrap_err(); + assert_eq!(err.status, StatusCode::BAD_REQUEST); + } + + #[test] + fn redeem_rejects_expired_token() { + let inner = test_inner(); + let tok = token::mint(&inner.token_secret, "slack:U060", 1000, 10, &[3u8; 16]); + let err = redeem_email_token(&inner, tok.as_str(), 2000).unwrap_err(); + assert_eq!(err.status, StatusCode::GONE); + } + + #[test] + fn pubkey_normalization() { + assert_eq!(normalize_pubkey(&"AB".repeat(32)).unwrap(), "ab".repeat(32)); + assert!(normalize_pubkey("npub1xyz").is_none()); + assert!(normalize_pubkey("tooshort").is_none()); + assert!(normalize_pubkey(&"g".repeat(64)).is_none()); // non-hex + } + + #[test] + fn urlencode_escapes_reserved() { + assert_eq!(urlencode("slack:U060"), "slack%3AU060"); + assert_eq!(urlencode("a-b_c.d~e"), "a-b_c.d~e"); + } +} From 4a528faa005cb6aa0ace75b33599c58bb4e4fdfa Mon Sep 17 00:00:00 2001 From: Ren Koya Date: Fri, 24 Jul 2026 16:17:58 +0900 Subject: [PATCH 11/23] feat(migrate): OIDC (Sign in with Slack) channel + security hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the second claim channel and hardens both. OIDC channel (oidc.rs + /oidc/* routes): - GET /oidc/start?pubkey=X binds state→(pubkey,nonce) and redirects to Slack. - GET /oidc/callback exchanges the code (confidential client), verifies the id_token nonce, calls userInfo, and requires the authenticated workspace to equal the configured SLACK_TEAM_ID before binding slack:→X. Since Slack pins the id to whoever authenticated, an attacker can only ever prove their own id. No bearer token exists in this channel. - GET /oidc/dev-complete simulates a verified result (only with --dev) so the publish path is testable without a Slack app. Hardening across the crate: - token: mint is fallible (no expect on the hot path); single use is now reserve→commit/release, so a failed relay write releases the token for the real claimant to retry instead of burning it. Expiry boundary tightened. - roster: emails shared by two active users are marked ambiguous and never resolve (belt-and-suspenders in subject_for_email) — no mis-attribution. - main: require SLACK_CLIENT_ID/SECRET/TEAM_ID together; token secret must be >=32 bytes; ttl must be > 0; base_url trailing slash trimmed. Live-verified end to end against a local relay: email channel (start→verify→ complete→attestation, single-use reuse rejected, wrong-host community boundary rejected) and OIDC dev path both produce a confirmed two-party binding. clippy + fmt clean; 27 unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Ren Koya --- crates/buzz-migrate/Cargo.toml | 1 + crates/buzz-migrate/src/lib.rs | 1 + crates/buzz-migrate/src/main.rs | 77 +++++++- crates/buzz-migrate/src/oidc.rs | 263 +++++++++++++++++++++++++++ crates/buzz-migrate/src/roster.rs | 36 +++- crates/buzz-migrate/src/server.rs | 288 ++++++++++++++++++++++++------ crates/buzz-migrate/src/token.rs | 140 +++++++++++---- 7 files changed, 711 insertions(+), 95 deletions(-) create mode 100644 crates/buzz-migrate/src/oidc.rs diff --git a/crates/buzz-migrate/Cargo.toml b/crates/buzz-migrate/Cargo.toml index 38b71dfac4..592a85c08e 100644 --- a/crates/buzz-migrate/Cargo.toml +++ b/crates/buzz-migrate/Cargo.toml @@ -35,3 +35,4 @@ axum = { workspace = true } reqwest = { workspace = true } url = { workspace = true } clap = { version = "4", features = ["derive", "env"] } +base64 = "0.22" diff --git a/crates/buzz-migrate/src/lib.rs b/crates/buzz-migrate/src/lib.rs index c843bd7a21..d9b803f1b2 100644 --- a/crates/buzz-migrate/src/lib.rs +++ b/crates/buzz-migrate/src/lib.rs @@ -31,6 +31,7 @@ //! signs is public-key-only and NIP-33-revocable. pub mod attest; +pub mod oidc; pub mod roster; pub mod server; pub mod token; diff --git a/crates/buzz-migrate/src/main.rs b/crates/buzz-migrate/src/main.rs index 3228ba1a7f..2cbd76ca6a 100644 --- a/crates/buzz-migrate/src/main.rs +++ b/crates/buzz-migrate/src/main.rs @@ -5,9 +5,11 @@ //! two-party import identity binding so a team migrates with no per-person //! operator work and no account-takeover. See the crate docs for the model. +use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; +use buzz_migrate::oidc::OidcConfig; use buzz_migrate::roster::Roster; use buzz_migrate::server::{router, AppState, Inner, Mailer}; use buzz_migrate::token::ConsumedNonces; @@ -54,6 +56,28 @@ struct Args { /// Magic-link token lifetime in seconds (default 72h). #[arg(long, default_value_t = 72 * 3600)] token_ttl_secs: u64, + + /// Slack OIDC client id (enables the Sign-in-with-Slack channel). + #[arg(long, env = "SLACK_CLIENT_ID")] + slack_client_id: Option, + + /// Slack OIDC client secret. + #[arg(long, env = "SLACK_CLIENT_SECRET")] + slack_client_secret: Option, + + /// Slack workspace id whose users may claim imported identities. + #[arg(long, env = "SLACK_TEAM_ID")] + slack_team_id: Option, + + /// OIDC redirect URI registered on the Slack app. Defaults to + /// `/oidc/callback`. + #[arg(long)] + oidc_redirect_uri: Option, + + /// Enable dev-only routes (e.g. /oidc/dev-complete) for local testing + /// without a real Slack app. Never set this in production. + #[arg(long)] + dev: bool, } #[tokio::main] @@ -101,10 +125,51 @@ async fn main() -> Result<(), Box> { s } }; + if token_secret.len() < 32 { + return Err("--token-secret must contain at least 32 bytes".into()); + } + if args.token_ttl_secs == 0 { + return Err("--token-ttl-secs must be greater than zero".into()); + } let base_url = args .base_url - .unwrap_or_else(|| format!("http://{}", args.bind)); + .unwrap_or_else(|| format!("http://{}", args.bind)) + .trim_end_matches('/') + .to_string(); + + let oidc = match ( + args.slack_client_id, + args.slack_client_secret, + args.slack_team_id, + ) { + (Some(client_id), Some(client_secret), Some(team_id)) => { + let redirect_uri = args + .oidc_redirect_uri + .unwrap_or_else(|| format!("{base_url}/oidc/callback")); + tracing::info!(%redirect_uri, "OIDC channel enabled (Sign in with Slack)"); + Some(OidcConfig { + client_id, + client_secret, + redirect_uri, + team_id, + }) + } + (None, None, None) => { + tracing::info!("OIDC channel disabled (no Slack OIDC configuration)"); + None + } + _ => { + return Err( + "set SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, and SLACK_TEAM_ID together, or omit all" + .into(), + ); + } + }; + + if args.dev { + tracing::warn!("--dev enabled: /oidc/dev-complete is active; do NOT use in production"); + } let inner = Inner { roster, @@ -115,7 +180,15 @@ async fn main() -> Result<(), Box> { auth_tag, base_url, token_ttl_secs: args.token_ttl_secs, - mailer: Mailer::Dev, + mailer: if args.dev { + Mailer::Dev + } else { + Mailer::Disabled + }, + http: reqwest::Client::new(), + oidc, + oidc_states: Mutex::new(HashMap::new()), + dev: args.dev, }; let state = AppState(Arc::new(inner)); diff --git a/crates/buzz-migrate/src/oidc.rs b/crates/buzz-migrate/src/oidc.rs new file mode 100644 index 0000000000..4825182414 --- /dev/null +++ b/crates/buzz-migrate/src/oidc.rs @@ -0,0 +1,263 @@ +//! Sign in with Slack (OIDC) — the second, stronger claim channel. +//! +//! Where the email channel proves control of an inbox, this channel proves +//! control of the Slack account itself, live: the person authenticates to +//! Slack and the service reads back the verified user id. There is no bearer +//! token to leak — the proof is a fresh authorization Slack performs at claim +//! time. +//! +//! Flow: +//! 1. `GET /oidc/start?pubkey=X` — the app opens this with **its own** key. We +//! stash `state → X` and redirect to Slack's authorize endpoint. +//! 2. Slack authenticates the person and redirects to +//! `GET /oidc/callback?code&state`. +//! 3. We exchange the code for an access token, call Slack's userInfo endpoint, +//! and read the verified `https://slack.com/user_id` claim. Because Slack +//! pins that id to whoever actually authenticated, binding `slack: → X` +//! is safe — an attacker can only ever prove their *own* Slack id. +//! 4. We publish the attestation and deep-link back into the app to self-claim. +//! +//! The exchange needs Slack's `client_secret`, so it runs server-side. That is +//! the service's only Slack credential; it never touches a Buzz private key +//! other than the operator admin key used to sign attestations. + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use serde::Deserialize; + +/// Slack OIDC application credentials. Absent → the `/oidc/*` routes return 503. +#[derive(Clone)] +pub struct OidcConfig { + /// Slack application's OAuth client id. + pub client_id: String, + /// Slack application's OAuth client secret. + pub client_secret: String, + /// Must exactly match a redirect URL registered on the Slack app. + pub redirect_uri: String, + /// The only Slack workspace whose identities may be bound. + pub team_id: String, +} + +const AUTHORIZE_URL: &str = "https://slack.com/openid/connect/authorize"; +const TOKEN_URL: &str = "https://slack.com/api/openid.connect.token"; +const USERINFO_URL: &str = "https://slack.com/api/openid.connect.userInfo"; + +/// Build the Slack authorize URL to redirect the person to. +pub fn authorize_url(cfg: &OidcConfig, state: &str, nonce: &str) -> String { + format!( + "{AUTHORIZE_URL}?response_type=code&scope=openid&client_id={}&redirect_uri={}&state={}&nonce={}&team={}", + urlencode(&cfg.client_id), + urlencode(&cfg.redirect_uri), + urlencode(state), + urlencode(nonce), + urlencode(&cfg.team_id), + ) +} + +#[derive(Deserialize)] +struct TokenResp { + #[serde(default)] + ok: bool, + #[serde(default)] + access_token: Option, + #[serde(default)] + id_token: Option, + #[serde(default)] + error: Option, +} + +#[derive(Deserialize)] +struct UserInfoResp { + #[serde(default)] + ok: bool, + /// Slack puts the workspace user id under this namespaced claim. + #[serde(rename = "https://slack.com/user_id", default)] + user_id: Option, + #[serde(rename = "https://slack.com/team_id", default)] + team_id: Option, + #[serde(default)] + error: Option, +} + +#[derive(Deserialize)] +struct IdTokenClaims { + nonce: String, +} + +/// Why an OIDC exchange failed. +#[derive(Debug, thiserror::Error)] +pub enum OidcError { + #[error("slack token exchange failed: {0}")] + Token(String), + #[error("slack userinfo failed: {0}")] + UserInfo(String), + #[error("slack did not return a user id")] + NoUserId, + #[error("slack OIDC nonce did not match")] + NonceMismatch, + #[error("slack OIDC id_token is malformed")] + MalformedIdToken, + #[error("authenticated Slack workspace {actual:?} does not match configured workspace")] + WrongTeam { actual: Option }, + #[error(transparent)] + Http(#[from] reqwest::Error), +} + +/// Exchange an authorization `code` for the authenticated Slack user id. +/// Returns the `slack:` subject. +pub async fn exchange_code_for_subject( + http: &reqwest::Client, + cfg: &OidcConfig, + code: &str, + expected_nonce: &str, +) -> Result { + if code.is_empty() { + return Err(OidcError::Token("missing authorization code".into())); + } + // 1. code → access token (confidential client: client_secret required). + // Build the x-www-form-urlencoded body by hand so we don't depend on + // reqwest's optional form feature. + let body = [ + ("client_id", cfg.client_id.as_str()), + ("client_secret", cfg.client_secret.as_str()), + ("code", code), + ("redirect_uri", cfg.redirect_uri.as_str()), + ("grant_type", "authorization_code"), + ] + .iter() + .map(|(k, v)| format!("{}={}", urlencode(k), urlencode(v))) + .collect::>() + .join("&"); + let token: TokenResp = http + .post(TOKEN_URL) + .header("content-type", "application/x-www-form-urlencoded") + .body(body) + .send() + .await? + .error_for_status()? + .json() + .await?; + if !token.ok { + return Err(OidcError::Token( + token.error.unwrap_or_else(|| "unknown".into()), + )); + } + let access = token + .access_token + .ok_or_else(|| OidcError::Token("no access_token".into()))?; + let claims = decode_id_token_claims( + token + .id_token + .as_deref() + .ok_or(OidcError::MalformedIdToken)?, + )?; + if claims.nonce != expected_nonce { + return Err(OidcError::NonceMismatch); + } + + // 2. access token → verified user id. + let info: UserInfoResp = http + .get(USERINFO_URL) + .bearer_auth(&access) + .send() + .await? + .error_for_status()? + .json() + .await + .map_err(|e| OidcError::UserInfo(e.to_string()))?; + if !info.ok { + return Err(OidcError::UserInfo( + info.error.unwrap_or_else(|| "unknown".into()), + )); + } + if info.team_id.as_deref() != Some(cfg.team_id.as_str()) { + return Err(OidcError::WrongTeam { + actual: info.team_id, + }); + } + let user_id = info + .user_id + .filter(|s| !s.is_empty()) + .ok_or(OidcError::NoUserId)?; + Ok(format!("slack:{user_id}")) +} + +/// Decode only the nonce from the ID token returned directly by Slack's token +/// endpoint. Identity still comes from the authenticated userInfo request. +fn decode_id_token_claims(id_token: &str) -> Result { + let payload = id_token + .split('.') + .nth(1) + .ok_or(OidcError::MalformedIdToken)?; + let decoded = URL_SAFE_NO_PAD + .decode(payload) + .map_err(|_| OidcError::MalformedIdToken)?; + serde_json::from_slice(&decoded).map_err(|_| OidcError::MalformedIdToken) +} + +/// Same minimal percent-encoding used by the email channel (no external dep). +fn urlencode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> OidcConfig { + OidcConfig { + client_id: "123.456".into(), + client_secret: "shh".into(), + redirect_uri: "https://mig.example/oidc/callback".into(), + team_id: "T060".into(), + } + } + + #[test] + fn authorize_url_encodes_params() { + let u = authorize_url(&cfg(), "st ate", "non/ce"); + assert!(u.starts_with(AUTHORIZE_URL)); + assert!(u.contains("client_id=123.456")); + assert!(u.contains("redirect_uri=https%3A%2F%2Fmig.example%2Foidc%2Fcallback")); + assert!(u.contains("state=st%20ate")); + assert!(u.contains("nonce=non%2Fce")); + assert!(u.contains("team=T060")); + assert!(u.contains("scope=openid")); + } + + #[test] + fn userinfo_reads_namespaced_user_id() { + let info: UserInfoResp = serde_json::from_str( + r#"{"ok":true,"sub":"x","https://slack.com/user_id":"U060", + "https://slack.com/team_id":"T060"}"#, + ) + .unwrap(); + assert_eq!(info.user_id.as_deref(), Some("U060")); + assert_eq!(info.team_id.as_deref(), Some("T060")); + } + + #[test] + fn token_resp_surfaces_error() { + let t: TokenResp = serde_json::from_str(r#"{"ok":false,"error":"bad_code"}"#).unwrap(); + assert!(!t.ok); + assert_eq!(t.error.as_deref(), Some("bad_code")); + } + + #[test] + fn id_token_nonce_is_decoded() { + let payload = URL_SAFE_NO_PAD.encode(r#"{"nonce":"expected"}"#); + let claims = + decode_id_token_claims(&format!("header.{payload}.signature")).expect("decodes"); + assert_eq!(claims.nonce, "expected"); + assert!(decode_id_token_claims("not-a-jwt").is_err()); + } +} diff --git a/crates/buzz-migrate/src/roster.rs b/crates/buzz-migrate/src/roster.rs index 5ae7c5ffae..cbed4feb34 100644 --- a/crates/buzz-migrate/src/roster.rs +++ b/crates/buzz-migrate/src/roster.rs @@ -8,7 +8,7 @@ //! history import used. use serde::Deserialize; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; #[derive(Deserialize)] struct RawUser { @@ -36,6 +36,9 @@ pub struct Roster { email_to_subject: HashMap, /// `slack:` → best human-readable name (for email copy). subject_to_name: HashMap, + /// Emails shared by more than one active Slack user. These are never + /// eligible for magic-link attribution because ownership is ambiguous. + ambiguous_emails: HashSet, } impl Roster { @@ -48,6 +51,7 @@ impl Roster { let users: Vec = serde_json::from_slice(bytes)?; let mut email_to_subject = HashMap::new(); let mut subject_to_name = HashMap::new(); + let mut ambiguous_emails = HashSet::new(); for u in users { if u.deleted { continue; @@ -62,22 +66,31 @@ impl Roster { }; subject_to_name.insert(subject.clone(), name); let email = u.profile.email.trim().to_lowercase(); - if !email.is_empty() { - email_to_subject.insert(email, subject); + if email.is_empty() || ambiguous_emails.contains(&email) { + continue; + } + if email_to_subject.insert(email.clone(), subject).is_some() { + email_to_subject.remove(&email); + ambiguous_emails.insert(email); } } Ok(Self { email_to_subject, subject_to_name, + ambiguous_emails, }) } /// The `slack:` subject for an email, if the export knows it. Matching /// is case-insensitive and whitespace-trimmed. pub fn subject_for_email(&self, email: &str) -> Option<&str> { - self.email_to_subject - .get(email.trim().to_lowercase().as_str()) - .map(String::as_str) + let email = email.trim().to_lowercase(); + // Defense in depth: shared emails are already absent from the map, but + // reject them explicitly so an ambiguous address can never resolve. + if self.ambiguous_emails.contains(&email) { + return None; + } + self.email_to_subject.get(&email).map(String::as_str) } /// The display name for a subject, for personalizing the email. @@ -135,4 +148,15 @@ mod tests { let r = Roster::from_users_json(USERS.as_bytes()).unwrap(); assert_eq!(r.subject_for_email("nobody@corp.com"), None); } + + #[test] + fn duplicate_email_is_ambiguous_and_not_mailable() { + let users = r#"[ + {"id":"U1","profile":{"email":"shared@corp.com"}}, + {"id":"U2","profile":{"email":"SHARED@corp.com"}} + ]"#; + let r = Roster::from_users_json(users.as_bytes()).unwrap(); + assert_eq!(r.subject_for_email("shared@corp.com"), None); + assert_eq!(r.mailable_count(), 0); + } } diff --git a/crates/buzz-migrate/src/server.rs b/crates/buzz-migrate/src/server.rs index 2c6ed0b2bd..e349845714 100644 --- a/crates/buzz-migrate/src/server.rs +++ b/crates/buzz-migrate/src/server.rs @@ -15,23 +15,31 @@ //! the owner/admin attestation `subject → pubkey`. The app separately //! publishes the matching self-claim; only then is history attributed. -use std::sync::{Arc, Mutex}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{SystemTime, UNIX_EPOCH}; use axum::extract::{Query, State}; use axum::http::StatusCode; -use axum::response::{Html, IntoResponse}; +use axum::response::{Html, IntoResponse, Redirect}; use axum::routing::{get, post}; use axum::{Json, Router}; use nostr::{Keys, Tag}; use serde::{Deserialize, Serialize}; +use crate::oidc::{self, OidcConfig}; use crate::roster::Roster; -use crate::token::{self, ConsumedNonces, MagicToken, TokenError}; +use crate::token::{self, ConsumedNonces, MagicToken, TokenError, VerifiedToken}; + +/// How long an OIDC `state` is valid between `/oidc/start` and the callback. +const OIDC_STATE_TTL_SECS: u64 = 600; /// How the service delivers magic links. #[derive(Clone)] pub enum Mailer { + /// Production-safe placeholder until an actual email delivery backend is + /// configured. `/email/start` stays enumeration-safe and sends nothing. + Disabled, /// Development: don't send anything; the link is logged and returned in the /// `/email/start` response so a local tester can follow it by hand. Dev, @@ -49,6 +57,16 @@ pub struct Inner { pub base_url: String, pub token_ttl_secs: u64, pub mailer: Mailer, + /// Shared HTTP client for the OIDC exchange. + pub http: reqwest::Client, + /// Slack OIDC credentials; `None` disables the `/oidc/*` routes. + pub oidc: Option, + /// Live OIDC `state` → (claimant pubkey, nonce, expiry). CSRF, replay + /// protection, and pubkey binding. + pub oidc_states: Mutex>, + /// Dev mode: enables `/oidc/dev-complete`, which simulates a verified OIDC + /// result so the publish path can be exercised without a real Slack app. + pub dev: bool, } /// Cloneable handle to the service state (an `Arc` under the hood). @@ -61,13 +79,18 @@ impl AppState { } } -/// Build the router for the email channel. +/// Build the router for both claim channels. pub fn router(state: AppState) -> Router { Router::new() .route("/healthz", get(|| async { "ok" })) + // Email channel. .route("/email/start", post(email_start)) .route("/email/verify", get(email_verify)) .route("/email/complete", post(email_complete)) + // OIDC channel (Sign in with Slack). + .route("/oidc/start", get(oidc_start)) + .route("/oidc/callback", get(oidc_callback)) + .route("/oidc/dev-complete", get(oidc_dev_complete)) .with_state(state) } @@ -116,35 +139,33 @@ const GENERIC_START_MSG: &str = async fn email_start( State(state): State, Json(req): Json, -) -> Json { +) -> Result, ApiError> { let inner = state.i(); - let dev_link = match inner.roster.subject_for_email(&req.email) { - Some(subject) => { + let dev_link = match (&inner.mailer, inner.roster.subject_for_email(&req.email)) { + (Mailer::Dev, Some(subject)) => { let tok = token::mint( &inner.token_secret, subject, now_secs(), inner.token_ttl_secs, &random_nonce(), - ); + ) + .map_err(|e| token_err_to_api(&e))?; let link = format!("{}/email/verify?token={}", inner.base_url, tok.as_str()); - match inner.mailer { - Mailer::Dev => { - tracing::info!(subject, %link, "dev mailer: magic link (not emailed)"); - Some(link) - } - } + tracing::info!(subject, %link, "dev mailer: magic link (not emailed)"); + Some(link) } - None => { + (Mailer::Dev, None) => { // Unknown address: do the same amount of visible work, send nothing. tracing::info!(email = %req.email, "email/start for unknown address (ignored)"); None } + (Mailer::Disabled, _) => None, }; - Json(EmailStartResp { + Ok(Json(EmailStartResp { message: GENERIC_START_MSG.to_string(), dev_link, - }) + })) } // ---- GET /email/verify ------------------------------------------------------- @@ -200,46 +221,184 @@ async fn email_complete( let pubkey = normalize_pubkey(&req.pubkey) .ok_or_else(|| ApiError::bad("pubkey must be 64-char hex (the app's own key)"))?; - // Verify + atomically consume (single use). Pure, network-free. - let subject = redeem_email_token(inner, &req.token, now_secs())?; + // Reserve before the network write to block concurrent redemption. Commit + // only after relay acceptance; a transient relay failure releases the + // reservation so the legitimate claimant can retry the same link. + let verified = reserve_email_token(inner, &req.token, now_secs())?; + let subject = verified.subject.clone(); + let event_id = match publish_attestation_for(inner, &subject, &pubkey, "email").await { + Ok(event_id) => { + consumed_ledger(inner).commit(&verified); + event_id + } + Err(error) => { + consumed_ledger(inner).release(&verified); + return Err(error); + } + }; + Ok(Json(CompleteResp { + subject, + attestation_event_id: event_id, + })) +} - // Publish the owner/admin attestation subject → pubkey. +/// Sign + publish the owner/admin attestation `subject → pubkey`. Shared by +/// every channel; `channel` is only for logging. +async fn publish_attestation_for( + inner: &Inner, + subject: &str, + pubkey: &str, + channel: &str, +) -> Result { let event_id = crate::attest::publish_attestation( &inner.relay_url, &inner.admin, - &subject, - &pubkey, + subject, + pubkey, inner.auth_tag.as_ref(), ) .await .map_err(|e| ApiError::upstream(&e.to_string()))?; + tracing::info!(subject, pubkey, event_id, channel, "published attestation"); + Ok(event_id) +} - tracing::info!( - subject, - pubkey, - event_id, - "published attestation for email claim" - ); +// ---- OIDC channel (Sign in with Slack) -------------------------------------- + +#[derive(Deserialize)] +struct OidcStartQuery { + /// The claimant's own Buzz pubkey (64-hex), supplied by their app. + pubkey: String, +} + +/// Begin Sign in with Slack: bind `state → pubkey` and redirect to Slack. +async fn oidc_start( + State(state): State, + Query(q): Query, +) -> Result { + let inner = state.i(); + let cfg = inner.oidc.as_ref().ok_or_else(oidc_unconfigured)?; + let pubkey = + normalize_pubkey(&q.pubkey).ok_or_else(|| ApiError::bad("pubkey must be 64-char hex"))?; + + let st = hex::encode(random_nonce()); + let nonce = hex::encode(random_nonce()); + { + let now = now_secs(); + let mut states = lock_or_recover(&inner.oidc_states, "oidc states"); + states.retain(|_, (_, _, exp)| *exp > now); + states.insert( + st.clone(), + ( + pubkey, + nonce.clone(), + now.saturating_add(OIDC_STATE_TTL_SECS), + ), + ); + } + Ok(Redirect::to(&oidc::authorize_url(cfg, &st, &nonce))) +} + +#[derive(Deserialize)] +struct OidcCallbackQuery { + #[serde(default)] + code: String, + #[serde(default)] + state: String, +} + +/// Slack's redirect target: resolve `state → pubkey`, exchange the code for the +/// verified Slack user id, publish the attestation, and hand back to the app. +async fn oidc_callback( + State(state): State, + Query(q): Query, +) -> Result { + let inner = state.i(); + let cfg = inner.oidc.as_ref().ok_or_else(oidc_unconfigured)?; + + // Consume the state → the pubkey the app started with (CSRF + binding). + let (pubkey, nonce) = { + let now = now_secs(); + let mut states = lock_or_recover(&inner.oidc_states, "oidc states"); + states.retain(|_, (_, _, exp)| *exp > now); + states.remove(&q.state).map(|(pk, nonce, _)| (pk, nonce)) + } + .ok_or_else(|| ApiError::bad("unknown or expired OIDC state"))?; + + let subject = oidc::exchange_code_for_subject(&inner.http, cfg, &q.code, &nonce) + .await + .map_err(|e| ApiError::upstream(&e.to_string()))?; + + publish_attestation_for(inner, &subject, &pubkey, "oidc").await?; + Ok(Redirect::to(&format!( + "buzz://import-claim?subject={}&via=oidc", + urlencode(&subject) + ))) +} + +#[derive(Deserialize)] +struct OidcDevCompleteQuery { + pubkey: String, + /// Simulated Slack user id (e.g. `U060`). + sub: String, +} + +/// Dev-only: simulate a verified OIDC result to exercise the publish path +/// without a Slack app. Returns 404 unless the service was started with --dev. +async fn oidc_dev_complete( + State(state): State, + Query(q): Query, +) -> Result, ApiError> { + let inner = state.i(); + if !inner.dev { + return Err(ApiError { + status: StatusCode::NOT_FOUND, + message: "not found".into(), + }); + } + let pubkey = + normalize_pubkey(&q.pubkey).ok_or_else(|| ApiError::bad("pubkey must be 64-char hex"))?; + if q.sub.trim().is_empty() { + return Err(ApiError::bad("sub must not be empty")); + } + let subject = format!("slack:{}", q.sub.trim()); + let event_id = publish_attestation_for(inner, &subject, &pubkey, "oidc-dev").await?; Ok(Json(CompleteResp { subject, attestation_event_id: event_id, })) } -/// Verify a token and, on success, atomically mark it used, returning the -/// proven subject. Factored out so the security-critical redeem path is unit -/// tested without a relay. -fn redeem_email_token(inner: &Inner, wire: &str, now: u64) -> Result { +fn oidc_unconfigured() -> ApiError { + ApiError { + status: StatusCode::SERVICE_UNAVAILABLE, + message: "OIDC channel is not configured on this service".into(), + } +} + +/// Verify a token and atomically reserve it for one in-flight relay write. +fn reserve_email_token(inner: &Inner, wire: &str, now: u64) -> Result { let tok = MagicToken::from_wire(wire.to_string()); let verified = token::verify(&inner.token_secret, &tok, now).map_err(|e| token_err_to_api(&e))?; - { - let mut ledger = inner.consumed.lock().expect("consumed ledger not poisoned"); - ledger - .try_consume(&verified, now) - .map_err(|e| token_err_to_api(&e))?; + consumed_ledger(inner) + .try_reserve(&verified, now) + .map_err(|e| token_err_to_api(&e))?; + Ok(verified) +} + +fn consumed_ledger(inner: &Inner) -> MutexGuard<'_, ConsumedNonces> { + lock_or_recover(&inner.consumed, "consumed nonce ledger") +} + +fn lock_or_recover<'a, T>(mutex: &'a Mutex, name: &str) -> MutexGuard<'a, T> { + match mutex.lock() { + Ok(guard) => guard, + Err(poisoned) => { + tracing::error!(name, "recovering poisoned migration-service mutex"); + poisoned.into_inner() + } } - Ok(verified.subject) } fn token_err_to_api(e: &TokenError) -> ApiError { @@ -248,6 +407,10 @@ fn token_err_to_api(e: &TokenError) -> ApiError { status: StatusCode::GONE, message: e.to_string(), }, + TokenError::Unavailable => ApiError { + status: StatusCode::SERVICE_UNAVAILABLE, + message: e.to_string(), + }, _ => ApiError::bad(&e.to_string()), } } @@ -306,6 +469,7 @@ fn urlencode(s: &str) -> String { } fn verify_page(subject: &str, deep_link: &str) -> String { + let subject = escape_html(subject); format!( "Complete your migration\ \ @@ -321,6 +485,7 @@ fn verify_page(subject: &str, deep_link: &str) -> String { } fn error_page(msg: &str) -> String { + let msg = escape_html(msg); format!( "Link problem\ \ @@ -329,6 +494,15 @@ fn error_page(msg: &str) -> String { ) } +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + #[cfg(test)] mod tests { use super::*; @@ -346,33 +520,39 @@ mod tests { base_url: "http://localhost:8787".into(), token_ttl_secs: 3600, mailer: Mailer::Dev, + http: reqwest::Client::new(), + oidc: None, + oidc_states: Mutex::new(HashMap::new()), + dev: false, } } #[test] - fn redeem_happy_path_returns_subject_and_consumes() { + fn reserve_happy_path_blocks_concurrent_redemption() { let inner = test_inner(); - let tok = token::mint(&inner.token_secret, "slack:U060", 1000, 3600, &[3u8; 16]); - let subject = redeem_email_token(&inner, tok.as_str(), 1500).expect("redeems"); - assert_eq!(subject, "slack:U060"); - // Second redeem of the same token is rejected as used. - let err = redeem_email_token(&inner, tok.as_str(), 1500).unwrap_err(); + let tok = + token::mint(&inner.token_secret, "slack:U060", 1000, 3600, &[3u8; 16]).expect("mint"); + let verified = reserve_email_token(&inner, tok.as_str(), 1500).expect("reserves"); + assert_eq!(verified.subject, "slack:U060"); + let err = reserve_email_token(&inner, tok.as_str(), 1500).unwrap_err(); assert_eq!(err.status, StatusCode::GONE); } #[test] - fn redeem_rejects_forged_token() { + fn reserve_rejects_forged_token() { let inner = test_inner(); - let forged = token::mint(b"other-secret", "slack:U060", 1000, 3600, &[3u8; 16]); - let err = redeem_email_token(&inner, forged.as_str(), 1500).unwrap_err(); + let forged = + token::mint(b"other-secret", "slack:U060", 1000, 3600, &[3u8; 16]).expect("mint"); + let err = reserve_email_token(&inner, forged.as_str(), 1500).unwrap_err(); assert_eq!(err.status, StatusCode::BAD_REQUEST); } #[test] - fn redeem_rejects_expired_token() { + fn reserve_rejects_expired_token() { let inner = test_inner(); - let tok = token::mint(&inner.token_secret, "slack:U060", 1000, 10, &[3u8; 16]); - let err = redeem_email_token(&inner, tok.as_str(), 2000).unwrap_err(); + let tok = + token::mint(&inner.token_secret, "slack:U060", 1000, 10, &[3u8; 16]).expect("mint"); + let err = reserve_email_token(&inner, tok.as_str(), 2000).unwrap_err(); assert_eq!(err.status, StatusCode::GONE); } @@ -389,4 +569,12 @@ mod tests { assert_eq!(urlencode("slack:U060"), "slack%3AU060"); assert_eq!(urlencode("a-b_c.d~e"), "a-b_c.d~e"); } + + #[test] + fn verify_page_escapes_export_supplied_subject() { + let page = verify_page(r#"slack:"#, "buzz://safe"); + assert!(!page.contains(""#, "buzz://safe"); diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index d8768af946..ed8c06a71f 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -15,6 +15,8 @@ pub(crate) struct PendingCommunityDeepLink { code: Option, policy_receipt: Option, name: Option, + /// Claim-service base URL, only set for the `join-slack` kind. + service: Option, } #[derive(Default)] @@ -29,6 +31,7 @@ impl PendingCommunityDeepLinks { && item.code == pending.code && item.policy_receipt == pending.policy_receipt && item.name == pending.name + && item.service == pending.service }) { return; } @@ -54,6 +57,69 @@ impl PendingCommunityDeepLinks { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PendingImportClaimDeepLink { + request_id: String, + #[serde(flatten)] + payload: ImportClaimDeepLinkPayload, +} + +#[derive(Default)] +pub(crate) struct PendingImportClaimDeepLinks(Mutex>); + +impl PendingImportClaimDeepLinks { + fn enqueue(&self, pending: PendingImportClaimDeepLink) { + let mut queue = self + .0 + .lock() + .expect("pending import-claim deep-link queue poisoned"); + if queue.iter().any(|item| item.payload == pending.payload) { + return; + } + queue.push_back(pending); + } + + fn first(&self) -> Option { + self.0 + .lock() + .expect("pending import-claim deep-link queue poisoned") + .front() + .cloned() + } + + fn acknowledge(&self, request_id: &str) -> bool { + let mut queue = self + .0 + .lock() + .expect("pending import-claim deep-link queue poisoned"); + if queue + .front() + .is_some_and(|item| item.request_id == request_id) + { + queue.pop_front(); + true + } else { + false + } + } +} + +#[tauri::command] +pub(crate) fn take_pending_import_claim_deep_link( + pending: State<'_, PendingImportClaimDeepLinks>, +) -> Option { + pending.first() +} + +#[tauri::command] +pub(crate) fn acknowledge_pending_import_claim_deep_link( + request_id: String, + pending: State<'_, PendingImportClaimDeepLinks>, +) -> bool { + pending.acknowledge(&request_id) +} + #[tauri::command] pub(crate) fn take_pending_community_deep_link( pending: State<'_, PendingCommunityDeepLinks>, @@ -69,6 +135,7 @@ pub(crate) fn acknowledge_pending_community_deep_link( pending.acknowledge(&id) } +#[allow(clippy::too_many_arguments)] fn queue_community_deep_link( app: &tauri::AppHandle, kind: &str, @@ -76,6 +143,7 @@ fn queue_community_deep_link( code: Option, policy_receipt: Option, name: Option, + service: Option, ) { app.state::() .enqueue(PendingCommunityDeepLink { @@ -85,6 +153,7 @@ fn queue_community_deep_link( code, policy_receipt, name, + service, }); } @@ -298,12 +367,15 @@ struct ImportClaimDeepLinkPayload { subject: String, /// Email channel: the single-use magic-link token to redeem at `service`. token: Option, - /// Email channel: base URL of the operator claim-service (POST target for - /// `/email/complete`). Always http(s); validated below. + /// Base URL of the operator claim-service. The email channel POSTs to + /// `/email/complete`; the OIDC channel uses it to bind the callback to the + /// pending `join-slack` transaction. service: Option, /// OIDC channel marker (`"oidc"`) — the attestation is already published by /// the service, so the app only needs to publish its self-claim. via: Option, + /// OIDC join channel: relay that received membership + attestation. + relay_url: Option, } /// A foreign-identity subject is `:` with both parts present and an @@ -324,7 +396,7 @@ fn validate_import_claim_subject(subject: &str) -> Result<(), String> { /// The claim-service URL is attacker-influenced (it rides in the link), so pin /// it to a plain http(s) origin with no embedded credentials before the app /// will POST to it. -fn validate_import_claim_service(service: &str) -> Result<(), String> { +fn validate_claim_service(service: &str) -> Result<(), String> { let url = Url::parse(service).map_err(|error| format!("invalid service url: {error}"))?; if url.scheme() != "http" && url.scheme() != "https" { return Err("service must use http or https".into()); @@ -339,21 +411,34 @@ fn validate_import_claim_service(service: &str) -> Result<(), String> { } /// `buzz://import-claim?subject=slack:U060&token=…&service=https://…` (email) -/// or `buzz://import-claim?subject=slack:U060&via=oidc` (OIDC). Rejects a link -/// that identifies neither channel so the dialog never sees a half-formed one. +/// or `buzz://import-claim?subject=slack:U060&via=oidc&relay=wss://…&service=https://…` +/// (OIDC). Rejects a link that identifies neither complete channel so the +/// dialog never sees a half-formed one. fn parse_import_claim_deep_link(url: &Url) -> Result { let subject = non_empty_param(url, "subject")?; validate_import_claim_subject(&subject)?; let token = optional_non_empty_param(url, "token"); let service = optional_non_empty_param(url, "service"); let via = optional_non_empty_param(url, "via"); + let mut relay_url = None; match (token.as_deref(), service.as_deref(), via.as_deref()) { // Email channel: both halves present; the service must be well-formed. - (Some(_), Some(service), _) => validate_import_claim_service(service)?, - // OIDC channel: attestation already published; self-claim only. - (_, _, Some("oidc")) => {} - _ => return Err("import-claim requires token+service (email) or via=oidc".into()), + (Some(_), Some(service), None) => validate_claim_service(service)?, + // OIDC channel: bind the callback to both the target relay and the + // claim service from the pending join-slack transaction. + (None, Some(service), Some("oidc")) => { + validate_claim_service(service)?; + relay_url = Some( + parse_websocket_relay_param(url) + .ok_or_else(|| "import-claim OIDC requires a valid relay".to_string())?, + ); + } + _ => { + return Err( + "import-claim requires token+service (email) or via=oidc+relay+service".into(), + ) + } } Ok(ImportClaimDeepLinkPayload { @@ -361,9 +446,30 @@ fn parse_import_claim_deep_link(url: &Url) -> Result&service=` — the join +/// method for a Slack-migration community. The person signs in with Slack at +/// `service`, which registers them and attests their imported identity; the +/// relay is the community they join. Both params are required and validated so +/// onboarding never sees a half-formed link. +fn parse_join_slack_deep_link(url: &Url) -> Result { + let relay_url = + parse_websocket_relay_param(url).ok_or_else(|| "missing or invalid relay".to_string())?; + let service = non_empty_param(url, "service")?; + validate_claim_service(&service)?; + Ok(JoinSlackDeepLinkPayload { relay_url, service }) +} + /// Handle an incoming `buzz://` deep link URL. /// /// Currently supports: @@ -389,7 +495,7 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { return; }; activate_main_window(app); - queue_community_deep_link(app, "connect", relay_url.clone(), None, None, None); + queue_community_deep_link(app, "connect", relay_url.clone(), None, None, None, None); let _ = app.emit("deep-link-connect", relay_url); } Some("join") => { @@ -404,7 +510,7 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { let relay_url = payload["relayUrl"].as_str().unwrap_or_default().to_owned(); let code = payload["code"].as_str().map(str::to_owned); let policy_receipt = payload["policyReceipt"].as_str().map(str::to_owned); - queue_community_deep_link(app, "join", relay_url, code, policy_receipt, None); + queue_community_deep_link(app, "join", relay_url, code, policy_receipt, None, None); let _ = app.emit("deep-link-join", payload); } Some("add-community") => { @@ -420,6 +526,7 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { None, None, payload.name.clone(), + None, ); let _ = app.emit("deep-link-add-community", payload); } @@ -451,12 +558,40 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { Some("import-claim") => match parse_import_claim_deep_link(&url) { Ok(payload) => { activate_main_window(app); - let _ = app.emit("deep-link-import-claim", payload); + // OAuth commonly returns while the app is already open, but a + // relaunch must not lose the callback before React subscribes. + let pending = PendingImportClaimDeepLink { + request_id: uuid::Uuid::new_v4().to_string(), + payload, + }; + app.state::() + .enqueue(pending.clone()); + let _ = app.emit("deep-link-import-claim", pending); } Err(error) => { eprintln!("buzz-desktop: rejecting import-claim deep link: {error}: {url_str}"); } }, + Some("join-slack") => match parse_join_slack_deep_link(&url) { + Ok(payload) => { + activate_main_window(app); + // Queue for cold-launch survival: a fresh install must create a + // key before the Slack sign-in can begin. + queue_community_deep_link( + app, + "join-slack", + payload.relay_url.clone(), + None, + None, + None, + Some(payload.service.clone()), + ); + let _ = app.emit("deep-link-join-slack", payload); + } + Err(error) => { + eprintln!("buzz-desktop: rejecting join-slack deep link: {error}: {url_str}"); + } + }, Some(action) => { eprintln!("buzz-desktop: unknown deep link action: {action}"); } @@ -467,375 +602,4 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { } #[cfg(test)] -mod tests { - use url::Url; - - use super::{ - parse_add_community_deep_link, parse_import_claim_deep_link, parse_join_deep_link, - parse_message_deep_link, parse_nostr_bind_deep_link, PendingCommunityDeepLink, - PendingCommunityDeepLinks, - }; - - #[test] - fn parse_import_claim_email_channel() { - let url = Url::parse( - "buzz://import-claim?subject=slack:U060&token=v1.aa.bb.cc.dd&service=https%3A%2F%2Fmig.example", - ) - .unwrap(); - let p = parse_import_claim_deep_link(&url).unwrap(); - assert_eq!(p.subject, "slack:U060"); - assert_eq!(p.token.as_deref(), Some("v1.aa.bb.cc.dd")); - assert_eq!(p.service.as_deref(), Some("https://mig.example")); - assert_eq!(p.via, None); - } - - #[test] - fn parse_import_claim_oidc_channel() { - let url = Url::parse("buzz://import-claim?subject=slack:U060&via=oidc").unwrap(); - let p = parse_import_claim_deep_link(&url).unwrap(); - assert_eq!(p.subject, "slack:U060"); - assert_eq!(p.via.as_deref(), Some("oidc")); - assert_eq!(p.token, None); - } - - #[test] - fn parse_import_claim_rejects_incomplete_and_malformed() { - // Neither channel identifiable (subject only). - assert!(parse_import_claim_deep_link( - &Url::parse("buzz://import-claim?subject=slack:U060").unwrap() - ) - .is_err()); - // token without service. - assert!(parse_import_claim_deep_link( - &Url::parse("buzz://import-claim?subject=slack:U060&token=t").unwrap() - ) - .is_err()); - // Malformed subject (no source). - assert!(parse_import_claim_deep_link( - &Url::parse("buzz://import-claim?subject=U060&via=oidc").unwrap() - ) - .is_err()); - // Non-http service. - assert!(parse_import_claim_deep_link( - &Url::parse("buzz://import-claim?subject=slack:U060&token=t&service=file%3A%2F%2Fx") - .unwrap() - ) - .is_err()); - } - - fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink { - PendingCommunityDeepLink { - id: id.to_owned(), - kind: if code.is_some() { "join" } else { "connect" }.to_owned(), - relay_url: relay_url.to_owned(), - code: code.map(str::to_owned), - policy_receipt: None, - name: None, - } - } - - #[test] - fn pending_join_serializes_policy_receipt_for_cold_launch_recovery() { - let mut link = pending("join", "wss://relay.example", Some("invite")); - link.policy_receipt = Some("relay-signed-receipt".to_owned()); - - let payload = serde_json::to_value(link).unwrap(); - assert_eq!(payload["policyReceipt"], "relay-signed-receipt"); - } - - #[test] - fn pending_community_links_are_fifo_and_acknowledged_in_order() { - let queue = PendingCommunityDeepLinks::default(); - queue.enqueue(pending("first", "wss://one.example", Some("one"))); - queue.enqueue(pending("second", "wss://two.example", Some("two"))); - assert_eq!(queue.first().unwrap().id, "first"); - assert!(!queue.acknowledge("second")); - assert!(queue.acknowledge("first")); - assert_eq!(queue.first().unwrap().id, "second"); - } - - #[test] - fn pending_community_links_dedupe_exact_intents() { - let queue = PendingCommunityDeepLinks::default(); - queue.enqueue(pending("first", "wss://one.example", Some("one"))); - queue.enqueue(pending("duplicate", "wss://one.example", Some("one"))); - assert!(queue.acknowledge("first")); - assert!(queue.first().is_none()); - } - - fn valid_nostr_bind_url() -> Url { - Url::parse( - "buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard", - ) - .unwrap() - } - - #[test] - fn parse_add_community_deep_link_extracts_relay_and_name() { - let url = Url::parse( - "buzz://add-community?relay=wss%3A%2F%2Facme.communities.buzz.xyz&name=Acme%20Team&ignored=value", - ) - .unwrap(); - let payload = parse_add_community_deep_link(&url).unwrap(); - assert_eq!(payload.relay_url, "wss://acme.communities.buzz.xyz"); - assert_eq!(payload.name.as_deref(), Some("Acme Team")); - } - - #[test] - fn parse_add_community_deep_link_accepts_an_omitted_or_empty_name() { - for raw in [ - "buzz://add-community?relay=wss%3A%2F%2Facme.example", - "buzz://add-community?relay=wss%3A%2F%2Facme.example&name=", - ] { - assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()) - .unwrap() - .name - .is_none()); - } - } - - #[test] - fn parse_add_community_deep_link_rejects_invalid_relays() { - for raw in [ - "buzz://add-community", - "buzz://add-community?relay=", - "buzz://add-community?relay=not-a-url", - "buzz://add-community?relay=https%3A%2F%2Facme.example", - "buzz://add-community?relay=wss%3A%2F%2F", - ] { - assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()).is_none()); - } - } - - #[test] - fn parse_message_deep_link_extracts_required_params() { - let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert_eq!(payload["channelId"], "abc"); - assert_eq!(payload["messageId"], "xyz"); - assert!(payload["threadRootId"].is_null()); - } - - #[test] - fn parse_message_deep_link_accepts_buzz_scheme() { - let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert_eq!(payload["channelId"], "abc"); - assert_eq!(payload["messageId"], "xyz"); - } - - #[test] - fn parse_message_deep_link_includes_thread_root() { - let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=root1").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert_eq!(payload["threadRootId"], "root1"); - } - - #[test] - fn parse_message_deep_link_rejects_missing_id() { - let url = Url::parse("buzz://message?channel=abc").unwrap(); - assert!(parse_message_deep_link(&url).is_none()); - } - - #[test] - fn parse_message_deep_link_rejects_empty_channel() { - // Regression: `channel=&id=foo` previously produced channelId: "". - let url = Url::parse("buzz://message?channel=&id=foo").unwrap(); - assert!(parse_message_deep_link(&url).is_none()); - } - - #[test] - fn parse_message_deep_link_rejects_empty_id() { - let url = Url::parse("buzz://message?channel=abc&id=").unwrap(); - assert!(parse_message_deep_link(&url).is_none()); - } - - #[test] - fn parse_message_deep_link_treats_empty_thread_as_absent() { - let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert!(payload["threadRootId"].is_null()); - } - - #[test] - fn parse_join_deep_link_extracts_relay_and_code() { - let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def").unwrap(); - let payload = parse_join_deep_link(&url).expect("required params present"); - assert_eq!(payload["relayUrl"], "wss://relay.example"); - assert_eq!(payload["code"], "abc.def"); - assert!(payload["policyReceipt"].is_null()); - } - - #[test] - fn parse_join_deep_link_extracts_policy_receipt() { - let url = Url::parse( - "buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def&policy_receipt=receipt.value", - ) - .unwrap(); - let payload = parse_join_deep_link(&url).expect("required params present"); - assert_eq!(payload["policyReceipt"], "receipt.value"); - } - - #[test] - fn parse_join_deep_link_rejects_missing_code() { - let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_join_deep_link_rejects_empty_code() { - let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_join_deep_link_rejects_missing_relay() { - let url = Url::parse("buzz://join?code=abc.def").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_join_deep_link_rejects_non_websocket_relay() { - let url = Url::parse("buzz://join?relay=https%3A%2F%2Frelay.example&code=abc.def").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_valid_url() { - let payload = parse_nostr_bind_deep_link(&valid_nostr_bind_url()).unwrap(); - assert_eq!(payload.challenge_id, "550e8400-e29b-41d4-a716-446655440000"); - assert_eq!(payload.nonce, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567"); - assert_eq!(payload.verification_code, "123456"); - assert_eq!(payload.audience, "buzz:nostr-identity"); - assert_eq!(payload.action, "bind_nostr_identity"); - assert_eq!(payload.protocol, "buzz-nostr-identity"); - assert_eq!(payload.version, "1"); - assert_eq!(payload.origin, "https://example.com"); - assert_eq!(payload.expires_at, "2999-01-01T00:00:00Z"); - assert_eq!(payload.return_mode, "clipboard"); - assert_eq!(payload.callback_url, None); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_same_origin_callback_url() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fexample.com%2Fbuzz%3FmockSession%3D1").unwrap(); - let payload = parse_nostr_bind_deep_link(&url).unwrap(); - assert_eq!( - payload.callback_url.as_deref(), - Some("https://example.com/buzz?mockSession=1") - ); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_browser_fragment_return() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1&callback_url=https%3A%2F%2Fexample.com%2Fbuzz").unwrap(); - let payload = parse_nostr_bind_deep_link(&url).unwrap(); - - assert_eq!(payload.return_mode, "browser_fragment_v1"); - assert_eq!( - payload.callback_url.as_deref(), - Some("https://example.com/buzz") - ); - } - - #[test] - fn parse_nostr_bind_deep_link_requires_callback_for_browser_fragment_return() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1").unwrap(); - - assert_eq!( - parse_nostr_bind_deep_link(&url).unwrap_err(), - "browser_fragment_v1 requires callback_url" - ); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_cross_origin_callback_url() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fevil.example%2Fbuzz").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_http_callback_url() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=http%3A%2F%2Fexample.com%2Fbuzz").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_missing_challenge_id() { - let url = Url::parse("buzz://nostr-bind?nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_empty_nonce() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_missing_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_short_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_long_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=1234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_non_digit_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345a&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_wrong_action() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=wrong&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_wrong_audience() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=other&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_non_https_origin() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=http%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_origin_with_path() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com%2Fbind&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_origin_with_credentials() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fuser%40example.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_unsupported_return_mode() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=callback").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_expired_link_for_user_facing_error() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2000-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - let payload = parse_nostr_bind_deep_link(&url).unwrap(); - assert_eq!(payload.expires_at, "2000-01-01T00:00:00Z"); - } -} +mod tests; diff --git a/desktop/src-tauri/src/deep_link/tests.rs b/desktop/src-tauri/src/deep_link/tests.rs new file mode 100644 index 0000000000..af35473e01 --- /dev/null +++ b/desktop/src-tauri/src/deep_link/tests.rs @@ -0,0 +1,439 @@ + +use url::Url; + +use super::{ + parse_add_community_deep_link, parse_import_claim_deep_link, parse_join_deep_link, + parse_join_slack_deep_link, parse_message_deep_link, parse_nostr_bind_deep_link, + PendingCommunityDeepLink, PendingCommunityDeepLinks, PendingImportClaimDeepLink, + PendingImportClaimDeepLinks, +}; + +#[test] +fn parse_join_slack_extracts_relay_and_service() { + let url = Url::parse( + "buzz://join-slack?relay=wss%3A%2F%2Frelay.example&service=https%3A%2F%2Fmig.example", + ) + .unwrap(); + let p = parse_join_slack_deep_link(&url).unwrap(); + assert_eq!(p.relay_url, "wss://relay.example"); + assert_eq!(p.service, "https://mig.example"); +} + +#[test] +fn parse_join_slack_rejects_missing_relay_or_bad_service() { + // missing relay + assert!(parse_join_slack_deep_link( + &Url::parse("buzz://join-slack?service=https%3A%2F%2Fmig.example").unwrap() + ) + .is_err()); + // missing service + assert!(parse_join_slack_deep_link( + &Url::parse("buzz://join-slack?relay=wss%3A%2F%2Frelay.example").unwrap() + ) + .is_err()); + // non-http service + assert!(parse_join_slack_deep_link( + &Url::parse("buzz://join-slack?relay=wss%3A%2F%2Fr.example&service=file%3A%2F%2Fx") + .unwrap() + ) + .is_err()); +} + +#[test] +fn parse_import_claim_email_channel() { + let url = Url::parse( + "buzz://import-claim?subject=slack:U060&token=v1.aa.bb.cc.dd&service=https%3A%2F%2Fmig.example", + ) + .unwrap(); + let p = parse_import_claim_deep_link(&url).unwrap(); + assert_eq!(p.subject, "slack:U060"); + assert_eq!(p.token.as_deref(), Some("v1.aa.bb.cc.dd")); + assert_eq!(p.service.as_deref(), Some("https://mig.example")); + assert_eq!(p.via, None); + assert_eq!(p.relay_url, None); +} + +#[test] +fn parse_import_claim_oidc_channel() { + let url = Url::parse( + "buzz://import-claim?subject=slack:U060&via=oidc&relay=wss%3A%2F%2Frelay.example&service=https%3A%2F%2Fmig.example", + ) + .unwrap(); + let p = parse_import_claim_deep_link(&url).unwrap(); + assert_eq!(p.subject, "slack:U060"); + assert_eq!(p.via.as_deref(), Some("oidc")); + assert_eq!(p.token, None); + assert_eq!(p.service.as_deref(), Some("https://mig.example")); + assert_eq!(p.relay_url.as_deref(), Some("wss://relay.example")); +} + +#[test] +fn parse_import_claim_rejects_incomplete_and_malformed() { + // Neither channel identifiable (subject only). + assert!(parse_import_claim_deep_link( + &Url::parse("buzz://import-claim?subject=slack:U060").unwrap() + ) + .is_err()); + assert!(parse_import_claim_deep_link( + &Url::parse("buzz://import-claim?subject=slack:U060&via=oidc").unwrap() + ) + .is_err()); + // token without service. + assert!(parse_import_claim_deep_link( + &Url::parse("buzz://import-claim?subject=slack:U060&token=t").unwrap() + ) + .is_err()); + // Malformed subject (no source). + assert!(parse_import_claim_deep_link( + &Url::parse("buzz://import-claim?subject=U060&via=oidc").unwrap() + ) + .is_err()); + // Non-http service. + assert!(parse_import_claim_deep_link( + &Url::parse("buzz://import-claim?subject=slack:U060&token=t&service=file%3A%2F%2Fx") + .unwrap() + ) + .is_err()); +} + +#[test] +fn pending_import_claims_dedupe_and_acknowledge() { + let queue = PendingImportClaimDeepLinks::default(); + let payload = parse_import_claim_deep_link( + &Url::parse( + "buzz://import-claim?subject=slack:U060&via=oidc&relay=wss%3A%2F%2Frelay.example&service=https%3A%2F%2Fmig.example", + ) + .unwrap(), + ) + .unwrap(); + queue.enqueue(PendingImportClaimDeepLink { + request_id: "first".into(), + payload: payload.clone(), + }); + queue.enqueue(PendingImportClaimDeepLink { + request_id: "duplicate".into(), + payload, + }); + + assert_eq!(queue.first().unwrap().request_id, "first"); + assert!(!queue.acknowledge("wrong")); + assert!(queue.acknowledge("first")); + assert!(queue.first().is_none()); +} + +fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink { + PendingCommunityDeepLink { + id: id.to_owned(), + kind: if code.is_some() { "join" } else { "connect" }.to_owned(), + relay_url: relay_url.to_owned(), + code: code.map(str::to_owned), + policy_receipt: None, + name: None, + service: None, + } +} + +#[test] +fn pending_join_serializes_policy_receipt_for_cold_launch_recovery() { + let mut link = pending("join", "wss://relay.example", Some("invite")); + link.policy_receipt = Some("relay-signed-receipt".to_owned()); + + let payload = serde_json::to_value(link).unwrap(); + assert_eq!(payload["policyReceipt"], "relay-signed-receipt"); +} + +#[test] +fn pending_community_links_are_fifo_and_acknowledged_in_order() { + let queue = PendingCommunityDeepLinks::default(); + queue.enqueue(pending("first", "wss://one.example", Some("one"))); + queue.enqueue(pending("second", "wss://two.example", Some("two"))); + assert_eq!(queue.first().unwrap().id, "first"); + assert!(!queue.acknowledge("second")); + assert!(queue.acknowledge("first")); + assert_eq!(queue.first().unwrap().id, "second"); +} + +#[test] +fn pending_community_links_dedupe_exact_intents() { + let queue = PendingCommunityDeepLinks::default(); + queue.enqueue(pending("first", "wss://one.example", Some("one"))); + queue.enqueue(pending("duplicate", "wss://one.example", Some("one"))); + assert!(queue.acknowledge("first")); + assert!(queue.first().is_none()); +} + +fn valid_nostr_bind_url() -> Url { + Url::parse( + "buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard", + ) + .unwrap() +} + +#[test] +fn parse_add_community_deep_link_extracts_relay_and_name() { + let url = Url::parse( + "buzz://add-community?relay=wss%3A%2F%2Facme.communities.buzz.xyz&name=Acme%20Team&ignored=value", + ) + .unwrap(); + let payload = parse_add_community_deep_link(&url).unwrap(); + assert_eq!(payload.relay_url, "wss://acme.communities.buzz.xyz"); + assert_eq!(payload.name.as_deref(), Some("Acme Team")); +} + +#[test] +fn parse_add_community_deep_link_accepts_an_omitted_or_empty_name() { + for raw in [ + "buzz://add-community?relay=wss%3A%2F%2Facme.example", + "buzz://add-community?relay=wss%3A%2F%2Facme.example&name=", + ] { + assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()) + .unwrap() + .name + .is_none()); + } +} + +#[test] +fn parse_add_community_deep_link_rejects_invalid_relays() { + for raw in [ + "buzz://add-community", + "buzz://add-community?relay=", + "buzz://add-community?relay=not-a-url", + "buzz://add-community?relay=https%3A%2F%2Facme.example", + "buzz://add-community?relay=wss%3A%2F%2F", + ] { + assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()).is_none()); + } +} + +#[test] +fn parse_message_deep_link_extracts_required_params() { + let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert_eq!(payload["channelId"], "abc"); + assert_eq!(payload["messageId"], "xyz"); + assert!(payload["threadRootId"].is_null()); +} + +#[test] +fn parse_message_deep_link_accepts_buzz_scheme() { + let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert_eq!(payload["channelId"], "abc"); + assert_eq!(payload["messageId"], "xyz"); +} + +#[test] +fn parse_message_deep_link_includes_thread_root() { + let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=root1").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert_eq!(payload["threadRootId"], "root1"); +} + +#[test] +fn parse_message_deep_link_rejects_missing_id() { + let url = Url::parse("buzz://message?channel=abc").unwrap(); + assert!(parse_message_deep_link(&url).is_none()); +} + +#[test] +fn parse_message_deep_link_rejects_empty_channel() { + // Regression: `channel=&id=foo` previously produced channelId: "". + let url = Url::parse("buzz://message?channel=&id=foo").unwrap(); + assert!(parse_message_deep_link(&url).is_none()); +} + +#[test] +fn parse_message_deep_link_rejects_empty_id() { + let url = Url::parse("buzz://message?channel=abc&id=").unwrap(); + assert!(parse_message_deep_link(&url).is_none()); +} + +#[test] +fn parse_message_deep_link_treats_empty_thread_as_absent() { + let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert!(payload["threadRootId"].is_null()); +} + +#[test] +fn parse_join_deep_link_extracts_relay_and_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def").unwrap(); + let payload = parse_join_deep_link(&url).expect("required params present"); + assert_eq!(payload["relayUrl"], "wss://relay.example"); + assert_eq!(payload["code"], "abc.def"); + assert!(payload["policyReceipt"].is_null()); +} + +#[test] +fn parse_join_deep_link_extracts_policy_receipt() { + let url = Url::parse( + "buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def&policy_receipt=receipt.value", + ) + .unwrap(); + let payload = parse_join_deep_link(&url).expect("required params present"); + assert_eq!(payload["policyReceipt"], "receipt.value"); +} + +#[test] +fn parse_join_deep_link_rejects_missing_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_join_deep_link_rejects_empty_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_join_deep_link_rejects_missing_relay() { + let url = Url::parse("buzz://join?code=abc.def").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_join_deep_link_rejects_non_websocket_relay() { + let url = Url::parse("buzz://join?relay=https%3A%2F%2Frelay.example&code=abc.def").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_valid_url() { + let payload = parse_nostr_bind_deep_link(&valid_nostr_bind_url()).unwrap(); + assert_eq!(payload.challenge_id, "550e8400-e29b-41d4-a716-446655440000"); + assert_eq!(payload.nonce, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567"); + assert_eq!(payload.verification_code, "123456"); + assert_eq!(payload.audience, "buzz:nostr-identity"); + assert_eq!(payload.action, "bind_nostr_identity"); + assert_eq!(payload.protocol, "buzz-nostr-identity"); + assert_eq!(payload.version, "1"); + assert_eq!(payload.origin, "https://example.com"); + assert_eq!(payload.expires_at, "2999-01-01T00:00:00Z"); + assert_eq!(payload.return_mode, "clipboard"); + assert_eq!(payload.callback_url, None); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_same_origin_callback_url() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fexample.com%2Fbuzz%3FmockSession%3D1").unwrap(); + let payload = parse_nostr_bind_deep_link(&url).unwrap(); + assert_eq!( + payload.callback_url.as_deref(), + Some("https://example.com/buzz?mockSession=1") + ); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_browser_fragment_return() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1&callback_url=https%3A%2F%2Fexample.com%2Fbuzz").unwrap(); + let payload = parse_nostr_bind_deep_link(&url).unwrap(); + + assert_eq!(payload.return_mode, "browser_fragment_v1"); + assert_eq!( + payload.callback_url.as_deref(), + Some("https://example.com/buzz") + ); +} + +#[test] +fn parse_nostr_bind_deep_link_requires_callback_for_browser_fragment_return() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1").unwrap(); + + assert_eq!( + parse_nostr_bind_deep_link(&url).unwrap_err(), + "browser_fragment_v1 requires callback_url" + ); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_cross_origin_callback_url() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fevil.example%2Fbuzz").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_http_callback_url() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=http%3A%2F%2Fexample.com%2Fbuzz").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_missing_challenge_id() { + let url = Url::parse("buzz://nostr-bind?nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_empty_nonce() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_missing_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_short_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_long_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=1234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_non_digit_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345a&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_wrong_action() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=wrong&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_wrong_audience() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=other&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_non_https_origin() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=http%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_origin_with_path() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com%2Fbind&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_origin_with_credentials() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fuser%40example.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_unsupported_return_mode() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=callback").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_expired_link_for_user_facing_error() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2000-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + let payload = parse_nostr_bind_deep_link(&url).unwrap(); + assert_eq!(payload.expires_at, "2000-01-01T00:00:00Z"); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6a2eec75fb..0e08358f90 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -33,8 +33,9 @@ use app_state::{build_app_state, resolve_persisted_identity, AppState}; use builderlab::*; use commands::*; use deep_link::{ - acknowledge_pending_community_deep_link, handle_deep_link_url, - take_pending_community_deep_link, PendingCommunityDeepLinks, + acknowledge_pending_community_deep_link, acknowledge_pending_import_claim_deep_link, + handle_deep_link_url, take_pending_community_deep_link, take_pending_import_claim_deep_link, + PendingCommunityDeepLinks, PendingImportClaimDeepLinks, }; use huddle::audio_output::{ get_audio_output_device, list_audio_output_devices, set_audio_output_device, @@ -353,6 +354,7 @@ pub fn run() { .manage(build_app_state()) .manage(ClipboardState::new()) .manage(PendingCommunityDeepLinks::default()) + .manage(PendingImportClaimDeepLinks::default()) .manage(BuilderlabSession::default()) .manage(BuilderlabLogin::default()) .manage(commands::pairing::PairingHandle::new()) @@ -647,6 +649,8 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ take_pending_community_deep_link, acknowledge_pending_community_deep_link, + take_pending_import_claim_deep_link, + acknowledge_pending_import_claim_deep_link, start_builderlab_login, cancel_builderlab_login, get_builderlab_auth, diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index a91e727854..21a4075b33 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -1,6 +1,7 @@ import { isTauri } from "@tauri-apps/api/core"; import { emit } from "@tauri-apps/api/event"; -import { QueryClientProvider } from "@tanstack/react-query"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { QueryClientProvider, useQueryClient } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; import { type ReactNode, @@ -21,6 +22,8 @@ import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground"; import { useReloadShortcut } from "@/app/useReloadShortcut"; import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys"; import { ImportClaimDialog } from "@/features/messages/ui/ImportClaimDialog"; +import { publishImportIdentityClaim } from "@/features/messages/lib/publishImportIdentityClaim"; +import { importIdentityBindingsQueryKey } from "@/features/messages/useImportIdentityBindings"; import { useAppOnboardingState } from "@/features/onboarding/hooks"; import { useMachineOnboardingState } from "@/features/onboarding/machineOnboarding"; import { @@ -296,7 +299,10 @@ function CommunityApp({ reconnectCommunity, } = useCommunities(); const communityOnboarding = useCommunityOnboarding(); + const queryClient = useQueryClient(); const connectingTransactionRef = useRef(null); + const slackAuthTransactionRef = useRef(null); + const slackClaimTransactionRef = useRef(null); // Tracks the ID of the profile-check request that has been launched for the // current connecting transaction. Prevents the effect from launching a // second request if it re-runs while a fetch is in flight. @@ -401,6 +407,40 @@ function CommunityApp({ transitionCommunity, ]); + // Slack-migration join: open the operator's claim-service in the browser so + // the person signs in with Slack. The `buzz://import-claim` return then + // advances this transaction to "connecting" (see ImportClaimDialog). Guarded + // so the browser opens once per transaction; if the key isn't ready yet the + // effect re-fires when `currentPubkey` resolves. + const handleCommunityOnboardingSlackAuth = useCallback(async () => { + const transaction = communityOnboarding.transaction; + if (transaction?.stage !== "slack-auth" || !transaction.slackService) + return; + if (!currentPubkey) return; + if (slackAuthTransactionRef.current === transaction.id) return; + slackAuthTransactionRef.current = transaction.id; + const base = transaction.slackService.replace(/\/+$/, ""); + const startUrl = `${base}/oidc/start?pubkey=${encodeURIComponent( + currentPubkey, + )}`; + try { + await openUrl(startUrl); + } catch (error) { + slackAuthTransactionRef.current = null; // allow a retry + communityOnboarding.update({ + error: error instanceof Error ? error.message : String(error), + }); + } + }, [communityOnboarding, currentPubkey]); + + // Reopen the Slack sign-in on demand (the browser tab was closed, or the + // auto-open failed): clear the once-guard, then open again. + const handleCommunityOnboardingSlackAuthRetry = useCallback(() => { + slackAuthTransactionRef.current = null; + communityOnboarding.update({ error: undefined }); + void handleCommunityOnboardingSlackAuth(); + }, [communityOnboarding, handleCommunityOnboardingSlackAuth]); + const handleCommunityOnboardingCancel = useCallback(async () => { const transaction = communityOnboarding.transaction; communityOnboarding.clear(); @@ -438,6 +478,7 @@ function CommunityApp({ if (transaction?.stage !== "connecting") { connectingTransactionRef.current = null; profileCheckTransactionRef.current = null; + slackClaimTransactionRef.current = null; } }, [transaction?.stage]); const targetIsReady = @@ -445,9 +486,53 @@ function CommunityApp({ community.isReady && community.appliedKey === communityKey; useEffect(() => { - if (transaction?.stage !== "connecting" || !targetIsReady) return; + if ( + transaction?.stage !== "connecting" || + !targetIsReady || + transaction.error + ) + return; const transactionId = transaction.id; const relayUrl = transaction.relayUrl; + + // A Slack join is admitted by the migration service before this app + // connects. Publish the person's consent only after the target relay is + // active; doing it during the OAuth callback would publish to the previous + // community (or fail on a first-community join). + if (transaction.slackSubject) { + if (slackClaimTransactionRef.current === transactionId) return; + slackClaimTransactionRef.current = transactionId; + const subject = transaction.slackSubject; + void publishImportIdentityClaim(subject) + .then(async () => { + if ( + !isTransactionStillConnecting(transactionRef.current, transactionId) + ) + return; + await queryClient.invalidateQueries({ + queryKey: importIdentityBindingsQueryKey, + }); + communityOnboarding.update( + { slackSubject: undefined, error: undefined }, + transactionId, + ); + }) + .catch((error: unknown) => { + if ( + !isTransactionStillConnecting(transactionRef.current, transactionId) + ) + return; + slackClaimTransactionRef.current = null; + communityOnboarding.update( + { + error: error instanceof Error ? error.message : String(error), + }, + transactionId, + ); + }); + return; + } + if (profileCheckTransactionRef.current === transactionId) return; profileCheckTransactionRef.current = transactionId; @@ -473,10 +558,13 @@ function CommunityApp({ }); }, [ communityOnboarding, + queryClient, targetIsReady, + transaction?.error, transaction?.stage, transaction?.id, transaction?.relayUrl, + transaction?.slackSubject, ]); // During "entering" the transaction stays alive as a curtain: the app mounts // underneath (already pointed at the Welcome channel route) while the @@ -575,6 +663,8 @@ function CommunityApp({ ) : null} @@ -655,7 +745,8 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { const transaction = communityOnboarding.transaction; const isDeepLink = transaction?.source === "deep-link-join" || - transaction?.source === "deep-link-connect"; + transaction?.source === "deep-link-connect" || + transaction?.source === "deep-link-join-slack"; const shouldAcknowledgeDeepLink = isDeepLink && !transaction.acknowledged; return ( diff --git a/desktop/src/features/messages/lib/publishImportIdentityClaim.ts b/desktop/src/features/messages/lib/publishImportIdentityClaim.ts new file mode 100644 index 0000000000..564aaebeaa --- /dev/null +++ b/desktop/src/features/messages/lib/publishImportIdentityClaim.ts @@ -0,0 +1,27 @@ +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent } from "@/shared/api/tauri"; +import { KIND_IMPORT_IDENTITY_CLAIM } from "@/shared/constants/kinds"; + +/** + * Publish the current device identity's consent to an imported identity + * binding. The relay connection must already point at the community that owns + * the imported history. + */ +export async function publishImportIdentityClaim( + subject: string, +): Promise { + const source = subject.split(":", 1)[0] || "slack"; + const event = await signRelayEvent({ + kind: KIND_IMPORT_IDENTITY_CLAIM, + content: "", + tags: [ + ["d", subject], + ["import", source], + ], + }); + await relayClient.publishEvent( + event, + "Timed out publishing your identity claim.", + "Failed to publish your identity claim.", + ); +} diff --git a/desktop/src/features/messages/ui/ImportClaimDialog.tsx b/desktop/src/features/messages/ui/ImportClaimDialog.tsx index f67d9de72b..8dadd67481 100644 --- a/desktop/src/features/messages/ui/ImportClaimDialog.tsx +++ b/desktop/src/features/messages/ui/ImportClaimDialog.tsx @@ -2,10 +2,9 @@ import { useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { toast } from "sonner"; -import { relayClient } from "@/shared/api/relayClient"; -import { signRelayEvent } from "@/shared/api/tauri"; +import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; + import { getIdentity } from "@/shared/api/tauriIdentity"; -import { KIND_IMPORT_IDENTITY_CLAIM } from "@/shared/constants/kinds"; import { type ImportClaimDeepLinkPayload, listenForImportClaimDeepLinks, @@ -21,9 +20,40 @@ import { } from "@/shared/ui/dialog"; import { importIdentityBindingsQueryKey } from "../useImportIdentityBindings"; +import { publishImportIdentityClaim } from "../lib/publishImportIdentityClaim"; type Phase = "confirm" | "working" | "done" | "error"; +function normalizedUrl(value: string): string | null { + try { + const url = new URL(value); + url.protocol = url.protocol.toLowerCase(); + url.hostname = url.hostname.toLowerCase(); + url.pathname = url.pathname.replace(/\/+$/, "") || "/"; + return url.toString().replace(/\/$/, ""); + } catch { + return null; + } +} + +function assertMatchesSlackJoin( + payload: ImportClaimDeepLinkPayload, + relayUrl: string, + serviceUrl: string | undefined, +): void { + if ( + payload.via !== "oidc" || + !payload.relayUrl || + !payload.service || + normalizedUrl(payload.relayUrl) !== normalizedUrl(relayUrl) || + normalizedUrl(payload.service) !== normalizedUrl(serviceUrl ?? "") + ) { + throw new Error( + "This Slack response doesn't match the pending community join. Restart from the original Slack join link.", + ); + } +} + /** * Completes a zero-touch Slack→Buzz identity migration when the operator's * claim-service opens `buzz://import-claim`. Two channels arrive here: @@ -34,14 +64,17 @@ type Phase = "confirm" | "working" | "done" | "error"; * publishes the subject's self-claim (kind 30624). Only both together * attribute the imported history — so a stray link can, at worst, make the * user consent to an identity no attestation vouches for (inert). - * - **oidc**: `via === "oidc"`. Slack already verified the user server-side and - * the attestation is published; the app only publishes the self-claim. + * - **oidc**: `via === "oidc"`. Slack already verified the user server-side, + * admitted their public key, and published the attestation. During a + * `join-slack` transaction this dialog records the verified subject; the + * onboarding flow connects to the target community before self-claiming. * * Because the self-claim is a consent signature, we always show an explicit * confirm step naming the subject before signing anything. */ export function ImportClaimDialog() { const queryClient = useQueryClient(); + const communityOnboarding = useCommunityOnboarding(); const [payload, setPayload] = React.useState(null); const [phase, setPhase] = React.useState("confirm"); @@ -80,25 +113,38 @@ export function ImportClaimDialog() { await completeEmailClaim(payload.service, payload.token, pubkey); } - // Both channels: publish the subject's self-claim (the consent half). - const source = payload.subject.split(":", 1)[0] || "slack"; - const event = await signRelayEvent({ - kind: KIND_IMPORT_IDENTITY_CLAIM, - content: "", - tags: [ - ["d", payload.subject], - ["import", source], - ], - }); - await relayClient.publishEvent( - event, - "Timed out publishing your identity claim.", - "Failed to publish your identity claim.", - ); + // If this claim completed a Slack-migration *join* (the person is mid + // onboarding at the slack-auth stage), connect to that community before + // publishing the self-claim. Publishing here would target whichever + // community happened to be active before the join. + const tx = communityOnboarding.transaction; + if (tx?.stage === "slack-auth") { + assertMatchesSlackJoin(payload, tx.relayUrl, tx.slackService); + communityOnboarding.update( + { + stage: "connecting", + slackSubject: payload.subject, + error: undefined, + }, + tx.id, + ); + toast.success("Signed in with Slack — setting up your workspace."); + close(); + return; + } + if (payload.via === "oidc") { + throw new Error( + "Start Slack sign-in from your team's Slack migration link.", + ); + } + + // Email fallback claims run inside an already-connected community. + await publishImportIdentityClaim(payload.subject); await queryClient.invalidateQueries({ queryKey: importIdentityBindingsQueryKey, }); + setPhase("done"); toast.success("Your imported history is now linked to your account."); } catch (err) { @@ -107,7 +153,7 @@ export function ImportClaimDialog() { setPhase("error"); toast.error(`Couldn't link your history: ${message}`); } - }, [payload, queryClient]); + }, [payload, queryClient, communityOnboarding, close]); const open = payload !== null; diff --git a/desktop/src/features/onboarding/communityOnboarding.test.mjs b/desktop/src/features/onboarding/communityOnboarding.test.mjs index e9645b191e..a004fa8871 100644 --- a/desktop/src/features/onboarding/communityOnboarding.test.mjs +++ b/desktop/src/features/onboarding/communityOnboarding.test.mjs @@ -55,6 +55,55 @@ test("non-invite onboarding starts at connection", () => { assert.equal(transaction.stage, "connecting"); }); +test("Slack migration join persists its auth stage and service", () => { + const storage = createMemoryStorage(); + const transaction = startCommunityOnboarding( + { + source: "deep-link-join-slack", + relayUrl: "wss://relay.example", + slackService: "https://migrate.example", + }, + storage, + ); + assert.equal(transaction.stage, "slack-auth"); + assert.equal(transaction.slackService, "https://migrate.example"); + + const persisted = loadCommunityOnboardingTransaction(storage); + assert.equal(persisted?.id, transaction.id); + assert.equal(persisted?.stage, "slack-auth"); + assert.equal(persisted?.slackService, "https://migrate.example"); +}); + +test("reopening a Slack join restarts auth and clears a stale subject", () => { + const storage = createMemoryStorage(); + const first = startCommunityOnboarding( + { + source: "deep-link-join-slack", + relayUrl: "wss://relay.example", + slackService: "https://migrate.example", + }, + storage, + ); + updateCommunityOnboardingTransaction( + first, + { stage: "connecting", slackSubject: "slack:U060" }, + storage, + ); + + const reopened = startCommunityOnboarding( + { + source: "deep-link-join-slack", + relayUrl: "wss://relay.example", + slackService: "https://migrate.example", + }, + storage, + ); + assert.equal(reopened.id, first.id); + assert.equal(reopened.source, "deep-link-join-slack"); + assert.equal(reopened.stage, "slack-auth"); + assert.equal(reopened.slackSubject, undefined); +}); + test("same-relay ingress resumes rather than replacing progress", () => { const storage = createMemoryStorage(); const first = startCommunityOnboarding( @@ -139,6 +188,22 @@ test("malformed persisted state is ignored and can be cleared", () => { assert.equal(storage.length, 0); }); +test("persisted Slack auth without its dedicated service is ignored", () => { + const now = new Date().toISOString(); + const storage = createMemoryStorage({ + "buzz-community-onboarding-transaction.v1": JSON.stringify({ + id: "broken-slack-join", + source: "deep-link-join-slack", + stage: "slack-auth", + relayUrl: "wss://relay.example", + communityName: "Example", + createdAt: now, + updatedAt: now, + }), + }); + assert.equal(loadCommunityOnboardingTransaction(storage), null); +}); + test("completion is scoped by relay and pubkey and preserves legacy gate", () => { const storage = createMemoryStorage(); markCommunityOnboardingComplete("pubkey", "wss://relay.example", storage); diff --git a/desktop/src/features/onboarding/communityOnboarding.tsx b/desktop/src/features/onboarding/communityOnboarding.tsx index bbb636b3a1..f2641a58fa 100644 --- a/desktop/src/features/onboarding/communityOnboarding.tsx +++ b/desktop/src/features/onboarding/communityOnboarding.tsx @@ -12,9 +12,16 @@ export type CommunityOnboardingSource = | "add-community" | "membership-recovery" | "deep-link-connect" - | "deep-link-join"; + | "deep-link-join" + | "deep-link-join-slack"; export type CommunityOnboardingStage = + /** + * Slack-migration join: the person signs in with Slack in their browser, + * which registers them and attests their imported identity. We wait here + * until the `buzz://import-claim` return advances the transaction. + */ + | "slack-auth" | "claiming" | "connecting" | "profile" @@ -49,6 +56,13 @@ export type CommunityOnboardingTransaction = { communityId?: string; previousCommunityId?: string; addedCommunity?: boolean; + /** Claim-service base URL for the Slack-migration join (`deep-link-join-slack`). */ + slackService?: string; + /** + * Verified imported identity waiting to be self-claimed after the target + * community connection is active. + */ + slackSubject?: string; createdAt: string; updatedAt: string; error?: string; @@ -68,6 +82,7 @@ export type CommunityOnboardingTransactionPatch = Partial< | "communityName" | "error" | "acknowledged" + | "slackSubject" > >; @@ -80,6 +95,7 @@ export type StartCommunityOnboardingInput = { token?: string; reposDir?: string; policyReceipt?: string; + slackService?: string; }; function canonicalRelayUrl(rawRelayUrl: string) { @@ -99,20 +115,26 @@ function isTransaction( ): value is CommunityOnboardingTransaction { if (!value || typeof value !== "object") return false; const transaction = value as Partial; + const validStage = [ + "claiming", + "slack-auth", + "connecting", + "profile", + "team-intro", + "finalizing", + "entering", + ].includes(transaction.stage ?? ""); return ( typeof transaction.id === "string" && typeof transaction.relayUrl === "string" && typeof transaction.communityName === "string" && typeof transaction.createdAt === "string" && typeof transaction.updatedAt === "string" && - [ - "claiming", - "connecting", - "profile", - "team-intro", - "finalizing", - "entering", - ].includes(transaction.stage ?? "") + validStage && + (transaction.stage !== "slack-auth" || + (transaction.source === "deep-link-join-slack" && + typeof transaction.slackService === "string" && + transaction.slackService.length > 0)) ); } @@ -163,6 +185,11 @@ export function startCommunityOnboarding( token: input.token?.trim() || existing.token, reposDir: input.reposDir ?? existing.reposDir, policyReceipt: input.policyReceipt ?? existing.policyReceipt, + slackService: input.slackService ?? existing.slackService, + slackSubject: input.slackService ? undefined : existing.slackSubject, + source: input.slackService ? input.source : existing.source, + // A re-opened Slack-join link restarts the browser sign-in. + stage: input.slackService ? "slack-auth" : existing.stage, updatedAt: now.toISOString(), error: undefined, // A freshly opened link deserves fresh feedback — re-present the gate @@ -178,13 +205,18 @@ export function startCommunityOnboarding( id: crypto.randomUUID(), source: input.source, firstCommunityPage: input.firstCommunityPage, - stage: input.inviteCode?.trim() ? "claiming" : "connecting", + stage: input.slackService + ? "slack-auth" + : input.inviteCode?.trim() + ? "claiming" + : "connecting", relayUrl, inviteCode: input.inviteCode?.trim() || undefined, communityName: input.communityName?.trim() || deriveCommunityName(relayUrl), token: input.token?.trim() || undefined, reposDir: input.reposDir, policyReceipt: input.policyReceipt, + slackService: input.slackService, createdAt: timestamp, updatedAt: timestamp, }; diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 4b729ab33f..7ca7de020a 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -144,9 +144,13 @@ function LoadingDots({ label }: { label: string }) { export function CommunityOnboardingFlow({ onCancel, onConnect, + onSlackAuth, + onSlackAuthRetry, }: { onCancel: () => void; onConnect: () => void; + onSlackAuth: () => void; + onSlackAuthRetry: () => void; }) { const { transaction, update, clear } = useCommunityOnboarding(); const queryClient = useQueryClient(); @@ -201,6 +205,10 @@ export function CommunityOnboardingFlow({ if (transaction?.stage === "connecting") onConnect(); }, [onConnect, transaction?.stage]); + React.useEffect(() => { + if (transaction?.stage === "slack-auth") onSlackAuth(); + }, [onSlackAuth, transaction?.stage]); + // "Entering" curtain: the app is mounting on the Welcome route underneath. // Fade out when Welcome reports its first settled render — or after a // safety timeout so a slow load can never strand the user on this screen. @@ -462,8 +470,35 @@ export function CommunityOnboardingFlow({ )} data-testid="community-onboarding-body" > - {transaction.stage === "claiming" || - transaction.stage === "connecting" ? ( + {transaction.stage === "slack-auth" ? ( + <> + +

+ Join {transaction.communityName} with Slack +

+

+ {transaction.error ?? + "Continue in your browser to sign in with Slack, then return to Buzz to confirm the connection to your imported history."} +

+
+ + +
+ + ) : transaction.stage === "claiming" || + transaction.stage === "connecting" ? ( <>

diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index d292dce1da..a203269239 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -2,7 +2,6 @@ import { useMemo, useState } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Archive, - ArrowRightLeft, BellRing, Bot, Check, @@ -75,7 +74,6 @@ import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard"; import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard"; import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard"; import { MobilePairingCard } from "./MobilePairingCard"; -import { SlackMigrationCard } from "./SlackMigrationCard"; import { ModerationQueueCard } from "./ModerationQueueCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard"; @@ -101,7 +99,6 @@ export type SettingsSection = | "moderation" | "custom-emoji" | "local-archive" - | "migrate" | "mobile" | "updates"; @@ -121,7 +118,6 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [ "moderation", "custom-emoji", "local-archive", - "migrate", "mobile", "updates", ]; @@ -225,11 +221,6 @@ export const settingsSections: SettingsSectionDescriptor[] = [ label: "Local archive", icon: Archive, }, - { - value: "migrate", - label: "Migrate from Slack", - icon: ArrowRightLeft, - }, { value: "mobile", label: "Mobile", @@ -848,8 +839,6 @@ export function renderSettingsSection( return ; case "local-archive": return ; - case "migrate": - return ; case "mobile": return ; case "updates": diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 069b2afad7..90634e01f4 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -61,7 +61,6 @@ const settingsNavGroups: Array<{ "shortcuts", "custom-emoji", "local-archive", - "migrate", ], }, { diff --git a/desktop/src/features/settings/ui/SlackMigrationCard.tsx b/desktop/src/features/settings/ui/SlackMigrationCard.tsx deleted file mode 100644 index 5db450428f..0000000000 --- a/desktop/src/features/settings/ui/SlackMigrationCard.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { openUrl } from "@tauri-apps/plugin-opener"; -import { LogIn } from "lucide-react"; -import { useState } from "react"; -import { toast } from "sonner"; - -import { Button } from "@/shared/ui/button"; -import { Input } from "@/shared/ui/input"; - -import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; -import { SettingsSectionHeader } from "./SettingsSectionHeader"; - -const SERVICE_URL_STORAGE_KEY = "buzz.migrate.serviceUrl"; - -/** Trim, strip trailing slashes, and require a plain http(s) origin. */ -function normalizeServiceUrl(raw: string): string | null { - const trimmed = raw.trim().replace(/\/+$/, ""); - if (!trimmed) return null; - let url: URL; - try { - url = new URL(trimmed); - } catch { - return null; - } - if (url.protocol !== "http:" && url.protocol !== "https:") return null; - return trimmed; -} - -/** - * The start side of a Sign-in-with-Slack identity migration. Opening - * `/oidc/start` needs the person's Buzz pubkey, which only exists on - * this device — so the flow must begin in the app. On connect we open the - * operator's claim-service in the system browser with our pubkey; Slack then - * verifies the user, the service publishes the attestation, and the person is - * returned via a `buzz://import-claim` deep link (see ImportClaimDialog). - * - * Only the public key ever leaves the device; the service URL is whatever the - * operator shared, remembered locally for next time. - */ -export function SlackMigrationCard({ - currentPubkey, -}: { - currentPubkey?: string; -}) { - const [serviceUrl, setServiceUrl] = useState(() => { - try { - return localStorage.getItem(SERVICE_URL_STORAGE_KEY) ?? ""; - } catch { - return ""; - } - }); - const [connecting, setConnecting] = useState(false); - - async function handleConnect() { - const normalized = normalizeServiceUrl(serviceUrl); - if (!normalized) { - toast.error("Enter your migration service URL (https://…)."); - return; - } - if (!currentPubkey) { - toast.error("Your identity isn't ready yet — try again in a moment."); - return; - } - try { - localStorage.setItem(SERVICE_URL_STORAGE_KEY, normalized); - } catch { - // Non-fatal: a locked-down storage just means we don't remember the URL. - } - setConnecting(true); - try { - const startUrl = `${normalized}/oidc/start?pubkey=${encodeURIComponent( - currentPubkey, - )}`; - await openUrl(startUrl); - toast.success("Continue in your browser to sign in with Slack."); - } catch (err) { - toast.error( - `Couldn't open Slack sign-in: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - } finally { - setConnecting(false); - } - } - - return ( -
- - Claim the history imported from your Slack workspace so it shows - under your account. Sign in with Slack to prove it's you — nothing - is linked until you do, and only your public key leaves this device. - - } - /> - - - -
- -
-

Connect Slack account

-

- Enter the migration service URL your operator shared, then sign - in with Slack. -

-
-
-
- setServiceUrl(e.target.value)} - placeholder="https://migrate.yourteam.example" - value={serviceUrl} - /> - -
-
-
-
- ); -} diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index 218d070feb..c442da364a 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -48,6 +48,8 @@ export type NostrBindDeepLinkPayload = { * `desktop/src-tauri/src/deep_link.rs`. */ export type ImportClaimDeepLinkPayload = { + /** Native queue id used to survive cold launches without duplicate delivery. */ + requestId?: string; /** `:`, e.g. `slack:U060976D0QN`. */ subject: string; /** Email channel: single-use magic-link token to redeem at `service`. */ @@ -56,6 +58,8 @@ export type ImportClaimDeepLinkPayload = { service?: string; /** OIDC channel marker (`"oidc"`); the attestation is already published. */ via?: string; + /** OIDC join channel: community relay that received the attestation. */ + relayUrl?: string; }; /** @@ -70,11 +74,13 @@ export type JoinDeepLinkPayload = { type PendingCommunityDeepLink = { id: string; - kind: "connect" | "join" | "add-community"; + kind: "connect" | "join" | "add-community" | "join-slack"; relayUrl: string; code: string | null; name: string | null; policyReceipt: string | null; + /** Claim-service base URL — only set for the `join-slack` kind. */ + service: string | null; }; function acceptPendingCommunityDeepLink( @@ -88,13 +94,19 @@ function acceptPendingCommunityDeepLink( relayUrl: pending.relayUrl, name: pending.name ?? undefined, }) - : deps.startCommunityOnboarding({ - source: - pending.kind === "join" ? "deep-link-join" : "deep-link-connect", - relayUrl: pending.relayUrl, - inviteCode: pending.code ?? undefined, - policyReceipt: pending.policyReceipt ?? undefined, - }); + : pending.kind === "join-slack" + ? deps.startCommunityOnboarding({ + source: "deep-link-join-slack", + relayUrl: pending.relayUrl, + slackService: pending.service ?? undefined, + }) + : deps.startCommunityOnboarding({ + source: + pending.kind === "join" ? "deep-link-join" : "deep-link-connect", + relayUrl: pending.relayUrl, + inviteCode: pending.code ?? undefined, + policyReceipt: pending.policyReceipt ?? undefined, + }); return accepted ? invoke("acknowledge_pending_community_deep_link", { id: pending.id, @@ -155,6 +167,7 @@ export async function listenForDeepLinks( const stopAvailabilityListener = deps.onAddCommunityAvailable(drain); const connectPromise = listen("deep-link-connect", drain); const joinPromise = listen("deep-link-join", drain); + const joinSlackPromise = listen("deep-link-join-slack", drain); const addCommunityPromise = listen( "deep-link-add-community", drain, @@ -162,6 +175,7 @@ export async function listenForDeepLinks( const unlistens = await Promise.all([ connectPromise, joinPromise, + joinSlackPromise, addCommunityPromise, ]); drain(); @@ -195,10 +209,40 @@ export function listenForNostrBindDeepLinks( export function listenForImportClaimDeepLinks( onOpen: (payload: ImportClaimDeepLinkPayload) => void, ): Promise { + const delivered = new Set(); + const deliver = async (payload: ImportClaimDeepLinkPayload) => { + if (payload.requestId) { + if (delivered.has(payload.requestId)) return; + delivered.add(payload.requestId); + onOpen(payload); + await invoke("acknowledge_pending_import_claim_deep_link", { + requestId: payload.requestId, + }); + return; + } + onOpen(payload); + }; + const drain = async () => { + while (true) { + const pending = await invoke( + "take_pending_import_claim_deep_link", + ); + if (!pending) return; + await deliver(pending); + if (!pending.requestId) return; + } + }; return listen( "deep-link-import-claim", (event) => { - onOpen(event.payload); + void deliver(event.payload).catch((error: unknown) => { + console.warn("Failed to acknowledge import-claim deep link", error); + }); }, - ); + ).then((unlisten) => { + void drain().catch((error: unknown) => { + console.warn("Failed to drain pending import-claim deep links", error); + }); + return unlisten; + }); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index a0af9c1374..f1f5361d31 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -328,17 +328,28 @@ type E2eConfig = { // Event IDs that `get_event` should report as definitively not found. // Causes `useDraftRootStatus` to classify as `deleted`. deletedEventIds?: string[]; - // Pending community deep links (buzz://join / buzz://connect / buzz://add-community) seeded into - // the mocked Rust-side queue. Mirrors the real queue's semantics: + // Pending community deep links seeded into the mocked Rust-side queue. + // Mirrors the real queue's semantics: // `take_pending_community_deep_link` peeks the head and // `acknowledge_pending_community_deep_link` removes by id. Drives the // pending-invite gate and deep-link drain path in tests. pendingCommunityDeepLinks?: Array<{ id: string; - kind: "connect" | "join" | "add-community"; + kind: "connect" | "join" | "add-community" | "join-slack"; relayUrl: string; code?: string | null; name?: string | null; + policyReceipt?: string | null; + service?: string | null; + }>; + // Pending OAuth/email migration callbacks captured before React mounts. + pendingImportClaimDeepLinks?: Array<{ + requestId: string; + subject: string; + token?: string; + service?: string; + via?: string; + relayUrl?: string; }>; // When true, `get_identity` returns `lost: true` until `persist_current_identity` // or `import_identity` is called. Drives the identity-lost recovery UX in tests. @@ -3829,6 +3840,16 @@ let mockPendingCommunityDeepLinks: Array<{ relayUrl: string; code: string | null; name: string | null; + policyReceipt: string | null; + service: string | null; +}> = []; +let mockPendingImportClaimDeepLinks: Array<{ + requestId: string; + subject: string; + token?: string; + service?: string; + via?: string; + relayUrl?: string; }> = []; function resetMockPendingCommunityDeepLinks(config: E2eConfig | null) { @@ -3838,7 +3859,12 @@ function resetMockPendingCommunityDeepLinks(config: E2eConfig | null) { ...pending, code: pending.code ?? null, name: pending.name ?? null, + policyReceipt: pending.policyReceipt ?? null, + service: pending.service ?? null, })); + mockPendingImportClaimDeepLinks = ( + config?.mock?.pendingImportClaimDeepLinks ?? [] + ).map((pending) => ({ ...pending })); } function recordMockUserStatus(event: RelayEvent) { @@ -9858,6 +9884,19 @@ export function maybeInstallE2eTauriMocks() { mockPendingCommunityDeepLinks.splice(index, 1); return true; } + case "take_pending_import_claim_deep_link": + return mockPendingImportClaimDeepLinks[0] ?? null; + case "acknowledge_pending_import_claim_deep_link": { + const { requestId } = payload as { requestId: string }; + const index = mockPendingImportClaimDeepLinks.findIndex( + (pending) => pending.requestId === requestId, + ); + if (index === -1) { + return false; + } + mockPendingImportClaimDeepLinks.splice(index, 1); + return true; + } case "get_relay_http_url": return getRelayHttpUrl(activeConfig); case "relay_requires_membership": diff --git a/desktop/tests/e2e/import-claim.spec.ts b/desktop/tests/e2e/import-claim.spec.ts index 4f55339e69..5a876d4796 100644 --- a/desktop/tests/e2e/import-claim.spec.ts +++ b/desktop/tests/e2e/import-claim.spec.ts @@ -51,10 +51,36 @@ test.beforeEach(async ({ page }) => { .waitFor({ state: "visible", timeout: 15_000 }); }); -test("OIDC import-claim: confirm dialog → publishes self-claim → done", async ({ +test("join-slack callback: connects the target relay before self-claim", async ({ page, }) => { - await emitImportClaim(page, { subject: "slack:U060", via: "oidc" }); + const now = new Date().toISOString(); + await page.evaluate( + ({ createdAt }) => { + localStorage.setItem( + "buzz-community-onboarding-transaction.v1", + JSON.stringify({ + id: "slack-join-e2e", + source: "deep-link-join-slack", + stage: "slack-auth", + relayUrl: "ws://localhost:3000", + communityName: "E2E Test", + slackService: "http://mock.local", + createdAt, + updatedAt: createdAt, + }), + ); + }, + { createdAt: now }, + ); + await page.reload({ waitUntil: "domcontentloaded" }); + + await emitImportClaim(page, { + subject: "slack:U060", + via: "oidc", + relayUrl: "ws://localhost:3000", + service: "http://mock.local", + }); const dialog = page.getByRole("dialog"); await expect(dialog.getByText("Link your imported history")).toBeVisible(); @@ -63,18 +89,19 @@ test("OIDC import-claim: confirm dialog → publishes self-claim → done", asyn await page.screenshot({ path: `${SHOTS}/oidc-confirm.png` }); await dialog.getByRole("button", { name: "Link my history" }).click(); - await expect( - dialog.getByText(/now show under your account/i), - ).toBeVisible({ timeout: 10_000 }); - await waitForAnimations(page); - await page.screenshot({ path: `${SHOTS}/oidc-done.png` }); + await expect(dialog).toBeHidden({ timeout: 10_000 }); - // The subject's self-claim (kind 30624) was signed with the right d-tag. - const signed = await page.evaluate( - () => - (window as unknown as { __BUZZ_E2E_SIGNED_EVENTS__: SignedEvent[] }) - .__BUZZ_E2E_SIGNED_EVENTS__, - ); + // The target community becomes active before the deferred subject + // self-claim is signed and published. + let signed: SignedEvent[] = []; + await expect(async () => { + signed = await page.evaluate( + () => + (window as unknown as { __BUZZ_E2E_SIGNED_EVENTS__: SignedEvent[] }) + .__BUZZ_E2E_SIGNED_EVENTS__, + ); + expect(signed.some((event) => event.kind === 30624)).toBe(true); + }).toPass({ timeout: 10_000 }); const claim = signed.find((e) => e.kind === 30624); expect(claim, "a kind-30624 self-claim should have been signed").toBeTruthy(); expect(claim?.tags).toContainEqual(["d", "slack:U060"]); @@ -106,9 +133,9 @@ test("email import-claim: POSTs token + own pubkey to the service, then done", a const dialog = page.getByRole("dialog"); await expect(dialog.getByText("slack:U081")).toBeVisible(); await dialog.getByRole("button", { name: "Link my history" }).click(); - await expect( - dialog.getByText(/now show under your account/i), - ).toBeVisible({ timeout: 10_000 }); + await expect(dialog.getByText(/now show under your account/i)).toBeVisible({ + timeout: 10_000, + }); // The app redeemed the magic-link token with the service, sending the token // and its OWN 64-hex pubkey (never a private key). diff --git a/docs/slack-import.md b/docs/slack-import.md index e08f2782bf..0608a28913 100644 --- a/docs/slack-import.md +++ b/docs/slack-import.md @@ -90,10 +90,91 @@ provenance on every event records the original Slack identity regardless. What two-party consent removes is the *unilateral* admin — the realistic insider risk before production. -How each person's own key comes to exist (no distribution): they onboard in -Buzz via an invite link — the key is generated on their device and never -leaves it — then share their **npub** (public) with the operator for the -attestation, and run `buzz import claim` to consent. +### Slack migration join + +For people coming from the imported Slack workspace, use one dedicated +onboarding link: + +```text +buzz://join-slack?relay=&service= +``` + +For example: + +```text +buzz://join-slack?relay=wss%3A%2F%2Fbuzz.example.com&service=https%3A%2F%2Fmigrate.example.com +``` + +Opening it in Buzz: + +1. Creates or loads the person's device key and opens + `/oidc/start` in their browser. +2. Slack authenticates them. The claim service rejects an account from any + workspace other than its configured `SLACK_TEAM_ID`. +3. The service idempotently adds that device's public key to the target + community and publishes the owner/admin attestation. +4. Slack returns through the internal `buzz://import-claim` callback. Buzz + asks the person to confirm the link, connects to the target community, and + only then publishes their self-signed claim. +5. With both signatures present, imported messages for that Slack user render + under the person's Buzz profile. + +Slack OAuth is intentionally a migration-time onboarding method. It is not a +permanent Buzz sign-in method and does not appear in Settings. Do not +distribute `buzz://import-claim` URLs: they are short-lived callbacks generated +by the claim service, not an alternative invitation format. Use normal Buzz +invite links for people who are not members of the imported Slack workspace. +The `buzz import bind` and `buzz import claim` commands above remain +manual fallbacks. + +#### Configure the claim service + +Create a Slack OIDC application for the workspace and register this exact +redirect URL: + +```text +https://migrate.example.com/oidc/callback +``` + +Run `buzz-migrate` behind HTTPS with an owner/admin Buzz key: + +```bash +export BUZZ_RELAY_URL=wss://buzz.example.com +export BUZZ_PRIVATE_KEY= +export SLACK_CLIENT_ID= +export SLACK_CLIENT_SECRET= +export SLACK_TEAM_ID= + +buzz-migrate \ + --export-dir ./my-workspace-export \ + --bind 127.0.0.1:8787 \ + --base-url https://migrate.example.com +``` + +`--export-dir` supplies `users.json` for the optional email fallback; the OIDC +path gets the verified Slack user id directly from Slack. `--base-url` must be +the externally reachable claim-service origin; its `/oidc/callback` is the +default OIDC redirect URI. Use `--oidc-redirect-uri` only when the registered +redirect differs. If the relay requires a NIP-OA delegation, also set +`BUZZ_AUTH_TAG`. + +The join link's `relay` must identify the same community as +`BUZZ_RELAY_URL`. The service's `BUZZ_PRIVATE_KEY` must be an owner/admin key +for that community. + +The service holds a Buzz owner/admin private key and the Slack client secret. +Run it as trusted migration infrastructure, keep it off the public relay +process, terminate TLS in front of it, and retire the service and migration +link when onboarding is complete. The join link remains usable while the +service is running; it does not expire by itself. Never run `--dev` in +production. + +The email magic-link channel is an identity-attribution fallback for someone +who is already a community member; it deliberately does **not** grant +membership. Only a workspace-verified Slack OIDC join performs automatic +member admission. `buzz-migrate` currently has no production email-delivery +backend, so this channel is only useful in `--dev` or after integrating a +mailer. ## Relay requirements From e7744db3130c35615f3c7d82dd350360ba6fa6f9 Mon Sep 17 00:00:00 2001 From: Ren Koya Date: Fri, 24 Jul 2026 19:09:43 +0900 Subject: [PATCH 16/23] fix(slack-import): harden migration security per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the migration security review: close the OIDC takeover vector, workspace-scope identity, make channel imports crash-idempotent, and correct the docs. Takeover (OIDC): the callback no longer publishes anything. `/oidc/start` drops the unauthenticated `pubkey` param; the callback mints a single-use, short-TTL `code` bound to the Slack-verified subject and hands it to the app. The app redeems it at the new `POST /oidc/finalize` with a freshly signed self-claim, and the service publishes membership + attestation only against the pubkey that *signed* it (id + Schnorr verified, `d` tag must equal the verified subject). The code binds to the first key that redeems it. So a phisher who makes a victim authenticate gets neither the code (it reaches only the victim's app) nor a forged claim for a key they do not hold — the attested key is proven, never a caller-chosen parameter. Identity namespace: the binding `d` tag is now workspace-scoped `slack::` (Slack user ids are unique only within a workspace). `buzz import slack|bind|claim` take a required `--team-id`; the claim service requires `SLACK_TEAM_ID` on both channels. Imported messages carry the team-scoped id in `import_author` so the client still joins a message to its binding by the identical key. Idempotency: channel UUIDs are now derived deterministically from `team_id:channel_id` (uuid v5), so a crash between the channel-create relay write and the state save resumes onto the same channel instead of minting a duplicate. (State saves were already atomic via temp-file + rename.) Docs: scope the "zero custody" claim to end-user keys (the service does hold the operator admin key + Slack secret); add the message-subtype handling table and note `ts` is parsed as a string, not a float; fix the reaction "once per emoji" wording; warn against secrets in shell history and against leaving the relay-wide rate-limit/drift knobs raised after an import. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Ren Koya --- Cargo.lock | 2 + Cargo.toml | 2 +- crates/buzz-cli/src/commands/import.rs | 118 ++++- .../buzz-cli/src/commands/import/importer.rs | 87 +++- crates/buzz-cli/src/commands/import/state.rs | 9 +- crates/buzz-cli/src/lib.rs | 10 + crates/buzz-migrate/Cargo.toml | 1 + crates/buzz-migrate/src/main.rs | 65 ++- crates/buzz-migrate/src/oidc.rs | 5 +- crates/buzz-migrate/src/roster.rs | 33 +- crates/buzz-migrate/src/server.rs | 454 ++++++++++++++++-- crates/buzz-sdk/src/builders.rs | 22 +- desktop/src-tauri/src/deep_link.rs | 22 +- desktop/src-tauri/src/deep_link/tests.rs | 28 +- desktop/src/app/App.tsx | 16 +- .../lib/confirmImportBindings.test.mjs | 32 +- .../messages/lib/confirmImportBindings.ts | 4 +- .../lib/publishImportIdentityClaim.ts | 55 ++- .../messages/ui/ImportClaimDialog.tsx | 136 ++++-- .../messages/useImportIdentityBindings.ts | 17 +- .../onboarding/ui/CommunityOnboardingFlow.tsx | 2 +- desktop/src/shared/deep-link.ts | 9 +- desktop/tests/e2e/import-claim.spec.ts | 88 ++-- docs/slack-import.md | 130 +++-- 24 files changed, 1063 insertions(+), 284 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 68a2e956b5..0204630e73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1039,6 +1039,7 @@ dependencies = [ "subtle", "thiserror 2.0.18", "tokio", + "tower-http", "tracing", "tracing-subscriber", "url", @@ -9609,6 +9610,7 @@ dependencies = [ "getrandom 0.4.3", "js-sys", "serde_core", + "sha1_smol", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index 80d8e97f3f..192534e093 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,7 +87,7 @@ thiserror = "2" anyhow = "1" # Utilities -uuid = { version = "1", features = ["v4", "serde"] } +uuid = { version = "1", features = ["v4", "v5", "serde"] } chrono = { version = "0.4", features = ["serde"] } # HTTP client (webhook delivery) diff --git a/crates/buzz-cli/src/commands/import.rs b/crates/buzz-cli/src/commands/import.rs index c082fdd034..21a1f1f4b2 100644 --- a/crates/buzz-cli/src/commands/import.rs +++ b/crates/buzz-cli/src/commands/import.rs @@ -43,6 +43,9 @@ use state::ImportState; pub struct ImportSlackParams { /// Unzipped Slack export directory. pub export_dir: String, + /// Slack workspace id (team id) — namespaces identity bindings and channel + /// UUIDs so ids can't collide across workspaces. + pub team_id: String, /// State file path override. pub state: Option, /// Optional comma-separated channel-name filter. @@ -57,6 +60,7 @@ pub struct ImportSlackParams { } pub async fn cmd_import_slack(client: &BuzzClient, p: ImportSlackParams) -> Result<(), CliError> { + let team_id = validate_team_id(&p.team_id)?.to_string(); let export_dir = PathBuf::from(&p.export_dir); let export = SlackExport::load(&export_dir)?; @@ -84,7 +88,15 @@ pub async fn cmd_import_slack(client: &BuzzClient, p: ImportSlackParams) -> Resu return dry_run_report(&export, &selected, &state, &bindings, p.skip_reactions); } - let mut importer = Importer::new(client, &export, &names, state, state_path, p.skip_reactions); + let mut importer = Importer::new( + client, + &export, + &names, + &team_id, + state, + state_path, + p.skip_reactions, + ); for channel in &selected { importer.import_channel(channel).await?; @@ -102,14 +114,17 @@ pub async fn cmd_import_slack(client: &BuzzClient, p: ImportSlackParams) -> Resu /// `cmd_import_claim` with their own key. pub async fn cmd_import_bind( client: &BuzzClient, + team_id: &str, slack_id: &str, pubkey: &str, ) -> Result<(), CliError> { + let team_id = validate_team_id(team_id)?; let slack_id = validate_slack_id(slack_id)?; let pubkey_hex = parse_pubkey(pubkey)?; - let event_id = publish_binding(client, slack_id, &pubkey_hex).await?; + let event_id = publish_binding(client, team_id, slack_id, &pubkey_hex).await?; print_json(&serde_json::json!({ "event_id": event_id, + "team_id": team_id, "slack_id": slack_id, "pubkey": pubkey_hex, "accepted": true, @@ -121,14 +136,20 @@ pub async fn cmd_import_bind( /// person whose history it is runs this with their own key. Inert until a /// community owner/admin has published the matching attestation for this /// pubkey. -pub async fn cmd_import_claim(client: &BuzzClient, slack_id: &str) -> Result<(), CliError> { +pub async fn cmd_import_claim( + client: &BuzzClient, + team_id: &str, + slack_id: &str, +) -> Result<(), CliError> { + let team_id = validate_team_id(team_id)?; let slack_id = validate_slack_id(slack_id)?; - let d_tag = buzz_sdk::slack_identity_binding_d_tag(slack_id); + let d_tag = buzz_sdk::slack_identity_binding_d_tag(team_id, slack_id); let builder = buzz_sdk::build_import_identity_claim(&d_tag) .map_err(|e| CliError::Other(format!("build_import_identity_claim failed: {e}")))?; let event_id = submit(client, builder).await?; print_json(&serde_json::json!({ "event_id": event_id, + "team_id": team_id, "slack_id": slack_id, "pubkey": client.keys().public_key().to_hex(), "accepted": true, @@ -170,17 +191,25 @@ fn parse_identity_map(spec: Option<&str>) -> Result, CliEr /// Validate and normalize a Slack user id supplied on the command line. fn validate_slack_id(slack_id: &str) -> Result<&str, CliError> { - let slack_id = slack_id.trim(); - if slack_id.is_empty() - || !slack_id + validate_slack_ident(slack_id, "user id") +} + +/// Validate and normalize a Slack workspace (team) id supplied on the command +/// line. Same character rules as a user id. +fn validate_team_id(team_id: &str) -> Result<&str, CliError> { + validate_slack_ident(team_id, "workspace (team) id") +} + +fn validate_slack_ident<'a>(value: &'a str, label: &str) -> Result<&'a str, CliError> { + let value = value.trim(); + if value.is_empty() + || !value .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { - return Err(CliError::Usage(format!( - "invalid Slack user id {slack_id:?}" - ))); + return Err(CliError::Usage(format!("invalid Slack {label} {value:?}"))); } - Ok(slack_id) + Ok(value) } /// Parse an `npub1…` or 64-char hex string into a hex pubkey. Rejects nsec so @@ -293,6 +322,7 @@ pub async fn dispatch(cmd: crate::ImportCmd, client: &BuzzClient) -> Result<(), match cmd { crate::ImportCmd::Slack { export_dir, + team_id, state, channels, dry_run, @@ -303,6 +333,7 @@ pub async fn dispatch(cmd: crate::ImportCmd, client: &BuzzClient) -> Result<(), client, ImportSlackParams { export_dir, + team_id, state, channels, dry_run, @@ -312,17 +343,22 @@ pub async fn dispatch(cmd: crate::ImportCmd, client: &BuzzClient) -> Result<(), ) .await } - crate::ImportCmd::Bind { slack_id, pubkey } => { - cmd_import_bind(client, &slack_id, &pubkey).await + crate::ImportCmd::Bind { + team_id, + slack_id, + pubkey, + } => cmd_import_bind(client, &team_id, &slack_id, &pubkey).await, + crate::ImportCmd::Claim { team_id, slack_id } => { + cmd_import_claim(client, &team_id, &slack_id).await } - crate::ImportCmd::Claim { slack_id } => cmd_import_claim(client, &slack_id).await, } } #[cfg(test)] mod tests { use super::importer::{ - author_display, author_id, build_imported_message, provenance_tags, thread_root_key, + author_display, author_id, build_imported_message, channel_uuid, provenance_tags, + thread_root_key, }; use super::*; use nostr::{EventId, Keys}; @@ -332,6 +368,17 @@ mod tests { serde_json::from_str(json).expect("test message parses") } + #[test] + fn channel_uuid_is_deterministic_and_team_scoped() { + // Same inputs → same UUID, so a crash-resumed run reuses the channel + // instead of minting a duplicate. + assert_eq!(channel_uuid("T1", "C1"), channel_uuid("T1", "C1")); + // Distinct team or channel → distinct UUID (no cross-workspace or + // cross-channel collision). + assert_ne!(channel_uuid("T1", "C1"), channel_uuid("T2", "C1")); + assert_ne!(channel_uuid("T1", "C1"), channel_uuid("T1", "C2")); + } + #[test] fn author_resolution() { let user_msg = msg(r#"{"type":"message","user":"U1","text":"x","ts":"1.0"}"#); @@ -421,7 +468,7 @@ mod tests { let mut names = HashMap::new(); names.insert("U1".to_string(), "Alice".to_string()); - let event = build_imported_message(channel_id, &message, &names, Some(&thread_ref)) + let event = build_imported_message(channel_id, &message, &names, "T1", Some(&thread_ref)) .expect("builder") .sign_with_keys(&Keys::generate()) .expect("signs"); @@ -434,11 +481,47 @@ mod tests { assert!(tags.contains(&vec!["h".into(), channel_id.to_string()])); assert!(tags.iter().any(|tag| tag.first().is_some_and(|v| v == "e"))); assert!(tags.contains(&vec!["import".into(), "slack".into()])); - assert!(tags.contains(&vec!["import_author".into(), "U1".into(), "Alice".into()])); + // The import_author id is workspace-scoped (`:`) so it + // composes to the same `slack:T1:U1` key the identity binding uses. + assert!(tags.contains(&vec![ + "import_author".into(), + "T1:U1".into(), + "Alice".into() + ])); assert!(tags.contains(&vec!["import_ts".into(), "100.000002".into()])); assert_eq!(event.created_at.as_secs(), 100); } + #[test] + fn imported_thread_broadcast_remains_visible_in_the_channel_timeline() { + let channel_id = Uuid::new_v4(); + let root = EventId::from_hex(&"11".repeat(32)).expect("event id"); + let thread_ref = buzz_sdk::ThreadRef { + root_event_id: root, + parent_event_id: root, + }; + let message = msg( + r#"{"type":"message","subtype":"thread_broadcast","user":"U1", + "text":"shared reply","ts":"100.000002","thread_ts":"99.000001"}"#, + ); + let event = build_imported_message( + channel_id, + &message, + &HashMap::new(), + "T1", + Some(&thread_ref), + ) + .expect("builder") + .sign_with_keys(&Keys::generate()) + .expect("signs"); + + assert!(event.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.first().map(String::as_str) == Some("broadcast") + && parts.get(1).map(String::as_str) == Some("1") + })); + } + #[tokio::test] async fn dry_run_is_offline_and_reports_counts() { let dir = std::env::temp_dir().join(format!("buzz-import-dryrun-{}", std::process::id())); @@ -469,6 +552,7 @@ mod tests { &client, ImportSlackParams { export_dir: dir.display().to_string(), + team_id: "T1".into(), state: None, channels: None, dry_run: true, diff --git a/crates/buzz-cli/src/commands/import/importer.rs b/crates/buzz-cli/src/commands/import/importer.rs index cb3b0e421a..de8a99db9e 100644 --- a/crates/buzz-cli/src/commands/import/importer.rs +++ b/crates/buzz-cli/src/commands/import/importer.rs @@ -47,6 +47,8 @@ pub(super) struct Importer<'a> { export: &'a SlackExport, /// Slack user id → display name. names: &'a HashMap, + /// Slack workspace id — namespaces identity bindings and channel UUIDs. + team_id: &'a str, state: ImportState, state_path: PathBuf, summary: Summary, @@ -59,6 +61,7 @@ impl<'a> Importer<'a> { client: &'a BuzzClient, export: &'a SlackExport, names: &'a HashMap, + team_id: &'a str, state: ImportState, state_path: PathBuf, skip_reactions: bool, @@ -67,6 +70,7 @@ impl<'a> Importer<'a> { client, export, names, + team_id, state, state_path, summary: Summary::default(), @@ -88,7 +92,7 @@ impl<'a> Importer<'a> { state.metadata_done, ), None => { - let uuid = Uuid::new_v4(); + let uuid = channel_uuid(self.team_id, &channel.id); let about = if channel.purpose.value.is_empty() { None } else { @@ -115,6 +119,7 @@ impl<'a> Importer<'a> { ChannelState { uuid: uuid.to_string(), metadata_done: false, + archived_done: false, }, ); self.save()?; @@ -187,23 +192,30 @@ impl<'a> Importer<'a> { } None => { self.summary.warn(format!( - "thread root {root_key} not imported — posting {key} as top-level" + "thread root {root_key} is not imported yet — deferring reply {key}; \ + re-run to resume" )); - None + self.summary.skipped += 1; + continue; } }, None => None, }; - let builder = - match build_imported_message(channel_uuid, msg, names, thread_ref.as_ref()) { - Ok(b) => b, - Err(e) => { - self.summary.warn(format!("skipping {key}: {e}")); - self.summary.skipped += 1; - continue; - } - }; + let builder = match build_imported_message( + channel_uuid, + msg, + names, + self.team_id, + thread_ref.as_ref(), + ) { + Ok(b) => b, + Err(e) => { + self.summary.warn(format!("skipping {key}: {e}")); + self.summary.skipped += 1; + continue; + } + }; match submit(self.client, builder).await { Ok(event_id) => { consecutive_failures = 0; @@ -238,12 +250,24 @@ impl<'a> Importer<'a> { } // Mirror Slack's archived flag once the channel's history is in. - if channel.is_archived { + let archive_done = self + .state + .channels + .get(&channel.id) + .is_some_and(|state| state.archived_done); + if channel.is_archived && !archive_done { let builder = buzz_sdk::build_archive(channel_uuid) .map_err(|e| CliError::Other(format!("build_archive failed: {e}")))?; - if let Err(e) = submit(self.client, builder).await { - self.summary - .warn(format!("archive failed for #{}: {e}", channel.name)); + match submit(self.client, builder).await { + Ok(_) => { + if let Some(state) = self.state.channels.get_mut(&channel.id) { + state.archived_done = true; + } + self.save()?; + } + Err(e) => self + .summary + .warn(format!("archive failed for #{}: {e}", channel.name)), } } self.save()?; @@ -312,7 +336,7 @@ impl<'a> Importer<'a> { bindings: &[(String, String)], ) -> Result<(), CliError> { for (slack_id, pubkey_hex) in bindings { - match publish_binding(self.client, slack_id, pubkey_hex).await { + match publish_binding(self.client, self.team_id, slack_id, pubkey_hex).await { Ok(_) => self.summary.bindings_published += 1, Err(e) => self .summary @@ -343,10 +367,16 @@ pub(super) fn build_imported_message( channel_uuid: Uuid, msg: &SlackMessage, names: &HashMap, + team_id: &str, thread_ref: Option<&buzz_sdk::ThreadRef>, ) -> Result { let author = author_id(msg); let author_name = author_display(msg, names); + // The `import_author` id is the workspace-scoped foreign id `:`, + // so it composes to the same `slack::` key the identity binding + // uses — that is how the client joins an imported message to its attributed + // Buzz profile. Without the team prefix the join would miss. + let author_foreign = format!("{team_id}:{}", author.as_deref().unwrap_or("unknown")); let mut content = mrkdwn::convert(&msg.text, names); for file in &msg.files { @@ -360,16 +390,13 @@ pub(super) fn build_imported_message( // prefix when rendering the provenance-aware message. let content = format!("**{author_name}**: {}", content.trim()); - buzz_sdk::build_message(channel_uuid, &content, thread_ref, &[], false, &[]) + let broadcast = msg.subtype.as_deref() == Some("thread_broadcast"); + buzz_sdk::build_message(channel_uuid, &content, thread_ref, &[], broadcast, &[]) .map_err(|e| CliError::Other(format!("build_message failed: {e}"))) .and_then(|builder| { Ok(builder .custom_created_at(Timestamp::from(ts_seconds(&msg.ts)?)) - .tags(provenance_tags( - author.as_deref().unwrap_or("unknown"), - &author_name, - &msg.ts, - )?)) + .tags(provenance_tags(&author_foreign, &author_name, &msg.ts)?)) }) } @@ -420,13 +447,25 @@ pub(super) async fn submit(client: &BuzzClient, builder: EventBuilder) -> Result Ok(event_id) } +/// Deterministic Buzz channel UUID for a Slack channel, derived from the +/// workspace + Slack channel id. Making it a pure function of stable Slack ids +/// means a re-run after a crash between the channel-create relay write and the +/// state save reuses the same UUID (an idempotent NIP-33 replace) instead of +/// minting a second channel. The `team_id` prefix keeps channel ids from two +/// workspaces from colliding onto one UUID. +pub(super) fn channel_uuid(team_id: &str, channel_id: &str) -> Uuid { + let name = format!("buzz:slack-import:{team_id}:{channel_id}"); + Uuid::new_v5(&Uuid::NAMESPACE_URL, name.as_bytes()) +} + /// Build and submit an owner/admin-signed Slack identity binding. pub(super) async fn publish_binding( client: &BuzzClient, + team_id: &str, slack_id: &str, pubkey_hex: &str, ) -> Result { - let d_tag = buzz_sdk::slack_identity_binding_d_tag(slack_id); + let d_tag = buzz_sdk::slack_identity_binding_d_tag(team_id, slack_id); let builder = buzz_sdk::build_import_identity_binding(&d_tag, pubkey_hex) .map_err(|e| CliError::Other(format!("build_import_identity_binding failed: {e}")))?; submit(client, builder).await diff --git a/crates/buzz-cli/src/commands/import/state.rs b/crates/buzz-cli/src/commands/import/state.rs index e462058782..ac316e145e 100644 --- a/crates/buzz-cli/src/commands/import/state.rs +++ b/crates/buzz-cli/src/commands/import/state.rs @@ -21,6 +21,9 @@ pub struct ChannelState { /// Whether the create/topic/purpose events were accepted. #[serde(default)] pub metadata_done: bool, + /// Whether an archived Slack channel was archived in Buzz. + #[serde(default)] + pub archived_done: bool, } /// The whole state file. @@ -90,19 +93,21 @@ mod tests { ChannelState { uuid: "u-u-i-d".into(), metadata_done: true, + archived_done: true, }, ); state .messages .insert(ImportState::message_key("C1", "1.000"), "ff".repeat(32)); - state.reactions.insert("C1:1.000:👍:aa".into()); + state.reactions.insert("C1:1.000:👍".into()); state.save(&path).expect("save"); let loaded = ImportState::load(&path).expect("load"); assert_eq!(loaded.channels["C1"].uuid, "u-u-i-d"); assert!(loaded.channels["C1"].metadata_done); + assert!(loaded.channels["C1"].archived_done); assert_eq!(loaded.messages["C1:1.000"], "ff".repeat(32)); - assert!(loaded.reactions.contains("C1:1.000:👍:aa")); + assert!(loaded.reactions.contains("C1:1.000:👍")); std::fs::remove_dir_all(&dir).ok(); } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 55290f9cab..eab9c3d842 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -945,6 +945,10 @@ buzz import slack --export-dir ./export --identity-map U060=npub1abc,U081=npub1d /// Path to the unzipped Slack export directory #[arg(long)] export_dir: String, + /// Slack workspace id (team id, e.g. T0266FRGM). Namespaces identity + /// bindings and channel UUIDs so ids never collide across workspaces. + #[arg(long)] + team_id: String, /// State file path (default: /buzz-import-state.json) #[arg(long)] state: Option, @@ -971,6 +975,9 @@ community owner or admin. History is attributed only once the person also runs \ buzz import bind --slack-id U060976D0QN --pubkey npub1abc..." )] Bind { + /// Slack workspace id (team id, e.g. T0266FRGM) + #[arg(long)] + team_id: String, /// Slack user id (e.g. U060976D0QN) #[arg(long)] slack_id: String, @@ -987,6 +994,9 @@ matching `buzz import bind` attestation for your pubkey.\n\nExample:\n \ buzz import claim --slack-id U060976D0QN" )] Claim { + /// Slack workspace id (team id, e.g. T0266FRGM) + #[arg(long)] + team_id: String, /// Your Slack user id (e.g. U060976D0QN) #[arg(long)] slack_id: String, diff --git a/crates/buzz-migrate/Cargo.toml b/crates/buzz-migrate/Cargo.toml index 592a85c08e..05f00e24c4 100644 --- a/crates/buzz-migrate/Cargo.toml +++ b/crates/buzz-migrate/Cargo.toml @@ -32,6 +32,7 @@ hex = { workspace = true } subtle = { workspace = true } rand = { workspace = true } axum = { workspace = true } +tower-http = { workspace = true } reqwest = { workspace = true } url = { workspace = true } clap = { version = "4", features = ["derive", "env"] } diff --git a/crates/buzz-migrate/src/main.rs b/crates/buzz-migrate/src/main.rs index 6a3679d879..37f980014b 100644 --- a/crates/buzz-migrate/src/main.rs +++ b/crates/buzz-migrate/src/main.rs @@ -65,9 +65,11 @@ struct Args { #[arg(long, env = "SLACK_CLIENT_SECRET")] slack_client_secret: Option, - /// Slack workspace id whose users may claim imported identities. + /// Slack workspace id (team id, e.g. T0266FRGM) whose users may claim + /// imported identities. Namespaces every `slack::` subject so + /// ids can't collide across workspaces — required on both channels. #[arg(long, env = "SLACK_TEAM_ID")] - slack_team_id: Option, + slack_team_id: String, /// OIDC redirect URI registered on the Slack app. Defaults to /// `/oidc/callback`. @@ -105,7 +107,7 @@ async fn main() -> Result<(), Box> { let users_path = args.export_dir.join("users.json"); let users_bytes = std::fs::read(&users_path) .map_err(|e| format!("could not read {}: {e}", users_path.display()))?; - let roster = Roster::from_users_json(&users_bytes) + let roster = Roster::from_users_json(&users_bytes, &args.slack_team_id) .map_err(|e| format!("could not parse {}: {e}", users_path.display()))?; tracing::info!( mailable = roster.mailable_count(), @@ -132,18 +134,14 @@ async fn main() -> Result<(), Box> { return Err("--token-ttl-secs must be greater than zero".into()); } - let base_url = args - .base_url - .unwrap_or_else(|| format!("http://{}", args.bind)) - .trim_end_matches('/') - .to_string(); - - let oidc = match ( - args.slack_client_id, - args.slack_client_secret, - args.slack_team_id, - ) { - (Some(client_id), Some(client_secret), Some(team_id)) => { + let base_url = normalize_public_base_url( + &args + .base_url + .unwrap_or_else(|| format!("http://{}", args.bind)), + )?; + + let oidc = match (args.slack_client_id, args.slack_client_secret) { + (Some(client_id), Some(client_secret)) => { let redirect_uri = args .oidc_redirect_uri .unwrap_or_else(|| format!("{base_url}/oidc/callback")); @@ -152,17 +150,16 @@ async fn main() -> Result<(), Box> { client_id, client_secret, redirect_uri, - team_id, + team_id: args.slack_team_id.clone(), }) } - (None, None, None) => { - tracing::info!("OIDC channel disabled (no Slack OIDC configuration)"); + (None, None) => { + tracing::info!("OIDC channel disabled (no Slack OIDC credentials)"); None } _ => { return Err( - "set SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, and SLACK_TEAM_ID together, or omit all" - .into(), + "set SLACK_CLIENT_ID and SLACK_CLIENT_SECRET together, or omit both".into(), ); } }; @@ -176,6 +173,7 @@ async fn main() -> Result<(), Box> { token_secret, consumed: Mutex::new(ConsumedNonces::new()), admin, + team_id: args.slack_team_id, relay_url: to_ws_url(&args.relay_url), auth_tag, base_url, @@ -188,6 +186,7 @@ async fn main() -> Result<(), Box> { http: reqwest::Client::new(), oidc, oidc_states: Mutex::new(HashMap::new()), + oidc_codes: Mutex::new(HashMap::new()), dev: args.dev, }; let state = AppState(Arc::new(inner)); @@ -210,6 +209,20 @@ fn to_ws_url(url: &str) -> String { } } +fn normalize_public_base_url(value: &str) -> Result { + let parsed = url::Url::parse(value).map_err(|error| format!("invalid --base-url: {error}"))?; + if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { + return Err("--base-url must be an http(s) URL with a host".into()); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("--base-url must not include credentials".into()); + } + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err("--base-url must not include a query or fragment".into()); + } + Ok(value.trim_end_matches('/').to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -221,4 +234,16 @@ mod tests { assert_eq!(to_ws_url("ws://x:1"), "ws://x:1"); assert_eq!(to_ws_url("wss://x"), "wss://x"); } + + #[test] + fn public_base_url_is_validated_and_normalized() { + assert_eq!( + normalize_public_base_url("https://migrate.example/").unwrap(), + "https://migrate.example" + ); + assert!(normalize_public_base_url("file:///tmp/migrate").is_err()); + assert!(normalize_public_base_url("https://user@migrate.example").is_err()); + assert!(normalize_public_base_url("https://migrate.example?next=evil").is_err()); + assert!(normalize_public_base_url("https://migrate.example#fragment").is_err()); + } } diff --git a/crates/buzz-migrate/src/oidc.rs b/crates/buzz-migrate/src/oidc.rs index 062bd1baf9..fbae30eb63 100644 --- a/crates/buzz-migrate/src/oidc.rs +++ b/crates/buzz-migrate/src/oidc.rs @@ -181,7 +181,10 @@ pub async fn exchange_code_for_subject( .user_id .filter(|s| !s.is_empty()) .ok_or(OidcError::NoUserId)?; - Ok(format!("slack:{user_id}")) + // Team-scope the subject: `slack::`. `team_id` was just verified + // to equal the workspace Slack authenticated against, so this is the real + // workspace, not one the caller could choose. + Ok(format!("slack:{}:{user_id}", cfg.team_id)) } /// Decode only the nonce from the ID token returned directly by Slack's token diff --git a/crates/buzz-migrate/src/roster.rs b/crates/buzz-migrate/src/roster.rs index cbed4feb34..d31c0f39f5 100644 --- a/crates/buzz-migrate/src/roster.rs +++ b/crates/buzz-migrate/src/roster.rs @@ -32,9 +32,9 @@ struct RawProfile { /// Email→subject and subject→name lookups for the active migration. #[derive(Debug, Default, Clone)] pub struct Roster { - /// lowercased, trimmed email → `slack:`. + /// lowercased, trimmed email → `slack::`. email_to_subject: HashMap, - /// `slack:` → best human-readable name (for email copy). + /// `slack::` → best human-readable name (for email copy). subject_to_name: HashMap, /// Emails shared by more than one active Slack user. These are never /// eligible for magic-link attribution because ownership is ambiguous. @@ -47,7 +47,7 @@ impl Roster { /// Deactivated (`deleted`) users are skipped: their email can no longer /// receive the magic link, so they can only be attributed by the OIDC /// channel or a manual `buzz import bind`. - pub fn from_users_json(bytes: &[u8]) -> Result { + pub fn from_users_json(bytes: &[u8], team_id: &str) -> Result { let users: Vec = serde_json::from_slice(bytes)?; let mut email_to_subject = HashMap::new(); let mut subject_to_name = HashMap::new(); @@ -56,7 +56,7 @@ impl Roster { if u.deleted { continue; } - let subject = format!("slack:{}", u.id); + let subject = format!("slack:{team_id}:{}", u.id); let name = if !u.profile.display_name.is_empty() { u.profile.display_name } else if !u.profile.real_name.is_empty() { @@ -117,35 +117,38 @@ mod tests { #[test] fn maps_email_to_subject_case_insensitively() { - let r = Roster::from_users_json(USERS.as_bytes()).unwrap(); - assert_eq!(r.subject_for_email("alice@corp.com"), Some("slack:U060")); + let r = Roster::from_users_json(USERS.as_bytes(), "T1").unwrap(); + assert_eq!(r.subject_for_email("alice@corp.com"), Some("slack:T1:U060")); // Original casing and surrounding whitespace both normalize. - assert_eq!(r.subject_for_email(" BOB@CORP.COM "), Some("slack:U081")); + assert_eq!( + r.subject_for_email(" BOB@CORP.COM "), + Some("slack:T1:U081") + ); } #[test] fn deactivated_users_are_excluded() { - let r = Roster::from_users_json(USERS.as_bytes()).unwrap(); + let r = Roster::from_users_json(USERS.as_bytes(), "T1").unwrap(); assert_eq!(r.subject_for_email("ghost@corp.com"), None); } #[test] fn users_without_email_are_not_mailable_but_named() { - let r = Roster::from_users_json(USERS.as_bytes()).unwrap(); + let r = Roster::from_users_json(USERS.as_bytes(), "T1").unwrap(); assert_eq!(r.mailable_count(), 2); // U060, U081 - assert_eq!(r.name_for_subject("slack:U100"), Some("NoEmail")); + assert_eq!(r.name_for_subject("slack:T1:U100"), Some("NoEmail")); } #[test] fn best_name_falls_back_display_then_real_then_id() { - let r = Roster::from_users_json(USERS.as_bytes()).unwrap(); - assert_eq!(r.name_for_subject("slack:U060"), Some("Alice")); - assert_eq!(r.name_for_subject("slack:U081"), Some("Bob B")); + let r = Roster::from_users_json(USERS.as_bytes(), "T1").unwrap(); + assert_eq!(r.name_for_subject("slack:T1:U060"), Some("Alice")); + assert_eq!(r.name_for_subject("slack:T1:U081"), Some("Bob B")); } #[test] fn unknown_email_is_none() { - let r = Roster::from_users_json(USERS.as_bytes()).unwrap(); + let r = Roster::from_users_json(USERS.as_bytes(), "T1").unwrap(); assert_eq!(r.subject_for_email("nobody@corp.com"), None); } @@ -155,7 +158,7 @@ mod tests { {"id":"U1","profile":{"email":"shared@corp.com"}}, {"id":"U2","profile":{"email":"SHARED@corp.com"}} ]"#; - let r = Roster::from_users_json(users.as_bytes()).unwrap(); + let r = Roster::from_users_json(users.as_bytes(), "T1").unwrap(); assert_eq!(r.subject_for_email("shared@corp.com"), None); assert_eq!(r.mailable_count(), 0); } diff --git a/crates/buzz-migrate/src/server.rs b/crates/buzz-migrate/src/server.rs index e75f4043f9..ae43b75643 100644 --- a/crates/buzz-migrate/src/server.rs +++ b/crates/buzz-migrate/src/server.rs @@ -24,8 +24,9 @@ use axum::http::StatusCode; use axum::response::{Html, IntoResponse, Redirect}; use axum::routing::{get, post}; use axum::{Json, Router}; -use nostr::{Keys, Tag}; +use nostr::{Event, JsonUtil, Keys, Tag}; use serde::{Deserialize, Serialize}; +use tower_http::cors::CorsLayer; use crate::oidc::{self, OidcConfig}; use crate::roster::Roster; @@ -33,6 +34,22 @@ use crate::token::{self, ConsumedNonces, MagicToken, TokenError, VerifiedToken}; /// How long an OIDC `state` is valid between `/oidc/start` and the callback. const OIDC_STATE_TTL_SECS: u64 = 600; +/// How long a post-callback finalize `code` is valid. The first valid +/// redemption binds it to one key; only that key may retry. +const OIDC_CODE_TTL_SECS: u64 = 300; +/// Bound unauthenticated `/oidc/start` memory use (and the pending-code map). +/// Operators should also rate-limit the public endpoint at their reverse proxy. +const MAX_PENDING_OIDC_STATES: usize = 4096; + +/// A callback code becomes permanently bound to the first valid claimant key +/// that redeems it. Replays by that same key are safe because both relay writes +/// are idempotent; a different key can never take over a partially completed +/// migration. +pub struct OidcFinalizeCode { + subject: String, + pubkey: Option, + expires_at: u64, +} /// How the service delivers magic links. #[derive(Clone)] @@ -51,6 +68,8 @@ pub struct Inner { pub token_secret: Vec, pub consumed: Mutex, pub admin: Keys, + /// Slack workspace id — the `` in every `slack::` subject. + pub team_id: String, pub relay_url: String, pub auth_tag: Option, /// Public base URL of this service, used to build magic links. @@ -61,9 +80,14 @@ pub struct Inner { pub http: reqwest::Client, /// Slack OIDC credentials; `None` disables the `/oidc/*` routes. pub oidc: Option, - /// Live OIDC `state` → (claimant pubkey, nonce, expiry). CSRF, replay - /// protection, and pubkey binding. - pub oidc_states: Mutex>, + /// Live OIDC `state` → (nonce, expiry). CSRF and replay protection for the + /// Slack round-trip. The claimant pubkey is deliberately NOT bound here — it + /// is proven only at `/oidc/finalize`, so an unauthenticated `/oidc/start` + /// can never pin an attacker's key to a victim's Slack login. + pub oidc_states: Mutex>, + /// Post-callback `code` → verified subject + first proven claimant key. + /// Codes are retryable only by that same key until expiry. + pub oidc_codes: Mutex>, /// Dev mode: enables `/oidc/dev-complete`, which simulates a verified OIDC /// result so the publish path can be exercised without a real Slack app. pub dev: bool, @@ -90,7 +114,13 @@ pub fn router(state: AppState) -> Router { // OIDC channel (Sign in with Slack). .route("/oidc/start", get(oidc_start)) .route("/oidc/callback", get(oidc_callback)) + .route("/oidc/finalize", post(oidc_finalize)) .route("/oidc/dev-complete", get(oidc_dev_complete)) + // Buzz Desktop runs in a WebView origin. The public migration + // endpoints are already protected by OIDC state, short-lived bearer + // codes, and signed claims; CORS is transport compatibility, not an + // authorization boundary. + .layer(CorsLayer::permissive()) .with_state(state) } @@ -206,7 +236,7 @@ struct CompleteReq { pubkey: String, } -#[derive(Serialize)] +#[derive(Debug, Serialize)] struct CompleteResp { subject: String, /// The published attestation's event id. @@ -291,35 +321,28 @@ async fn publish_join_attestation_for( // ---- OIDC channel (Sign in with Slack) -------------------------------------- -#[derive(Deserialize)] -struct OidcStartQuery { - /// The claimant's own Buzz pubkey (64-hex), supplied by their app. - pubkey: String, -} - -/// Begin Sign in with Slack: bind `state → pubkey` and redirect to Slack. -async fn oidc_start( - State(state): State, - Query(q): Query, -) -> Result { +/// Begin Sign in with Slack: store a fresh `state`/`nonce` and redirect to +/// Slack. The claimant's Buzz pubkey is intentionally not accepted here — it is +/// proven at `/oidc/finalize`. This closes the takeover where an attacker starts +/// OIDC bound to their own key and has a victim complete the Slack login. +async fn oidc_start(State(state): State) -> Result { let inner = state.i(); let cfg = inner.oidc.as_ref().ok_or_else(oidc_unconfigured)?; - let pubkey = - normalize_pubkey(&q.pubkey).ok_or_else(|| ApiError::bad("pubkey must be 64-char hex"))?; let st = hex::encode(random_nonce()); let nonce = hex::encode(random_nonce()); { let now = now_secs(); let mut states = lock_or_recover(&inner.oidc_states, "oidc states"); - states.retain(|_, (_, _, exp)| *exp > now); + states.retain(|_, (_, exp)| *exp > now); + if states.len() >= MAX_PENDING_OIDC_STATES { + return Err(ApiError::too_many_requests( + "too many pending Slack sign-ins; try again shortly", + )); + } states.insert( st.clone(), - ( - pubkey, - nonce.clone(), - now.saturating_add(OIDC_STATE_TTL_SECS), - ), + (nonce.clone(), now.saturating_add(OIDC_STATE_TTL_SECS)), ); } Ok(Redirect::to(&oidc::authorize_url(cfg, &st, &nonce))) @@ -333,8 +356,13 @@ struct OidcCallbackQuery { state: String, } -/// Slack's redirect target: resolve `state → pubkey`, exchange the code for the -/// verified Slack user id, publish the attestation, and hand back to the app. +/// Slack's redirect target: resolve `state`, exchange the code for the verified +/// Slack user id, mint a short-lived finalize `code`, and hand back to the app. +/// +/// It deliberately does NOT publish anything. The attestation is signed only at +/// `/oidc/finalize`, once the app proves control of the pubkey to be attested — +/// so a victim's Slack login can never mint an attestation for someone else's +/// key. async fn oidc_callback( State(state): State, Query(q): Query, @@ -342,12 +370,12 @@ async fn oidc_callback( let inner = state.i(); let cfg = inner.oidc.as_ref().ok_or_else(oidc_unconfigured)?; - // Consume the state → the pubkey the app started with (CSRF + binding). - let (pubkey, nonce) = { + // Consume the state (CSRF + replay) → the nonce we sent Slack. + let nonce = { let now = now_secs(); let mut states = lock_or_recover(&inner.oidc_states, "oidc states"); - states.retain(|_, (_, _, exp)| *exp > now); - states.remove(&q.state).map(|(pk, nonce, _)| (pk, nonce)) + states.retain(|_, (_, exp)| *exp > now); + states.remove(&q.state).map(|(nonce, _)| nonce) } .ok_or_else(|| ApiError::bad("unknown or expired OIDC state"))?; @@ -355,32 +383,159 @@ async fn oidc_callback( .await .map_err(|e| ApiError::upstream(&e.to_string()))?; - publish_join_attestation_for(inner, &subject, &pubkey, "oidc").await?; - Ok(Redirect::to(&oidc_app_return_url(inner, &subject))) + // Mint a short-lived code bound to the verified subject. The first valid + // self-claim also binds it permanently to that claimant key. + let code = hex::encode(random_nonce()); + { + let now = now_secs(); + let mut codes = lock_or_recover(&inner.oidc_codes, "oidc codes"); + codes.retain(|_, pending| pending.expires_at > now); + if codes.len() >= MAX_PENDING_OIDC_STATES { + return Err(ApiError::too_many_requests( + "too many pending Slack sign-ins; try again shortly", + )); + } + codes.insert( + code.clone(), + OidcFinalizeCode { + subject: subject.clone(), + pubkey: None, + expires_at: now.saturating_add(OIDC_CODE_TTL_SECS), + }, + ); + } + Ok(Redirect::to(&oidc_app_return_url(inner, &subject, &code))) } -fn oidc_app_return_url(inner: &Inner, subject: &str) -> String { +fn oidc_app_return_url(inner: &Inner, subject: &str, code: &str) -> String { format!( - "buzz://import-claim?subject={}&via=oidc&relay={}&service={}", + "buzz://import-claim?subject={}&via=oidc&code={}&relay={}&service={}", urlencode(subject), + urlencode(code), urlencode(&inner.relay_url), urlencode(&inner.base_url), ) } +#[derive(Deserialize)] +struct OidcFinalizeReq { + /// Short-lived code from the `/oidc/callback` redirect. + code: String, + /// The claimant's signed self-claim (kind `KIND_IMPORT_IDENTITY_CLAIM`). Its + /// valid signature proves control of the pubkey to be attested; its `d` tag + /// must equal the code's OIDC-verified subject. + claim: serde_json::Value, +} + +/// The app's proof-of-possession finalize: redeem a post-OIDC `code` and publish +/// the owner/admin attestation — bound to the pubkey that *signed* the +/// accompanying self-claim, never to an attacker-suppliable parameter. +/// +/// The claim event is fully verified (id hash + Schnorr signature); its `d` tag +/// must equal the Slack-verified subject the code stands for. Because the code +/// reaches only the app that completed the Slack login, and the attested pubkey +/// is proven by signature, a phisher who makes a victim authenticate can neither +/// obtain the code nor forge a self-claim for a key they do not hold. +async fn oidc_finalize( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let inner = state.i(); + + // Reject the wrong event kind up front (cheap), then fully verify id+sig + // before trusting any field read off the event. + let kind_ok = req.claim.get("kind").and_then(|k| k.as_u64()) + == Some(u64::from(buzz_core::kind::KIND_IMPORT_IDENTITY_CLAIM)); + if !kind_ok { + return Err(ApiError::bad("claim must be an identity-claim event")); + } + let claim_json = serde_json::to_string(&req.claim) + .map_err(|_| ApiError::bad("claim must be a JSON event"))?; + let event = Event::from_json(&claim_json) + .map_err(|_| ApiError::bad("claim is not a valid Nostr event"))?; + let to_verify = event.clone(); + tokio::task::spawn_blocking(move || buzz_core::verification::verify_event(&to_verify)) + .await + .map_err(|_| ApiError::upstream("claim verification task failed"))? + .map_err(|_| ApiError::bad("claim signature is invalid"))?; + + let claim_subject = event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + if parts.first().map(String::as_str) == Some("d") { + parts.get(1).cloned() + } else { + None + } + }); + + // Bind the code to the first key that proves possession. Keeping that + // binding until expiry lets the same app retry if the relay accepted the + // attestation but the subsequent client-side claim publish failed. + let pubkey = event.pubkey.to_hex(); + let subject = bind_oidc_finalize_code( + inner, + &req.code, + claim_subject.as_deref(), + &pubkey, + now_secs(), + )?; + let event_id = publish_join_attestation_for(inner, &subject, &pubkey, "oidc").await?; + Ok(Json(CompleteResp { + subject, + attestation_event_id: event_id, + })) +} + +fn bind_oidc_finalize_code( + inner: &Inner, + code: &str, + claim_subject: Option<&str>, + pubkey: &str, + now: u64, +) -> Result { + let mut codes = lock_or_recover(&inner.oidc_codes, "oidc codes"); + codes.retain(|_, pending| pending.expires_at > now); + let pending = codes + .get_mut(code) + .ok_or_else(|| ApiError::bad("unknown or expired finalize code"))?; + if claim_subject != Some(pending.subject.as_str()) { + return Err(ApiError::bad( + "self-claim does not match the verified Slack identity", + )); + } + match pending.pubkey.as_deref() { + Some(bound) if bound != pubkey => { + return Err(ApiError::bad( + "finalize code is already bound to a different Buzz account", + )); + } + Some(_) => {} + None => pending.pubkey = Some(pubkey.to_string()), + } + Ok(pending.subject.clone()) +} + #[derive(Deserialize)] struct OidcDevCompleteQuery { - pubkey: String, /// Simulated Slack user id (e.g. `U060`). sub: String, } -/// Dev-only: simulate a verified OIDC result to exercise the publish path -/// without a Slack app. Returns 404 unless the service was started with --dev. +#[derive(Serialize)] +struct DevCompleteResp { + subject: String, + /// The `buzz://import-claim` URL a real Slack callback would redirect to, + /// carrying the short-lived finalize code — follow it to drive the app. + app_return_url: String, +} + +/// Dev-only: simulate a verified OIDC result. Mints the same finalize code a +/// real callback would and returns the app deep link, so the finalize path can +/// be exercised without a Slack app. Returns 404 unless started with --dev. async fn oidc_dev_complete( State(state): State, Query(q): Query, -) -> Result, ApiError> { +) -> Result, ApiError> { let inner = state.i(); if !inner.dev { return Err(ApiError { @@ -388,16 +543,28 @@ async fn oidc_dev_complete( message: "not found".into(), }); } - let pubkey = - normalize_pubkey(&q.pubkey).ok_or_else(|| ApiError::bad("pubkey must be 64-char hex"))?; - if q.sub.trim().is_empty() { + let sub = q.sub.trim(); + if sub.is_empty() { return Err(ApiError::bad("sub must not be empty")); } - let subject = format!("slack:{}", q.sub.trim()); - let event_id = publish_join_attestation_for(inner, &subject, &pubkey, "oidc-dev").await?; - Ok(Json(CompleteResp { + let subject = format!("slack:{}:{sub}", inner.team_id); + let code = hex::encode(random_nonce()); + { + let now = now_secs(); + let mut codes = lock_or_recover(&inner.oidc_codes, "oidc codes"); + codes.insert( + code.clone(), + OidcFinalizeCode { + subject: subject.clone(), + pubkey: None, + expires_at: now.saturating_add(OIDC_CODE_TTL_SECS), + }, + ); + } + let app_return_url = oidc_app_return_url(inner, &subject, &code); + Ok(Json(DevCompleteResp { subject, - attestation_event_id: event_id, + app_return_url, })) } @@ -468,6 +635,12 @@ impl ApiError { message: msg.to_string(), } } + fn too_many_requests(msg: &str) -> Self { + Self { + status: StatusCode::TOO_MANY_REQUESTS, + message: msg.to_string(), + } + } } impl IntoResponse for ApiError { @@ -543,10 +716,11 @@ mod tests { let users = r#"[{"id":"U060","profile":{"email":"alice@corp.com","display_name":"Alice"}}]"#; Inner { - roster: Roster::from_users_json(users.as_bytes()).unwrap(), + roster: Roster::from_users_json(users.as_bytes(), "T060").unwrap(), token_secret: b"secret".to_vec(), consumed: Mutex::new(ConsumedNonces::new()), admin: Keys::generate(), + team_id: "T060".into(), relay_url: "ws://127.0.0.1:1".into(), auth_tag: None, base_url: "http://localhost:8787".into(), @@ -555,6 +729,7 @@ mod tests { http: reqwest::Client::new(), oidc: None, oidc_states: Mutex::new(HashMap::new()), + oidc_codes: Mutex::new(HashMap::new()), dev: false, } } @@ -631,12 +806,193 @@ mod tests { } #[test] - fn oidc_return_is_bound_to_target_relay_and_service() { + fn oidc_return_carries_code_relay_and_service() { let inner = test_inner(); assert_eq!( - oidc_app_return_url(&inner, "slack:U060"), - "buzz://import-claim?subject=slack%3AU060&via=oidc&relay=ws%3A%2F%2F127.0.0.1%3A1&service=http%3A%2F%2Flocalhost%3A8787" + oidc_app_return_url(&inner, "slack:U060", "c0de"), + "buzz://import-claim?subject=slack%3AU060&via=oidc&code=c0de&relay=ws%3A%2F%2F127.0.0.1%3A1&service=http%3A%2F%2Flocalhost%3A8787" + ); + } + + #[tokio::test] + async fn oidc_start_rejects_when_pending_state_capacity_is_reached() { + let mut inner = test_inner(); + inner.oidc = Some(OidcConfig { + client_id: "client".into(), + client_secret: "secret".into(), + redirect_uri: "https://migrate.example/oidc/callback".into(), + team_id: "T060".into(), + }); + inner.oidc_states = Mutex::new( + (0..MAX_PENDING_OIDC_STATES) + .map(|index| (format!("state-{index}"), ("nonce".into(), u64::MAX))) + .collect(), ); + let state = AppState(Arc::new(inner)); + let error = match oidc_start(State(state)).await { + Ok(_) => panic!("capacity must reject"), + Err(error) => error, + }; + assert_eq!(error.status, StatusCode::TOO_MANY_REQUESTS); + } + + /// Build a signed self-claim (kind `KIND_IMPORT_IDENTITY_CLAIM`) with the + /// given `d` subject, as JSON — what the app POSTs to `/oidc/finalize`. + fn signed_claim(subject: &str) -> (serde_json::Value, Keys) { + use nostr::{EventBuilder, Kind}; + let keys = Keys::generate(); + let event = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_IMPORT_IDENTITY_CLAIM as u16), + "", + ) + .tags([Tag::parse(["d", subject]).unwrap()]) + .sign_with_keys(&keys) + .expect("sign"); + (serde_json::from_str(&event.as_json()).expect("json"), keys) + } + + fn inner_with_code(code: &str, subject: &str) -> Inner { + let inner = test_inner(); + inner.oidc_codes.lock().unwrap().insert( + code.to_string(), + OidcFinalizeCode { + subject: subject.to_string(), + pubkey: None, + expires_at: u64::MAX, + }, + ); + inner + } + + #[test] + fn finalize_code_allows_same_key_retry_but_rejects_a_different_key() { + let inner = inner_with_code("retry-code", "slack:U060"); + let first = bind_oidc_finalize_code( + &inner, + "retry-code", + Some("slack:U060"), + &"aa".repeat(32), + 100, + ) + .unwrap(); + assert_eq!(first, "slack:U060"); + assert!(bind_oidc_finalize_code( + &inner, + "retry-code", + Some("slack:U060"), + &"aa".repeat(32), + 101, + ) + .is_ok()); + let error = bind_oidc_finalize_code( + &inner, + "retry-code", + Some("slack:U060"), + &"bb".repeat(32), + 102, + ) + .unwrap_err(); + assert_eq!(error.status, StatusCode::BAD_REQUEST); + } + + #[test] + fn mismatched_subject_does_not_consume_or_bind_finalize_code() { + let inner = inner_with_code("subject-code", "slack:U060"); + assert!(bind_oidc_finalize_code( + &inner, + "subject-code", + Some("slack:U999"), + &"aa".repeat(32), + 100, + ) + .is_err()); + assert!(bind_oidc_finalize_code( + &inner, + "subject-code", + Some("slack:U060"), + &"bb".repeat(32), + 101, + ) + .is_ok()); + } + + #[tokio::test] + async fn finalize_rejects_claim_for_a_different_subject() { + // Slack verified U060, but the self-claim consents to U999. Even with a + // valid signature and a live code, the mismatch is refused — no attacker + // can redirect a code onto a different identity. + let inner = inner_with_code("code1", "slack:U060"); + let (claim, _keys) = signed_claim("slack:U999"); + let state = AppState(Arc::new(inner)); + let err = oidc_finalize( + State(state), + Json(OidcFinalizeReq { + code: "code1".into(), + claim, + }), + ) + .await + .unwrap_err(); + assert_eq!(err.status, StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn finalize_rejects_tampered_signature() { + // A claim whose signature doesn't verify never reaches the code redemption + // or the relay — the attested pubkey must be genuinely proven. + let inner = inner_with_code("code2", "slack:U060"); + let (mut claim, _keys) = signed_claim("slack:U060"); + claim["sig"] = serde_json::Value::String("0".repeat(128)); + let state = AppState(Arc::new(inner)); + let err = oidc_finalize( + State(state), + Json(OidcFinalizeReq { + code: "code2".into(), + claim, + }), + ) + .await + .unwrap_err(); + assert_eq!(err.status, StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn finalize_rejects_unknown_code() { + let inner = inner_with_code("code3", "slack:U060"); + let (claim, _keys) = signed_claim("slack:U060"); + let state = AppState(Arc::new(inner)); + let err = oidc_finalize( + State(state), + Json(OidcFinalizeReq { + code: "not-the-code".into(), + claim, + }), + ) + .await + .unwrap_err(); + assert_eq!(err.status, StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn finalize_rejects_wrong_kind() { + let inner = inner_with_code("code4", "slack:U060"); + let keys = Keys::generate(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(1), "") + .tags([Tag::parse(["d", "slack:U060"]).unwrap()]) + .sign_with_keys(&keys) + .expect("sign"); + let claim: serde_json::Value = serde_json::from_str(&event.as_json()).expect("json"); + let state = AppState(Arc::new(inner)); + let err = oidc_finalize( + State(state), + Json(OidcFinalizeReq { + code: "code4".into(), + claim, + }), + ) + .await + .unwrap_err(); + assert_eq!(err.status, StatusCode::BAD_REQUEST); } #[test] diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 10a2dcecee..d56c6ed620 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -531,15 +531,21 @@ pub fn build_set_canvas(channel_id: Uuid, content: &str) -> Result`. -pub fn slack_identity_binding_d_tag(slack_user_id: &str) -> String { - format!("slack:{slack_user_id}") +/// Canonical `d`-tag for a Slack identity binding: `slack::`. +/// +/// Slack user ids are only unique **within** a workspace, so the workspace +/// (`team_id`, e.g. `T0266FRGM`) is part of the key. Without it, the same +/// `U…` id from two different Slack workspaces would collide, and an +/// attestation or claim minted for one workspace could be replayed against +/// another. Both parts must be non-empty (callers validate them). +pub fn slack_identity_binding_d_tag(team_id: &str, slack_user_id: &str) -> String { + format!("slack:{team_id}:{slack_user_id}") } /// Build an owner/admin-signed import identity binding (kind 30623). /// /// Attests that the foreign workspace identity keyed by `d_tag` (e.g. -/// `slack:U060976D0QN`, via [`slack_identity_binding_d_tag`]) belongs to +/// `slack:T0266FRGM:U060976D0QN`, via [`slack_identity_binding_d_tag`]) belongs to /// `bound_pubkey`. Carries **public keys only** — no secret transits. The /// relay accepts this event solely from a community owner/admin, so a member /// cannot claim another person's imported history. Parameterized-replaceable: @@ -565,7 +571,7 @@ pub fn build_import_identity_binding( /// Build a subject-signed import identity claim (kind 30624). /// /// The consent half of a two-party binding: the signer asserts that the -/// foreign workspace identity keyed by `d_tag` (e.g. `slack:U060976D0QN`, via +/// foreign workspace identity keyed by `d_tag` (e.g. `slack:T0266FRGM:U060976D0QN`, via /// [`slack_identity_binding_d_tag`]) is them. The signer's own pubkey is the /// consent, so there is no `p` tag — sign this with the subject's key. It only /// takes effect paired with a matching owner/admin @@ -3838,8 +3844,8 @@ mod tests { #[test] fn identity_binding_carries_d_and_p_but_no_secret() { let pk = keys().public_key().to_hex(); - let d = slack_identity_binding_d_tag("U060976D0QN"); - assert_eq!(d, "slack:U060976D0QN"); + let d = slack_identity_binding_d_tag("T0266FRGM", "U060976D0QN"); + assert_eq!(d, "slack:T0266FRGM:U060976D0QN"); let ev = sign(build_import_identity_binding(&d, &pk).unwrap()); assert_eq!(ev.kind.as_u16() as u32, KIND_IMPORT_IDENTITY_BINDING); let get = |k: &str| { @@ -3866,7 +3872,7 @@ mod tests { fn identity_claim_is_self_signed_with_no_p_tag() { // The claim's consent is the signature itself — it must NOT name a // pubkey, so nobody can craft a claim "for" someone else. - let d = slack_identity_binding_d_tag("U1"); + let d = slack_identity_binding_d_tag("T1", "U1"); let ev = sign(build_import_identity_claim(&d).unwrap()); assert_eq!(ev.kind.as_u16() as u32, KIND_IMPORT_IDENTITY_CLAIM); assert_eq!( diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index ed8c06a71f..154c95e837 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -371,11 +371,14 @@ struct ImportClaimDeepLinkPayload { /// `/email/complete`; the OIDC channel uses it to bind the callback to the /// pending `join-slack` transaction. service: Option, - /// OIDC channel marker (`"oidc"`) — the attestation is already published by - /// the service, so the app only needs to publish its self-claim. + /// OIDC channel marker (`"oidc"`). The service has NOT yet published the + /// attestation — the app must first redeem `code` at `/oidc/finalize` with a + /// signed self-claim (proof of key possession), then publish that self-claim. via: Option, /// OIDC join channel: relay that received membership + attestation. relay_url: Option, + /// OIDC join channel: short-lived finalize code from the Slack callback. + code: Option, } /// A foreign-identity subject is `:` with both parts present and an @@ -407,6 +410,9 @@ fn validate_claim_service(service: &str) -> Result<(), String> { if !url.username().is_empty() || url.password().is_some() { return Err("service must not include credentials".into()); } + if url.query().is_some() || url.fragment().is_some() { + return Err("service must not include a query or fragment".into()); + } Ok(()) } @@ -420,15 +426,20 @@ fn parse_import_claim_deep_link(url: &Url) -> Result validate_claim_service(service)?, - // OIDC channel: bind the callback to both the target relay and the - // claim service from the pending join-slack transaction. + // OIDC channel: bind the callback to the target relay and the claim + // service from the pending join-slack transaction, and require the + // short-lived finalize code the app redeems with its signed self-claim. (None, Some(service), Some("oidc")) => { validate_claim_service(service)?; + if code.is_none() { + return Err("import-claim OIDC requires a finalize code".into()); + } relay_url = Some( parse_websocket_relay_param(url) .ok_or_else(|| "import-claim OIDC requires a valid relay".to_string())?, @@ -436,7 +447,7 @@ fn parse_import_claim_deep_link(url: &Url) -> Result { return Err( - "import-claim requires token+service (email) or via=oidc+relay+service".into(), + "import-claim requires token+service (email) or via=oidc+code+relay+service".into(), ) } } @@ -447,6 +458,7 @@ fn parse_import_claim_deep_link(url: &Url) -> Result { if ( diff --git a/desktop/src/features/messages/lib/confirmImportBindings.test.mjs b/desktop/src/features/messages/lib/confirmImportBindings.test.mjs index 207c0a403c..3cb6d67801 100644 --- a/desktop/src/features/messages/lib/confirmImportBindings.test.mjs +++ b/desktop/src/features/messages/lib/confirmImportBindings.test.mjs @@ -14,10 +14,12 @@ const MALLORY = let clock = 1000; function attestation(dTag, pubkey) { + const createdAt = clock++; return { + id: createdAt.toString(16).padStart(64, "0"), kind: KIND_IMPORT_IDENTITY_BINDING, pubkey: "admin".padEnd(64, "0"), - created_at: clock++, + created_at: createdAt, tags: [ ["d", dTag], ["p", pubkey], @@ -25,10 +27,12 @@ function attestation(dTag, pubkey) { }; } function claim(dTag, author) { + const createdAt = clock++; return { + id: createdAt.toString(16).padStart(64, "0"), kind: KIND_IMPORT_IDENTITY_CLAIM, pubkey: author, - created_at: clock++, + created_at: createdAt, tags: [["d", dTag]], }; } @@ -90,3 +94,27 @@ test("bound pubkey is lowercased on both sides before matching", () => { // Case must not defeat the match, and the stored key is lowercase. assert.deepEqual([...map], [["slack:U1", lower]]); }); + +test("same-second conflicting attestations resolve deterministically by id", () => { + const first = attestation("slack:U1", ALICE); + const second = attestation("slack:U1", MALLORY); + first.created_at = 2000; + second.created_at = 2000; + first.id = "1".padStart(64, "0"); + second.id = "2".padStart(64, "0"); + const events = [ + claim("slack:U1", ALICE), + claim("slack:U1", MALLORY), + second, + first, + ]; + + assert.deepEqual( + [...buildConfirmedImportBindings(events)], + [["slack:U1", MALLORY]], + ); + assert.deepEqual( + [...buildConfirmedImportBindings([...events].reverse())], + [["slack:U1", MALLORY]], + ); +}); diff --git a/desktop/src/features/messages/lib/confirmImportBindings.ts b/desktop/src/features/messages/lib/confirmImportBindings.ts index a7a9ffa009..6129be8afc 100644 --- a/desktop/src/features/messages/lib/confirmImportBindings.ts +++ b/desktop/src/features/messages/lib/confirmImportBindings.ts @@ -25,7 +25,9 @@ export function buildConfirmedImportBindings( ): Map { // Attestations are parameterized-replaceable: newest per key wins, so sort // ascending and let later writes overwrite. - const ordered = [...events].sort((a, b) => a.created_at - b.created_at); + const ordered = [...events].sort( + (a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id), + ); // slack: -> attested pubkey (owner/admin-signed, relay-gated). const attested = new Map(); diff --git a/desktop/src/features/messages/lib/publishImportIdentityClaim.ts b/desktop/src/features/messages/lib/publishImportIdentityClaim.ts index 564aaebeaa..8f0756cd92 100644 --- a/desktop/src/features/messages/lib/publishImportIdentityClaim.ts +++ b/desktop/src/features/messages/lib/publishImportIdentityClaim.ts @@ -3,15 +3,14 @@ import { signRelayEvent } from "@/shared/api/tauri"; import { KIND_IMPORT_IDENTITY_CLAIM } from "@/shared/constants/kinds"; /** - * Publish the current device identity's consent to an imported identity - * binding. The relay connection must already point at the community that owns - * the imported history. + * Sign this device identity's consent to an imported identity binding, WITHOUT + * publishing it. The signed self-claim (kind {@link KIND_IMPORT_IDENTITY_CLAIM}) + * doubles as a proof of key possession: its valid signature is what the + * migration service binds the attestation to at `/oidc/finalize`. */ -export async function publishImportIdentityClaim( - subject: string, -): Promise { +async function signImportIdentityClaim(subject: string) { const source = subject.split(":", 1)[0] || "slack"; - const event = await signRelayEvent({ + return signRelayEvent({ kind: KIND_IMPORT_IDENTITY_CLAIM, content: "", tags: [ @@ -19,9 +18,51 @@ export async function publishImportIdentityClaim( ["import", source], ], }); +} + +/** + * Publish the current device identity's consent to an imported identity + * binding. The relay connection must already point at the community that owns + * the imported history. + */ +export async function publishImportIdentityClaim( + subject: string, +): Promise { + const event = await signImportIdentityClaim(subject); await relayClient.publishEvent( event, "Timed out publishing your identity claim.", "Failed to publish your identity claim.", ); } + +/** + * Complete the server side of a Slack OAuth join before connecting: sign a + * self-claim and hand it to `/oidc/finalize` as proof of possession. The + * service admits that exact key and publishes the owner/admin attestation. + * The app publishes its consent claim separately after the target relay is + * active. + */ +export async function finalizeSlackOidcAttestation(input: { + service: string; + code: string; + subject: string; +}): Promise { + const event = await signImportIdentityClaim(input.subject); + + const base = input.service.replace(/\/+$/, ""); + const response = await fetch(`${base}/oidc/finalize`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: input.code, claim: event }), + signal: AbortSignal.timeout(30_000), + }); + const json = (await response.json().catch(() => ({}))) as { + error?: unknown; + }; + if (!response.ok) { + const message = + typeof json.error === "string" ? json.error : `HTTP ${response.status}`; + throw new Error(message); + } +} diff --git a/desktop/src/features/messages/ui/ImportClaimDialog.tsx b/desktop/src/features/messages/ui/ImportClaimDialog.tsx index 8dadd67481..d70c806cd4 100644 --- a/desktop/src/features/messages/ui/ImportClaimDialog.tsx +++ b/desktop/src/features/messages/ui/ImportClaimDialog.tsx @@ -20,7 +20,10 @@ import { } from "@/shared/ui/dialog"; import { importIdentityBindingsQueryKey } from "../useImportIdentityBindings"; -import { publishImportIdentityClaim } from "../lib/publishImportIdentityClaim"; +import { + finalizeSlackOidcAttestation, + publishImportIdentityClaim, +} from "../lib/publishImportIdentityClaim"; type Phase = "confirm" | "working" | "done" | "error"; @@ -45,6 +48,7 @@ function assertMatchesSlackJoin( payload.via !== "oidc" || !payload.relayUrl || !payload.service || + !payload.code || normalizedUrl(payload.relayUrl) !== normalizedUrl(relayUrl) || normalizedUrl(payload.service) !== normalizedUrl(serviceUrl ?? "") ) { @@ -64,80 +68,114 @@ function assertMatchesSlackJoin( * publishes the subject's self-claim (kind 30624). Only both together * attribute the imported history — so a stray link can, at worst, make the * user consent to an identity no attestation vouches for (inert). - * - **oidc**: `via === "oidc"`. Slack already verified the user server-side, - * admitted their public key, and published the attestation. During a - * `join-slack` transaction this dialog records the verified subject; the - * onboarding flow connects to the target community before self-claiming. + * - **oidc**: `via === "oidc"`. Slack verified the user server-side but the + * attestation is *not* yet published. During a `join-slack` transaction the + * callback is matched to its expected relay and service, then the app redeems + * the `code` at `/oidc/finalize` with a freshly signed self-claim. Only after + * that creates membership and the attestation does onboarding connect to the + * target community and publish the member's consent claim. * - * Because the self-claim is a consent signature, we always show an explicit - * confirm step naming the subject before signing anything. + * Completing the dedicated Slack OAuth flow is the consent action for OIDC, so + * it needs no second confirmation in Buzz. The email fallback retains an + * explicit confirmation because opening a bearer link is a different flow. */ export function ImportClaimDialog() { const queryClient = useQueryClient(); - const communityOnboarding = useCommunityOnboarding(); + const { transaction, update } = useCommunityOnboarding(); const [payload, setPayload] = React.useState(null); const [phase, setPhase] = React.useState("confirm"); const [error, setError] = React.useState(null); + const transactionRef = React.useRef(transaction); + const oidcFinalizeRef = React.useRef(null); + transactionRef.current = transaction; + + const close = React.useCallback(() => { + setPayload(null); + setError(null); + setPhase("confirm"); + }, []); + + const receiveClaim = React.useCallback( + (next: ImportClaimDeepLinkPayload) => { + if (next.via === "oidc") { + try { + const pending = transactionRef.current; + if (pending?.stage !== "slack-auth") { + throw new Error( + "Start Slack sign-in from your team's Slack migration link.", + ); + } + assertMatchesSlackJoin(next, pending.relayUrl, pending.slackService); + const service = next.service; + const code = next.code; + if (!service || !code) { + throw new Error("This Slack response is incomplete."); + } + if (oidcFinalizeRef.current === pending.id) return; + oidcFinalizeRef.current = pending.id; + void finalizeSlackOidcAttestation({ + service, + code, + subject: next.subject, + }) + .then(() => { + update( + { + stage: "connecting", + slackSubject: next.subject, + error: undefined, + }, + pending.id, + ); + toast.success( + "Signed in with Slack — setting up your workspace.", + ); + }) + .catch((error: unknown) => { + oidcFinalizeRef.current = null; + const message = + error instanceof Error ? error.message : String(error); + toast.error(`Couldn't finish Slack migration: ${message}`); + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + toast.error(`Couldn't finish Slack migration: ${message}`); + } + return; + } - React.useEffect(() => { - let cancelled = false; - const unlistenPromise = listenForImportClaimDeepLinks((next) => { - if (cancelled) return; setError(null); setPhase("confirm"); setPayload(next); + }, + [update], + ); + + React.useEffect(() => { + let cancelled = false; + const unlistenPromise = listenForImportClaimDeepLinks((next) => { + if (!cancelled) receiveClaim(next); }); return () => { cancelled = true; void unlistenPromise.then((unlisten) => unlisten()); }; - }, []); - - const close = React.useCallback(() => { - setPayload(null); - setError(null); - setPhase("confirm"); - }, []); + }, [receiveClaim]); const confirm = React.useCallback(async () => { if (!payload) return; setPhase("working"); setError(null); try { + if (!payload.token || !payload.service) { + throw new Error("This migration link is incomplete."); + } const { pubkey } = await getIdentity(); // Email channel: redeem the token so the operator publishes the // attestation. The token proves inbox control; the pubkey is ours. - if (payload.token && payload.service) { - await completeEmailClaim(payload.service, payload.token, pubkey); - } - - // If this claim completed a Slack-migration *join* (the person is mid - // onboarding at the slack-auth stage), connect to that community before - // publishing the self-claim. Publishing here would target whichever - // community happened to be active before the join. - const tx = communityOnboarding.transaction; - if (tx?.stage === "slack-auth") { - assertMatchesSlackJoin(payload, tx.relayUrl, tx.slackService); - communityOnboarding.update( - { - stage: "connecting", - slackSubject: payload.subject, - error: undefined, - }, - tx.id, - ); - toast.success("Signed in with Slack — setting up your workspace."); - close(); - return; - } - - if (payload.via === "oidc") { - throw new Error( - "Start Slack sign-in from your team's Slack migration link.", - ); - } + await completeEmailClaim(payload.service, payload.token, pubkey); // Email fallback claims run inside an already-connected community. await publishImportIdentityClaim(payload.subject); @@ -153,7 +191,7 @@ export function ImportClaimDialog() { setPhase("error"); toast.error(`Couldn't link your history: ${message}`); } - }, [payload, queryClient, communityOnboarding, close]); + }, [payload, queryClient]); const open = payload !== null; diff --git a/desktop/src/features/messages/useImportIdentityBindings.ts b/desktop/src/features/messages/useImportIdentityBindings.ts index 17404c94d9..5d971bda0f 100644 --- a/desktop/src/features/messages/useImportIdentityBindings.ts +++ b/desktop/src/features/messages/useImportIdentityBindings.ts @@ -24,6 +24,7 @@ export const importIdentityBindingsQueryKey = [ ] as const; const EMPTY_PUBKEYS: string[] = []; +const IMPORT_IDENTITY_EVENT_LIMIT = 10_000; /** * Returns the confirmed binding map plus the deduped list of bound pubkeys — @@ -38,11 +39,17 @@ export function useImportIdentityBindings(): { const query = useQuery({ queryKey: importIdentityBindingsQueryKey, queryFn: async () => { - const events = await relayClient.fetchEvents({ - kinds: [KIND_IMPORT_IDENTITY_BINDING, KIND_IMPORT_IDENTITY_CLAIM], - limit: 2000, - }); - return buildConfirmedImportBindings(events); + // Fetch each half separately so the relay's per-query cap supports up to + // 10,000 identities instead of being shared by attestations and claims. + const eventPages = await Promise.all( + [KIND_IMPORT_IDENTITY_BINDING, KIND_IMPORT_IDENTITY_CLAIM].map((kind) => + relayClient.fetchEvents({ + kinds: [kind], + limit: IMPORT_IDENTITY_EVENT_LIMIT, + }), + ), + ); + return buildConfirmedImportBindings(eventPages.flat()); }, // Bindings change rarely (only when an operator attributes an import or a // person consents). diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 7ca7de020a..faf6c3fb59 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -478,7 +478,7 @@ export function CommunityOnboardingFlow({

{transaction.error ?? - "Continue in your browser to sign in with Slack, then return to Buzz to confirm the connection to your imported history."} + "Continue in your browser to sign in with Slack. Buzz will return here and connect your imported history automatically."}