diff --git a/.cargo/config.toml b/.cargo/config.toml index d363b83..46adaad 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,2 @@ [env] -MTP_TYPE_MAPS = { value = "type-maps.yaml", relative = true } +MTP_TYPE_MAPS = { value = "mtp-type-maps/type-maps.yaml", relative = true } diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..3069632 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "mtp-type-maps"] + path = mtp-type-maps + url = ssh://git@git.methanium.net/tensamin/mtp-type-maps diff --git a/Cargo.lock b/Cargo.lock index b8631b7..ebb658a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,7 +198,7 @@ dependencies = [ "foldhash", "futures-core", "futures-util", - "impl-more 0.3.2", + "impl-more 0.3.5", "itoa", "language-tags", "log", @@ -651,9 +651,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "jobserver", @@ -1327,9 +1327,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "encoding_rs" @@ -2136,9 +2136,9 @@ checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" [[package]] name = "impl-more" -version = "0.3.2" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "134d2c4324d61664107020b79019cf6a6aec153f0b79bc9619ee9e794a5fb021" +checksum = "277ff51754a3f68f12f58446c5d006aa8baa4914ea273cce24a599cfaff33d4f" [[package]] name = "indexmap" @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "mtp" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "mtp-client", "mtp-codec", @@ -2972,7 +2972,7 @@ dependencies = [ [[package]] name = "mtp-client" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "mtp-codec", "mtp-common", @@ -2985,7 +2985,7 @@ dependencies = [ [[package]] name = "mtp-codec" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "base64", "byteorder", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "mtp-common" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "quinn", "rustls", @@ -3009,7 +3009,7 @@ dependencies = [ [[package]] name = "mtp-crypto" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "base64", "chacha20poly1305", @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "mtp-files" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "mtp-crypto", "rand 0.10.2", @@ -3042,7 +3042,7 @@ dependencies = [ [[package]] name = "mtp-host" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "mtp-codec", "mtp-common", @@ -3057,7 +3057,7 @@ dependencies = [ [[package]] name = "mtp-transport" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "async-trait", "mtp-codec", @@ -3075,7 +3075,7 @@ dependencies = [ [[package]] name = "mtp-type-map" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "serde", "serde_yaml", @@ -3084,7 +3084,7 @@ dependencies = [ [[package]] name = "mtp-webserver" version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738" +source = "git+https://git.methanium.net/Methanium/mtp.git#88ae866b91857c9b1650ea0ee2af2fdb70b92f10" dependencies = [ "async-trait", "bytes", @@ -4263,9 +4263,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs index 3ffa8d1..ad0796f 100644 --- a/iota-cli/src/ipc_client.rs +++ b/iota-cli/src/ipc_client.rs @@ -594,7 +594,7 @@ impl IpcClient { return Err(error); } - match tokio::time::timeout(Duration::from_secs(30), response_rx).await { + match tokio::time::timeout(Duration::from_secs(45), response_rx).await { Ok(Ok(result)) => Ok(result), Ok(Err(_)) => Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)), Err(_) => { diff --git a/iota-cli/src/screens/overview.rs b/iota-cli/src/screens/overview.rs index 4d4aea6..6f32393 100644 --- a/iota-cli/src/screens/overview.rs +++ b/iota-cli/src/screens/overview.rs @@ -55,6 +55,17 @@ impl OverviewScreen { lines.push(Line::from(Span::styled("Connection", theme.text.heading))); lines.push(Line::from(format!(" State: {}", connection_label(&conn)))); + let omikron = daemon.components.get(&iota_ipc::ComponentId::Omikron); + let omikron_label = match omikron.map(|health| health.status) { + Some(iota_ipc::HealthStatus::Healthy) => "[OK] Connected", + Some(iota_ipc::HealthStatus::Degraded) => "[WARN] Connecting or unavailable", + Some(iota_ipc::HealthStatus::Failed) => "[FAIL] Authentication failed", + None => "Unknown", + }; + lines.push(Line::from(format!(" Omikron: {omikron_label}"))); + if let Some(message) = omikron.and_then(|health| health.message.as_deref()) { + lines.push(Line::from(format!(" Omikron detail: {message}"))); + } lines.push(Line::from("")); lines.push(Line::from(Span::styled("Daemon", theme.text.heading))); diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index ebc3e0e..b47366a 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -8,7 +8,7 @@ use iota_ipc::{ }; use iota_logger::{log, log_command}; use iota_storage::users::user_manager; -use iota_storage::util::config_util::{self, modify_config}; +use iota_storage::util::config_util::{self}; use mtp::codec::{CommunicationType, CommunicationValue}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -52,10 +52,7 @@ impl CommandRouter { } let needs_omikron = matches!( request, - LocalRequest::CreateUser { .. } - | LocalRequest::RemoveUser { .. } - | LocalRequest::ReconnectOmikron - | LocalRequest::RotateIotaIdentity + LocalRequest::CreateUser { .. } | LocalRequest::RemoveUser { .. } ); if needs_omikron && !self.services.omikron.is_connected().await { return ResponseResult::Error( @@ -112,11 +109,33 @@ impl CommandRouter { ) .await { - (Some(user), _) => ResponseResult::Ok(ResponsePayload::UserCreated { + Ok(user) => ResponseResult::Ok(ResponsePayload::UserCreated { user_id: user.user_id, username: user.username, }), - _ => ResponseResult::Error(IpcErrorCode::StorageFailure), + Err(error) => { + log!("User creation failed: {error:?}"); + match error { + omikron_connector::user_ops::CreateUserError::InvalidUsername => { + ResponseResult::Error(IpcErrorCode::InvalidRequest) + } + omikron_connector::user_ops::CreateUserError::Transport( + omikron_connector::OmikronError::Timeout(_), + ) => ResponseResult::Error(IpcErrorCode::Timeout), + omikron_connector::user_ops::CreateUserError::Transport(_) => { + ResponseResult::Error(IpcErrorCode::OmikronUnavailable) + } + omikron_connector::user_ops::CreateUserError::RemoteRejected => { + ResponseResult::Error(IpcErrorCode::Conflict) + } + omikron_connector::user_ops::CreateUserError::LocalPersistence(_) => { + ResponseResult::Error(IpcErrorCode::StorageFailure) + } + omikron_connector::user_ops::CreateUserError::InvalidResponse => { + ResponseResult::Error(IpcErrorCode::InternalFailure) + } + } + } } } LocalRequest::RemoveUser { user_id } => { @@ -139,16 +158,14 @@ impl CommandRouter { Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), }, LocalRequest::RotateIotaIdentity => { - modify_config(|config| { - config.public_key = None; - config.private_key = None; - config.iota_id = None; - }); - match self.services.omikron.reconnect().await { + match self.services.omikron.rotate_identity().await { Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: "Key pair regenerated and Omikron reconnection requested".into(), + message: "New identity registered with Omikron".into(), }), - Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), + Err(error) => { + log!("Iota identity rotation failed: {}", error); + ResponseResult::Error(IpcErrorCode::OmikronUnavailable) + } } } LocalRequest::RequestProcessExit { intent } => { diff --git a/iota-daemon-lib/src/services.rs b/iota-daemon-lib/src/services.rs index bd2ac1c..b63b574 100644 --- a/iota-daemon-lib/src/services.rs +++ b/iota-daemon-lib/src/services.rs @@ -61,6 +61,11 @@ impl OmikronClient for InactiveOmikron { "terms have not been accepted".into(), )) } + async fn rotate_identity(&self) -> Result<(), OmikronError> { + Err(OmikronError::Disconnected( + "terms have not been accepted".into(), + )) + } async fn is_connected(&self) -> bool { false } diff --git a/iota-daemon-lib/tests/command_router.rs b/iota-daemon-lib/tests/command_router.rs index 3f80199..2bf8bd7 100644 --- a/iota-daemon-lib/tests/command_router.rs +++ b/iota-daemon-lib/tests/command_router.rs @@ -29,8 +29,12 @@ impl OmikronClient for FakeOmikron { self.reconnects.fetch_add(1, Ordering::SeqCst); Ok(()) } + async fn rotate_identity(&self) -> Result<(), OmikronError> { + self.reconnects.fetch_add(1, Ordering::SeqCst); + Ok(()) + } async fn is_connected(&self) -> bool { - true + false } } @@ -43,6 +47,7 @@ async fn reconnect_uses_the_injected_client() { omikron: fake.clone(), users: Default::default(), config: Default::default(), + active: true, }); let router = CommandRouter::new( Arc::new(DaemonRuntime::new()), @@ -55,3 +60,29 @@ async fn reconnect_uses_the_injected_client() { )); assert_eq!(fake.reconnects.load(Ordering::SeqCst), 1); } + +#[tokio::test] +async fn identity_rotation_is_available_while_omikron_is_offline() { + let fake = Arc::new(FakeOmikron { + reconnects: AtomicUsize::new(0), + }); + let services = Arc::new(DaemonServices { + omikron: fake.clone(), + users: Default::default(), + config: Default::default(), + active: true, + }); + let router = CommandRouter::new( + Arc::new(DaemonRuntime::new()), + services, + Arc::new(Mutex::new(LogBuffer::new(100))), + ); + assert!(matches!( + router + .route(1, LocalRequest::RotateIotaIdentity) + .await + .result, + ResponseResult::Ok(_) + )); + assert_eq!(fake.reconnects.load(Ordering::SeqCst), 1); +} diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs index d971f91..47a0a51 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -140,19 +140,75 @@ async fn main() -> ExitCode { ); connection } - Err(omikron_connector::OmikronStartupError::Authentication) => { + Err(omikron_connector::OmikronStartupError::Authentication { connection }) => { runtime.set_component_failed( iota_ipc::ComponentId::Omikron, - "Omikron authentication failed".into(), + "Omikron authentication failed; regenerate the Iota identity to register again" + .into(), ); - return ExitCode::FAILURE; + // Keep IPC alive: identity rotation is the supported recovery + // action and must remain available after authentication fails. + connection } Err(omikron_connector::OmikronStartupError::Construction(error)) => { eprintln!("Cannot construct Omikron connection: {error}"); return ExitCode::FAILURE; } }; + let omikron_health = omikron.clone(); let services = DaemonServices::new(omikron); + let health_runtime = runtime.clone(); + runtime + .tasks + .spawn_tracked("omikron-health", async move { + let mut states = omikron_health.connection_state(); + loop { + let state = *states.borrow(); + match state { + omikron_connector::omikron_connection::ConnectionState::Connected { + .. + } => { + let ping_ms = *omikron_health.last_ping.lock().await; + let message = if ping_ms >= 0 { + format!("connected (RTT: {ping_ms} ms)") + } else { + "connected (waiting for RTT sample)".into() + }; + health_runtime + .set_component_healthy(iota_ipc::ComponentId::Omikron, Some(message)); + } + omikron_connector::omikron_connection::ConnectionState::Connecting => { + health_runtime.set_component_degraded( + iota_ipc::ComponentId::Omikron, + "connecting to Omikron".into(), + ); + } + omikron_connector::omikron_connection::ConnectionState::Disconnected => { + let message = omikron_health + .get_auth_failure() + .await + .unwrap_or_else(|| "disconnected; retrying".into()); + if omikron_health.has_auth_failure().await { + health_runtime + .set_component_failed(iota_ipc::ComponentId::Omikron, message); + } else { + health_runtime + .set_component_degraded(iota_ipc::ComponentId::Omikron, message); + } + } + } + tokio::select! { + changed = states.changed() => if changed.is_err() { break }, + // RTT is updated by MTP's heartbeat independently of a + // connection-state transition, so periodically refresh + // the component detail while connected. + _ = tokio::time::sleep(Duration::from_secs(1)) => {}, + _ = health_runtime.cancellation.cancelled() => break, + } + } + Ok(()) + }) + .await; let ipc_server = match IpcServer::bind( socket.clone(), runtime.clone(), diff --git a/iota-installer/src/lib.rs b/iota-installer/src/lib.rs index 62f663a..29724d6 100644 --- a/iota-installer/src/lib.rs +++ b/iota-installer/src/lib.rs @@ -171,5 +171,3 @@ fn run(program: &str, args: &[&str]) -> Result<()> { bail!("{program} failed; run the installer as root") } } - - diff --git a/iota-logger/src/lib.rs b/iota-logger/src/lib.rs index fa1e2f0..fe3ed8e 100644 --- a/iota-logger/src/lib.rs +++ b/iota-logger/src/lib.rs @@ -98,19 +98,19 @@ pub fn startup_with_log_dir(log_dir: Option) { format!("{} ", msg.prefix) }; - let line1 = format!( - "{} {}{}", + let line = format!( + "{} {} {}{}", fixed_box(&msg.timestamp_ms.to_string(), 13), + timestamp, prefix, resolved_message ); - let line2 = format!(" {}", timestamp); if let Some(file) = file.as_mut() { - let _ = writeln!(file, "{}\n{}", line1, line2); + let _ = writeln!(file, "{}", line); } - let _ = writeln!(std::io::stderr(), "{}\n{}", line1, line2); + let _ = writeln!(std::io::stderr(), "{}", line); let entry = UiLogEntry { timestamp_ms: msg.timestamp_ms, @@ -329,8 +329,11 @@ pub fn format_cv(cv: &CommunicationValue) -> String { parts.push(format!("> {}", receiver)); } - let comm_type = cv.get_type().to_string(); - parts.push(format!("{}", comm_type)); + let comm_type = cv + .get_comm_type_enum() + .map(|kind| kind.to_string()) + .unwrap_or_else(|| cv.get_type().to_string()); + parts.push(format!("{} (id={})", comm_type, cv.get_id())); let data = cv.data(); diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index d6a0b47..af842e5 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -7,7 +7,13 @@ use rand_core::{OsRng, RngCore}; use rusqlite::params; pub fn add_user(user: UserProfile) { - if let Err(e) = db::with_db(|conn| { + if let Err(e) = try_add_user(user) { + eprintln!("Failed to add_user: {}", e); + } +} + +pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::StorageError> { + db::with_db(|conn| { conn.execute( r#" INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at, display_name) @@ -40,9 +46,7 @@ pub fn add_user(user: UserProfile) { )?; } Ok(()) - }) { - eprintln!("Failed to add_user: {}", e); - } + }) } pub fn update_user(user: UserProfile) { diff --git a/iota-storage/src/util/e2ee_storage.rs b/iota-storage/src/util/e2ee_storage.rs index bfa70c4..9d628d4 100644 --- a/iota-storage/src/util/e2ee_storage.rs +++ b/iota-storage/src/util/e2ee_storage.rs @@ -239,5 +239,3 @@ fn pending_forward_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result Result, std::io::Error> } pub fn save_file(path: &str, name: &str, value: &str) { - let Ok(file_path) = storage_file(path, name) else { - eprintln!("[IMPORTANT] Refusing unsafe storage path"); - return; - }; - let Some(dir) = file_path.parent() else { - return; - }; - - if !dir.exists() { - if let Err(e) = fs::create_dir_all(&dir) { - println!("[IMPORTANT] Couldn't create directories: {}", e); - return; - } + if let Err(error) = try_save_file(path, name, value) { + eprintln!("[IMPORTANT] Couldn't save file: {error}"); } +} + +pub fn try_save_file(path: &str, name: &str, value: &str) -> io::Result<()> { + let file_path = storage_file(path, name)?; + let dir = file_path + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "file has no parent"))?; + + fs::create_dir_all(dir)?; // Write to a temp file first, then atomically rename to prevent partial writes. let tmp_name = format!(".{}.tmp", name); let tmp_path = dir.join(&tmp_name); - if let Err(e) = fs::write(&tmp_path, value) { - println!( - "[IMPORTANT] Couldn't write temp file {}: {}", - tmp_path.display(), - e - ); - return; - } - if let Err(e) = fs::rename(&tmp_path, &file_path) { - println!( - "[IMPORTANT] Couldn't rename {} to {}: {}", - tmp_path.display(), - file_path.display(), - e - ); + fs::write(&tmp_path, value)?; + if let Err(error) = fs::rename(&tmp_path, &file_path) { let _ = fs::remove_file(&tmp_path); + return Err(error); } + Ok(()) } pub fn get_children(path: &str) -> Vec { diff --git a/mtp-type-maps b/mtp-type-maps new file mode 160000 index 0000000..594646a --- /dev/null +++ b/mtp-type-maps @@ -0,0 +1 @@ +Subproject commit 594646ac39d986f0787aa614a99d580035a67318 diff --git a/omikron-connector/src/client.rs b/omikron-connector/src/client.rs index 872ff67..0561212 100644 --- a/omikron-connector/src/client.rs +++ b/omikron-connector/src/client.rs @@ -27,7 +27,9 @@ pub enum OmikronStartupError { InitialConnectionTimeout { connection: std::sync::Arc, }, - Authentication, + Authentication { + connection: std::sync::Arc, + }, } #[async_trait] @@ -39,5 +41,9 @@ pub trait OmikronClient: Send + Sync { timeout: Duration, ) -> Result; async fn reconnect(&self) -> Result<(), OmikronError>; + /// Replace the local Iota identity and wait for the new identity to + /// register/authenticate. This is deliberately available while offline: + /// it is the recovery operation for an authentication failure. + async fn rotate_identity(&self) -> Result<(), OmikronError>; async fn is_connected(&self) -> bool; } diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index bbf2bf2..02fd8f4 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -13,7 +13,7 @@ use std::env; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Arc, LazyLock}; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::sync::{Mutex, RwLock, Semaphore, oneshot, watch}; use tokio::task::JoinHandle; use tokio::time::sleep; @@ -221,6 +221,11 @@ impl OmikronConnection { let _ = self.state_watch_tx.send(new_state); } + /// Subscribe to connection transitions for daemon health reporting. + pub fn connection_state(&self) -> watch::Receiver { + self.state_watch_tx.subscribe() + } + // ------------------------------------------------------------------------- // Connection Management // ------------------------------------------------------------------------- @@ -1880,10 +1885,11 @@ impl OmikronConnection { let reason = response_cv .get_data(DataType::Message) .as_str() + .or_else(|| response_cv.get_data(DataType::ErrorType).as_str()) .unwrap_or("connection error") .to_string(); Err(format!( - "Request failed due to disconnect (msg_id={}, reason={})", + "Request rejected (msg_id={}, reason={})", msg_id, reason )) } else { @@ -1955,6 +1961,75 @@ impl OmikronConnection { self.stop().await; self.connect().await; } + + /// Create a new local keyring and register it as a new Iota identity. + /// The existing keyring is retained as a timestamped backup so a failed + /// recovery does not silently destroy the user's previous identity. + pub async fn rotate_identity(self: &Arc) -> Result<(), OmikronError> { + log!("Iota identity rotation requested"); + self.stop().await; + + let path = identity_path(); + if path.exists() { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let backup = path.with_extension(format!("mk.backup-{stamp}")); + std::fs::rename(path, &backup).map_err(|error| { + OmikronError::Internal(format!( + "could not back up identity {}: {error}", + path.display() + )) + })?; + log!("Existing Iota identity backed up to {}", backup.display()); + } + + let keyring = crypto_helper::generate_keyring(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + OmikronError::Internal(format!( + "could not create identity directory {}: {error}", + parent.display() + )) + })?; + } + mtp::files::save_keyring_raw(&keyring, path).map_err(|error| { + OmikronError::Internal(format!( + "could not save new identity {}: {error}", + path.display() + )) + })?; + modify_config(|config| { + config.iota_id = None; + config.keyring = None; + config.public_key = None; + config.private_key = None; + }); + log!("New Iota identity generated; registration started"); + + self.clear_auth_failure().await; + self.connect().await; + match self.await_connection(Some(CONNECTION_TIMEOUT)).await { + Ok(()) => { + let id = CONFIG.load().iota_id; + log!( + "New Iota identity registered{}", + id.map(|v| format!(" (Iota-ID: {v})")).unwrap_or_default() + ); + Ok(()) + } + Err(timeout) => { + if let Some(reason) = self.get_auth_failure().await { + log!("Iota identity registration failed: {}", reason); + Err(OmikronError::Authentication(reason)) + } else { + log!("Iota identity registration did not complete: {}", timeout); + Err(OmikronError::Timeout(timeout)) + } + } + } + } } // ============================================================================ @@ -1975,7 +2050,7 @@ pub async fn connect_initial( match conn.await_connection(Some(CONNECTION_TIMEOUT)).await { Ok(()) => Ok(conn), Err(_) if conn.has_auth_failure().await => { - Err(crate::client::OmikronStartupError::Authentication) + Err(crate::client::OmikronStartupError::Authentication { connection: conn }) } Err(_) => { Err(crate::client::OmikronStartupError::InitialConnectionTimeout { connection: conn }) @@ -2027,6 +2102,8 @@ impl OmikronClient for OmikronConnection { .map_err(|error| { if error.contains("timed out") { OmikronError::Timeout(error) + } else if error.starts_with("Request rejected") { + OmikronError::Internal(error) } else { OmikronError::Disconnected(error) } @@ -2057,6 +2134,29 @@ impl OmikronClient for OmikronConnection { Ok(()) } + async fn rotate_identity(&self) -> Result<(), OmikronError> { + let this = Arc::new(Self { + state: self.state.clone(), + state_watch_tx: self.state_watch_tx.clone(), + sender: self.sender.clone(), + connection_loop_handle: self.connection_loop_handle.clone(), + last_ping: self.last_ping.clone(), + heartbeat_handle: self.heartbeat_handle.clone(), + connection_id: self.connection_id, + shutdown_tx: self.shutdown_tx.clone(), + reconnect_on_close: self.reconnect_on_close.clone(), + auth_failure: self.auth_failure.clone(), + app_challenges: self.app_challenges.clone(), + app_sessions: self.app_sessions.clone(), + missed_pongs: self.missed_pongs.clone(), + handler_semaphore: self.handler_semaphore.clone(), + cancellation: self.cancellation.clone(), + active_tasks: self.active_tasks.clone(), + app: self.app.clone(), + }); + Self::rotate_identity(&this).await + } + async fn is_connected(&self) -> bool { Self::is_connected(self).await } diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs index 00733e1..fde043f 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -1,9 +1,9 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use iota_logger::{PrintType, log, log_cv, log_t}; -use iota_storage::users::user_manager::{add_user, save_users}; +use iota_storage::users::user_manager::try_add_user; use iota_storage::users::user_profile::UserProfile; use iota_util::crypto_helper::{self, hex_hash, public_key_bundle_to_base64}; -use iota_util::file_util::save_file; +use iota_util::file_util::try_save_file; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use rand_core::{OsRng, RngCore}; use std::time::Duration; @@ -11,34 +11,75 @@ use std::time::Duration; use crate::OmikronClient; use crate::omega_discovery; +#[derive(Debug)] +pub enum CreateUserError { + InvalidUsername, + Transport(crate::OmikronError), + InvalidResponse, + RemoteRejected, + LocalPersistence(String), +} + +fn valid_username(username: &str) -> bool { + !username.is_empty() + && username.chars().count() <= 15 + && !username.chars().any(char::is_control) + && !username.contains(['/', '\\']) +} + +async fn request_user_id(connection: &dyn OmikronClient) -> Result<(i64, String), CreateUserError> { + let request = CommunicationValue::new(CommunicationType::GetRegister); + let response = connection + .await_response(&request, Duration::from_secs(20)) + .await + .map_err(CreateUserError::Transport)?; + + if !response.is_type(CommunicationType::GetRegister) { + return Err(CreateUserError::InvalidResponse); + } + + let user_id = response + .get_data(DataType::UserId) + .as_number() + .and_then(|id| i64::try_from(id).ok()) + .filter(|id| (1..(1_i64 << 48)).contains(id)) + .ok_or(CreateUserError::InvalidResponse)?; + let registration_token = response + .get_data(DataType::RegisterId) + .as_str() + .filter(|token| uuid::Uuid::parse_str(token).is_ok()) + .map(str::to_owned) + .ok_or(CreateUserError::InvalidResponse)?; + Ok((user_id, registration_token)) +} + +/// A completion response can be lost after Omega commits the user. Confirm +/// the exact remote record before treating that transport failure as success. +async fn registration_committed(connection: &dyn OmikronClient, profile: &UserProfile) -> bool { + let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( + DataType::UserId, + DataValue::SignedNumber(profile.user_id.into()), + ); + let Ok(response) = connection + .await_response(&request, Duration::from_secs(5)) + .await + else { + return false; + }; + response.get_data(DataType::UserId).as_number() == Some(profile.user_id.into()) + && response.get_data(DataType::Username).as_str() == Some(profile.username.as_str()) + && response.get_data(DataType::PublicKey).as_str() == Some(profile.public_key.as_str()) +} + pub async fn create_user( connection: &dyn OmikronClient, username: &str, -) -> (Option, Option) { - let register_communication_value = CommunicationValue::new(CommunicationType::GetRegister); - - let response_communication_value = match connection - .await_response(®ister_communication_value, Duration::from_secs(20)) - .await - { - Ok(communication_value) => communication_value, - Err(e) => { - log_t!("User creation: {}", e.to_string()); - return (None, None); - } - }; - log_cv!(PrintType::Omega, response_communication_value); - - let user_id = match response_communication_value - .get_data(DataType::UserId) - .as_number() - { - Some(id) => id, - None => { - log_t!("User creation: Response returned none"); - return (None, None); - } - }; +) -> Result { + if !valid_username(username) { + return Err(CreateUserError::InvalidUsername); + } + let (user_id, registration_token) = request_user_id(connection).await?; + log!("User creation: Omega allocated user ID {user_id}"); let keyring = crypto_helper::generate_keyring(); let pub_key_bundle = keyring.public_key_bundle(); let keyring_b64 = crypto_helper::keyring_to_base64(&keyring); @@ -50,7 +91,7 @@ pub async fn create_user( let reset_token = STANDARD.encode(&bytes); let user_profile = UserProfile::new( - user_id as i64, + user_id, username.to_string(), None, public_key_bundle_to_base64(&pub_key_bundle), @@ -59,30 +100,43 @@ pub async fn create_user( ); let communication_value = CommunicationValue::new(CommunicationType::CompleteRegisterUser) - .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id as i128)) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into())) .add_typed_default(DataType::Username, DataValue::Str(username.to_string())) .add_typed_default( DataType::PublicKey, DataValue::Str(public_key_bundle_to_base64(&pub_key_bundle)), ) - .add_typed_default(DataType::IotaId, DataValue::SignedNumber(user_id as i128)) - .add_typed_default(DataType::ResetToken, DataValue::Str(reset_token)); + .add_typed_default(DataType::ResetToken, DataValue::Str(reset_token)) + .add_typed_default(DataType::RegisterId, DataValue::Str(registration_token)); let response_communication_value = connection .await_response(&communication_value, Duration::from_secs(20)) .await; - if let Ok(response) = response_communication_value { - log_cv!(PrintType::Omega, response); - if !response.is_type(CommunicationType::Success) { - return (None, None); + match response_communication_value { + Ok(response) => { + log_cv!(PrintType::Omega, response); + if !response.is_type(CommunicationType::Success) { + return Err(CreateUserError::RemoteRejected); + } + } + Err(error) => { + if registration_committed(connection, &user_profile).await { + log!( + "User creation: completion response was lost; verified user {} remotely", + user_id + ); + } else { + log_t!("User creation: {}", error.to_string()); + return Err(match error { + crate::OmikronError::Internal(_) => CreateUserError::RemoteRejected, + error => CreateUserError::Transport(error), + }); + } } - } else { - log_t!("User creation: Response returned none"); - return (None, None); } log!("Created User"); - save_file( + try_save_file( "", &format!("{}.tu", username), &format!( @@ -91,9 +145,91 @@ pub async fn create_user( omega_discovery::omega_host(), keyring_b64 ), - ); + ) + .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?; - add_user(user_profile.clone()); - save_users(); - (Some(user_profile), Some(keyring_b64)) + try_add_user(user_profile.clone()) + .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?; + Ok(user_profile) +} + +#[cfg(test)] +mod tests { + use super::{CreateUserError, request_user_id, valid_username}; + use crate::{OmikronClient, OmikronError}; + use async_trait::async_trait; + use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; + use std::time::Duration; + + struct RegistrationClient { + response: CommunicationValue, + } + + #[async_trait] + impl OmikronClient for RegistrationClient { + async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> { + unreachable!() + } + + async fn await_response( + &self, + request: &CommunicationValue, + _: Duration, + ) -> Result { + assert!(request.is_type(CommunicationType::GetRegister)); + Ok(self.response.clone().with_id(request.get_id())) + } + + async fn reconnect(&self) -> Result<(), OmikronError> { + unreachable!() + } + + async fn rotate_identity(&self) -> Result<(), OmikronError> { + unreachable!() + } + + async fn is_connected(&self) -> bool { + true + } + } + + #[test] + fn validates_usernames_before_remote_registration() { + assert!(valid_username("alice")); + assert!(valid_username("fifteen_char_ok")); + assert!(!valid_username("")); + assert!(!valid_username("sixteen_chars_bad")); + assert!(!valid_username("path/name")); + assert!(!valid_username("line\nbreak")); + } + + #[tokio::test] + async fn uses_user_id_allocated_by_omega() { + let client = RegistrationClient { + response: CommunicationValue::new(CommunicationType::GetRegister) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(4_294_967_311)) + .add_typed_default( + DataType::RegisterId, + DataValue::Str("00000000-0000-4000-8000-000000000001".into()), + ), + }; + + assert_eq!( + request_user_id(&client).await.unwrap(), + (4_294_967_311, "00000000-0000-4000-8000-000000000001".into()) + ); + } + + #[tokio::test] + async fn rejects_registration_response_without_a_positive_user_id() { + let client = RegistrationClient { + response: CommunicationValue::new(CommunicationType::GetRegister) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(0)), + }; + + assert!(matches!( + request_user_id(&client).await, + Err(CreateUserError::InvalidResponse) + )); + } } diff --git a/type-maps.yaml b/type-maps.yaml deleted file mode 100644 index 53684cc..0000000 --- a/type-maps.yaml +++ /dev/null @@ -1,278 +0,0 @@ -protocol_version: "1.0" - -# Note that markers 0 to 31 are reserved for default use, manually working with them is not recommended - -# Fixed CommunicationType markers are: -# Error: 0 -# ErrorParsing: 1 -# ErrorBadVersion: 2 -# Disconnect: 3 -# Redirect: 4 -# Shutdown: 5 -# BadRequest: 6 -# Unauthorized: 7 -# Forbidden: 8 -# NotFound: 9 -# TooManyRequests: 10 -# InternalServerError: 11 -# BadGateway: 12 -# ServiceUnavailable: 13 -# GatewayTimeout: 14 -# Identification: 15 -# IdentificationResponse: 16 -# Register: 17 -# RegisterResponse: 18 -# Ping: 19 -# Pong: 20 - -# Fixed Data Type markers are: -# Error: 0 -# ErrorParsing: 1 -# ErrorMessage: 2 -# Version: 3 -# Description: 4 -# Timestamp: 5 -# Id: 6 -# ClientNonce: 7 -# ServerNonce: 8 -# PublicKeys: 9 -# Signature: 10 -# Connected: 11 - -type_maps: - "1.0": - CommunicationTypes: - ErrorProtocol: 33 - ErrorAnonymous: 34 - ErrorInternal: 35 - ErrorInvalidData: 36 - ErrorInvalidUserId: 37 - ErrorInvalidOmikronId: 38 - ErrorNotFound: 39 - ErrorNotAuthenticated: 40 - ErrorNoIota: 41 - ErrorInvalidChallenge: 42 - ErrorInvalidSecret: 43 - ErrorInvalidPrivateKey: 44 - ErrorInvalidPublicKey: 45 - ErrorNoUserId: 46 - ErrorNoCallId: 47 - ErrorInvalidCallId: 48 - Success: 49 - ShortenLink: 50 - SettingsSave: 51 - SettingsLoad: 52 - SettingsList: 53 - GlobalSettingsSave: 54 - GlobalSettingsLoad: 55 - Message: 56 - MessageState: 57 - MessageSend: 58 - MessageLive: 59 - MessageOtherIota: 60 - MessageChunk: 61 - MessageGet: 143 - MessagesGet: 62 - PushNotification: 63 - ReadNotification: 64 - GetNotifications: 65 - TauriIdentification: 66 - ChangeConfirm: 67 - ConfirmReceive: 68 - ConfirmRead: 69 - GetChats: 70 - GetStates: 71 - AddCommunity: 72 - RemoveCommunity: 73 - GetCommunities: 74 - RegisterIota: 81 - RegisterIotaSuccess: 82 - AddConversation: 85 - SendChat: 86 - ClientChanged: 87 - ClientConnected: 88 - ClientDisconnected: 89 - ClientClosed: 90 - PublicKey: 91 - PrivateKey: 92 - WebrtcSdp: 93 - WebrtcIce: 94 - StartStream: 95 - EndStream: 96 - WatchStream: 97 - CallToken: 98 - CallInvite: 99 - CallDisconnectUser: 100 - CallTimeoutUser: 101 - CallSetAnonymousJoining: 102 - CallData: 103 - EndCall: 104 - Function: 105 - Update: 106 - CreateUser: 107 - RhoUpdate: 108 - UserConnected: 109 - UserDisconnected: 110 - IotaConnected: 111 - IotaDisconnected: 112 - SyncClientIotaStatus: 113 - GetUserData: 114 - GetIotaData: 115 - IotaUserData: 116 - ChangeUserData: 117 - ChangeIotaData: 118 - GetRegister: 119 - CompleteRegisterUser: 120 - CompleteRegisterIota: 121 - DeleteUser: 122 - DeleteIota: 123 - StartRegister: 124 - CompleteRegister: 125 - GetApp: 126 - CreateApp: 127 - DeleteApp: 128 - SaveAppData: 129 - LoadAppData: 130 - AppIdentification: 131 - AppChallenge: 132 - AppChallengeResponse: 133 - AppIdentificationResponse: 134 - LoadTxtRecord: 135 - ErrorNotSet: 136 - SetChatSecret: 139 - GetChatSecret: 140 - ChatSecretResponse: 141 - ChatSecretForward: 142 - MessageEditLive: 144 - MessageEdit: 145 - MessageReactionAdd: 146 - MessageReactionRemove: 147 - MessageReactionLive: 148 - MessageDeleteLive: 150 - ClientStateSync: 151 - ClientStateAck: 152 - StateSubscribe: 153 - DataTypes: - ErrorType: 32 - ErrorProtocol: 33 - AcceptedIds: 34 - Uuid: 35 - RegisterId: 36 - Link: 37 - Settings: 38 - SettingsName: 39 - ChatPartnerId: 40 - ChatPartnerName: 41 - IotaId: 42 - UserId: 43 - UserIds: 44 - IotaIds: 45 - UserState: 46 - UserStates: 47 - UserPings: 48 - CallState: 49 - ScreenShare: 50 - PrivateKeyHash: 51 - # Accepted: 52 now part of default MTP - AcceptedProfiles: 53 - DeniedProfiles: 54 - Content: 55 - Messages: 56 - Notifications: 57 - SendTime: 58 - GetTime: 59 - GetVariant: 60 - SharedSecretOwn: 61 - SharedSecretOther: 62 - SharedSecretSign: 63 - SharedSecret: 64 - CallId: 65 - CallToken: 66 - CallSecret: 67 - Untill: 68 - Enabled: 69 - StartDate: 70 - EndDate: 71 - ReceiverId: 72 - SenderId: 73 - Signed: 75 - Message: 76 - MessageState: 77 - LastPing: 78 - PingIota: 79 - PingClients: 80 - Matches: 81 - Omikron: 82 - Offset: 83 - Amount: 84 - Position: 85 - Name: 86 - Path: 87 - Codec: 88 - Function: 89 - Payload: 90 - Result: 91 - Interactables: 92 - WantToWatch: 93 - Watcher: 94 - CreatedAt: 95 - Username: 96 - Display: 97 - Avatar: 98 - About: 99 - Status: 100 - PublicKey: 101 - SubLevel: 102 - SubEnd: 103 - CommunityAddress: 104 - CommunityTitle: 106 - Communities: 107 - RhoConnections: 108 - User: 109 - OnlineStatus: 110 - OmikronId: 111 - OmikronConnections: 112 - ResetToken: 113 - NewToken: 114 - CallInvited: 115 - CallMembers: 116 - Calls: 117 - Timeout: 118 - HasAdmin: 119 - LastMessageAt: 120 - Height: 121 - SentBySelf: 122 - SessionId: 123 - Contacts: 124 - LastMessage: 125 - AppIdentifier: 127 - AppPrivateKey: 128 - AppPublicKey: 129 - AppSession: 130 - AppData: 131 - TauriToken: 132 - Challenge: 133 - EncryptedPayload: 134 - SecurePayload: 135 - DeviceId: 136 - ClientId: 137 - SecretId: 142 - VersionNumber: 143 - EncryptedSecret: 144 - WrappingScheme: 146 - UpdatedAt: 147 - ChatId: 148 - KemCiphertext: 149 - SenderUserId: 152 - RecipientUserId: 153 - Recipients: 154 - Edited: 155 - Reactions: 156 - Reaction: 157 - ReplyId: 158 - CacheValid: 159 - CacheSchemaVersion: 160 - SyncMode: 161 - MessageId: 162 - DeletedMessageIds: 163 - DeletedContactIds: 164