diff --git a/Cargo.lock b/Cargo.lock index c8e129d..1003ddf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2250,6 +2250,7 @@ dependencies = [ "iota-paths", "iota-process-manager", "iota-terms", + "iota-util", "serde_json", "serde_yaml", "tokio", diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs index ad0796f..ca912fc 100644 --- a/iota-cli/src/ipc_client.rs +++ b/iota-cli/src/ipc_client.rs @@ -483,6 +483,9 @@ impl IpcClient { ResponsePayload::UserRemoved { user_id } => { format!("Removed user {}", user_id) } + ResponsePayload::UserDataPurged { user_id } => { + format!("Purged hosted data for {}", user_id) + } ResponsePayload::Acknowledged { message } => message.clone(), ResponsePayload::DaemonStatus(status) => status.formatted.clone(), ResponsePayload::Config(config) => config.yaml.clone(), diff --git a/iota-cli/src/screens/users.rs b/iota-cli/src/screens/users.rs index fab710c..93d4b13 100644 --- a/iota-cli/src/screens/users.rs +++ b/iota-cli/src/screens/users.rs @@ -27,6 +27,9 @@ use std::{ pub struct UserEntry { pub user_id: i64, pub username: String, + pub state: iota_ipc::LocalUserState, + pub data_present: bool, + pub credential_present: bool, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -146,7 +149,9 @@ impl UsersScreen { let user = &self.users[*user_index]; ( *user_index, - format!("{:>6} {}", user.user_id, user.username), + format!("{:>6} {} {}{}", user.user_id, user.username, + match user.state { iota_ipc::LocalUserState::Managed => "managed", iota_ipc::LocalUserState::Released => "released" }, + if user.data_present { "" } else { ", data purged" }), ) }) .collect(); @@ -211,7 +216,7 @@ impl UsersScreen { f, buttons_area[2], ActionButton { - label: "Remove", + label: "Release", intent: ButtonIntent::Destructive, focused: self.focus == Focus::RemoveButton, enabled: !self.loading && !self.pending && !self.users.is_empty(), @@ -235,7 +240,7 @@ impl UsersScreen { return InteractionResult::AppTask { task: Box::pin(async move { let result = match ipc.send_request(iota_ipc::LocalRequest::CreateUser { username: name }).await { - Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { user_id, username })) => Ok(UserEntry { user_id, username }), + Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { user_id, username })) => Ok(UserEntry { user_id, username, state: iota_ipc::LocalUserState::Managed, data_present: true, credential_present: true }), Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot create user: {error}")), Ok(_) => Err("Daemon returned an unexpected response while creating the user.".into()), Err(error) => Err(format!("Cannot create user: {error}")), @@ -249,14 +254,14 @@ impl UsersScreen { let ipc = self.ipc.clone(); let id = user.user_id; self.pending = true; - self.message = Some(format!("Removing {}…", user.username)); + self.message = Some(format!("Releasing {}…", user.username)); return InteractionResult::AppTask { task: Box::pin(async move { - let result = match ipc.send_request(iota_ipc::LocalRequest::RemoveUser { user_id: id }).await { - Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserRemoved { .. })) => Ok(()), - Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot remove user: {error}")), - Ok(_) => Err("Daemon returned an unexpected response while removing the user.".into()), - Err(error) => Err(format!("Cannot remove user: {error}")), + let result = match ipc.send_request(iota_ipc::LocalRequest::ReleaseUser { user_id: id }).await { + Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Acknowledged { .. })) => Ok(()), + Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot release user: {error}")), + Ok(_) => Err("Daemon returned an unexpected response while releasing the user.".into()), + Err(error) => Err(format!("Cannot release user: {error}")), }; UiEvent::App(AppEvent::UserRemoved { user_id: id, @@ -528,10 +533,11 @@ impl Screen for UsersScreen { match result { Ok(()) => { self.pending_dialog = None; - self.users.retain(|user| user.user_id != user_id); - self.focused_index = - self.focused_index.min(self.users.len().saturating_sub(1)); - self.message = Some(format!("Removed user {user_id}.")); + if let Some(user) = self.users.iter_mut().find(|user| user.user_id == user_id) { + user.state = iota_ipc::LocalUserState::Released; + user.credential_present = false; + } + self.message = Some(format!("Released user {user_id}; hosted data retained.")); } Err(error) => { self.dialog = self.pending_dialog.take(); diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index b06dab1..dad4c80 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -602,6 +602,9 @@ impl UI { .map(|u| UserEntry { user_id: u.user_id, username: u.username, + state: u.state, + data_present: u.data_present, + credential_present: u.credential_present, }) .collect()) } diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index 58e627d..74f5f96 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -9,8 +9,9 @@ use iota_ipc::{ use iota_logger::{log, log_command}; use iota_storage::users::user_manager; use iota_storage::util::config_util::{self}; -use mtp::codec::{CommunicationType, CommunicationValue}; +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use std::sync::{Arc, Mutex}; +use std::time::Duration; use crate::daemon_state::{ShutdownReason, StartupPhase}; @@ -51,7 +52,10 @@ impl CommandRouter { } let needs_omikron = matches!( request, - LocalRequest::CreateUser { .. } | LocalRequest::RemoveUser { .. } + LocalRequest::CreateUser { .. } + | LocalRequest::AttachUserFromTu { .. } + | LocalRequest::ReleaseUser { .. } + | LocalRequest::CompleteDeleteUser { .. } ); if needs_omikron && !self.services.omikron.is_connected().await { return ResponseResult::Error( @@ -92,11 +96,15 @@ impl CommandRouter { ResponseResult::Ok(ResponsePayload::Tasks(tasks)) } LocalRequest::ListUsers => { - let users: Vec = user_manager::get_users() + let users: Vec = user_manager::get_residency() .into_iter() .map(|user| UserSummary { + credential_present: user.state == user_manager::LocalUserState::Managed + && user_manager::get_user(user.user_id).is_some_and(|profile| iota_util::file_util::read_user_credential_with_legacy(user.user_id, &profile.username).ok().flatten().is_some()), user_id: user.user_id, username: user.username, + state: match user.state { user_manager::LocalUserState::Managed => iota_ipc::LocalUserState::Managed, user_manager::LocalUserState::Released => iota_ipc::LocalUserState::Released }, + data_present: user.data_present, }) .collect(); ResponseResult::Ok(ResponsePayload::Users(users)) @@ -137,18 +145,61 @@ impl CommandRouter { } } } - LocalRequest::RemoveUser { user_id } => { - let user = match user_manager::get_user(user_id) { - Some(user) => user, - None => return ResponseResult::Error(IpcErrorCode::NotFound), + LocalRequest::PurgeUserData { user_id } => match user_manager::purge_user_data(user_id) { + Ok(()) => ResponseResult::Ok(ResponsePayload::UserDataPurged { user_id }), + Err(error) => { + log!("User data purge failed for {user_id}: {error}"); + ResponseResult::Error(IpcErrorCode::StorageFailure) + } + }, + LocalRequest::AttachUserFromTu { credential } => { + match omikron_connector::user_ops::attach_user_from_tu(self.services.omikron.as_ref(), &credential.0).await { + Ok(user) => ResponseResult::Ok(ResponsePayload::Acknowledged { message: format!("Added {} ({}) to this Iota", user.username, user.user_id) }), + Err(error) => { + log!("Credential attach failed: {error:?}"); + ResponseResult::Error(IpcErrorCode::Unauthorized) + } + } + } + LocalRequest::CompleteDeleteUser { user_id, credential } => { + let contents = match credential { + Some(value) => Ok(value.0), + None => user_manager::get_user(user_id) + .ok_or(()) + .and_then(|user| iota_util::file_util::read_user_credential_with_legacy(user_id, &user.username).map_err(|_| ())) + .and_then(|value| value.ok_or(())), }; - let message = CommunicationValue::new(CommunicationType::DeleteUser) - .with_sender(user.user_id as u64); - if let Err(_e) = self.services.omikron.send_message(&message).await { - return ResponseResult::Error(IpcErrorCode::OmikronUnavailable); + let Ok(contents) = contents else { return ResponseResult::Error(IpcErrorCode::Unauthorized); }; + match omikron_connector::user_ops::complete_delete_user_with_tu(self.services.omikron.as_ref(), &contents, user_id).await { + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { message: format!("Deleted Tensamin account {user_id}") }), + Err(error) => { + log!("Credential deletion failed for {user_id}: {error:?}"); + ResponseResult::Error(IpcErrorCode::Unauthorized) + } + } + } + LocalRequest::RemoveUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest), + LocalRequest::ReleaseUser { user_id } => { + if user_manager::get_user(user_id).is_none() { + return ResponseResult::Error(IpcErrorCode::NotFound); + } + let request = CommunicationValue::new(CommunicationType::ReleaseUserFromIota) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into())); + match self.services.omikron.await_response(&request, Duration::from_secs(20)).await { + Ok(response) if response.is_type(CommunicationType::Success) => match user_manager::release_user(user_id) { + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: format!("Released user {user_id}; hosted data was retained"), + }), + Err(error) => { + log!("Remote release succeeded but local cleanup failed for {user_id}: {error}"); + ResponseResult::Error(IpcErrorCode::StorageFailure) + } + }, + Ok(response) if response.is_type(CommunicationType::ErrorNotAuthenticated) => ResponseResult::Error(IpcErrorCode::Unauthorized), + Ok(_) => ResponseResult::Error(IpcErrorCode::Conflict), + Err(omikron_connector::OmikronError::Timeout(_)) => ResponseResult::Error(IpcErrorCode::Timeout), + Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), } - user_manager::remove_user(user.user_id); - ResponseResult::Ok(ResponsePayload::UserRemoved { user_id }) } LocalRequest::ReconnectOmikron => match self.services.omikron.reconnect().await { Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { @@ -242,23 +293,22 @@ impl CommandRouter { ResponseResult::Ok(ResponsePayload::Components(components)) } LocalRequest::GetUser { user_id } => match user_manager::get_user(user_id) { - Some(user) => ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse { + Some(user) => { + let credential_present = iota_util::file_util::read_user_credential_with_legacy(user_id, &user.username).ok().flatten().is_some(); + ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse { user_id: user.user_id, username: user.username, display_name: user.display_name, created_at: user.created_at, trusted_apps: user.trusted_apps.keys().cloned().collect(), - })), + state: iota_ipc::LocalUserState::Managed, + data_present: user_manager::get_residency().iter().find(|entry| entry.user_id == user_id).is_none_or(|entry| entry.data_present), + credential_present, + })) + }, None => ResponseResult::Error(IpcErrorCode::NotFound), }, - LocalRequest::ImportUser { username } => { - match user_manager::load_from_tu(&username).await { - Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: format!("Imported user {username}"), - }), - Err(()) => ResponseResult::Error(IpcErrorCode::StorageFailure), - } - } + LocalRequest::ImportUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest), LocalRequest::GetLogs { limit } => { let entries = if let Ok(buf) = self.log_buffer.lock() { buf.recent(limit) diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs index 47a0a51..76eeaf0 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -156,6 +156,7 @@ async fn main() -> ExitCode { } }; let omikron_health = omikron.clone(); + let omikron_reconcile = omikron.clone(); let services = DaemonServices::new(omikron); let health_runtime = runtime.clone(); runtime @@ -209,6 +210,22 @@ async fn main() -> ExitCode { Ok(()) }) .await; + runtime + .tasks + .spawn_tracked("user-lifecycle-reconciliation", async move { + let mut states = omikron_reconcile.connection_state(); + loop { + if matches!(*states.borrow(), omikron_connector::omikron_connection::ConnectionState::Connected { .. }) { + omikron_connector::user_ops::reconcile_managed_users(omikron_reconcile.as_ref()).await; + } + tokio::select! { + changed = states.changed() => if changed.is_err() { break }, + _ = tokio::time::sleep(Duration::from_secs(30)) => {}, + } + } + Ok(()) + }) + .await; let ipc_server = match IpcServer::bind( socket.clone(), runtime.clone(), diff --git a/iota-ipc/src/lib.rs b/iota-ipc/src/lib.rs index 0ff6284..bd3d102 100644 --- a/iota-ipc/src/lib.rs +++ b/iota-ipc/src/lib.rs @@ -8,7 +8,7 @@ pub use protocol::{ ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest, LogEntriesResponse, LogEntry, MetricSample, OmikronStatusResponse, RequestEnvelope, ResponseEnvelope, ResponsePayload, ResponseResult, StartupPhase, StateSnapshot, StatusResponse, - SupervisorKind, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, + SecretString, SupervisorKind, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, LocalUserState, }; pub use transport::{read_msg, write_msg}; diff --git a/iota-ipc/src/protocol.rs b/iota-ipc/src/protocol.rs index d31bee4..2e2be1a 100644 --- a/iota-ipc/src/protocol.rs +++ b/iota-ipc/src/protocol.rs @@ -1,5 +1,17 @@ use serde::{Deserialize, Serialize}; +/// IPC credentials are supplied by the interactive CLI, never a daemon-side +/// path lookup. Debug is deliberately redacted because command routing logs +/// the request value. +#[derive(Clone, Deserialize, Serialize)] +pub struct SecretString(pub String); + +impl std::fmt::Debug for SecretString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("") + } +} + // --------------------------------------------------------------------------- // Client → Daemon // --------------------------------------------------------------------------- @@ -36,6 +48,21 @@ pub enum LocalRequest { CreateUser { username: String, }, + AttachUserFromTu { + credential: SecretString, + }, + PurgeUserData { + user_id: i64, + }, + ReleaseUser { + user_id: i64, + }, + CompleteDeleteUser { + user_id: i64, + credential: Option, + }, + /// Retained only to return an actionable deprecation error to old IPC + /// clients. It must never select lifecycle semantics implicitly. RemoveUser { user_id: i64, }, @@ -137,7 +164,9 @@ pub enum ResponsePayload { Tasks(Vec), Users(Vec), UserCreated { user_id: i64, username: String }, + /// Retained only for wire compatibility. New lifecycle code never emits it. UserRemoved { user_id: i64 }, + UserDataPurged { user_id: i64 }, Acknowledged { message: String }, DaemonStatus(DaemonStatusResponse), Config(ConfigResponse), @@ -174,6 +203,9 @@ pub struct UserDetailResponse { pub display_name: Option, pub created_at: i64, pub trusted_apps: Vec, + pub state: LocalUserState, + pub data_present: bool, + pub credential_present: bool, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -208,6 +240,16 @@ pub struct TaskSummary { pub struct UserSummary { pub user_id: i64, pub username: String, + pub state: LocalUserState, + pub data_present: bool, + pub credential_present: bool, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LocalUserState { + Managed, + Released, } #[derive(Clone, Debug, Deserialize, Serialize)] diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index af842e5..ccd3c9e 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -1,10 +1,26 @@ use crate::users::user_profile::UserProfile; use crate::util::db; -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use iota_util::crypto_helper::{self, hex_hash, keyring_from_base64, public_key_bundle_to_base64}; -use iota_util::file_util::{load_file, save_file}; -use rand_core::{OsRng, RngCore}; +use iota_util::file_util::{delete_user_directory, load_file, remove_user_credential, save_file}; use rusqlite::params; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LocalUserState { + Managed, + Released, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UserResidency { + pub user_id: i64, + pub username: String, + pub state: LocalUserState, + pub data_present: bool, +} + +fn now_millis() -> i64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as i64 +} pub fn add_user(user: UserProfile) { if let Err(e) = try_add_user(user) { @@ -45,6 +61,12 @@ pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::Stora params![user.user_id, app_id, app_secret], )?; } + conn.execute( + r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, updated_at) + VALUES (?1, ?2, 'managed', COALESCE((SELECT data_state FROM user_residency WHERE user_id = ?1), 'present'), ?3) + ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, lifecycle_state = 'managed', updated_at = excluded.updated_at"#, + params![user.user_id, user.username, now_millis()], + )?; Ok(()) }) } @@ -205,42 +227,92 @@ pub fn remove_user(user_id: i64) { } } +/// Remove only local management authority. Hosted content is intentionally +/// retained and is indexed as released for a later purge operation. +pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageError> { + let username = get_user(user_id).map(|user| user.username).ok_or_else(|| { + crate::storage_error::StorageError::Other("managed user was not found".into()) + })?; + db::with_db(|conn| { + let tx = conn.unchecked_transaction()?; + tx.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?; + tx.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?; + tx.execute( + r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, updated_at) + VALUES (?1, ?2, 'released', COALESCE((SELECT data_state FROM user_residency WHERE user_id = ?1), 'present'), ?3) + ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, lifecycle_state = 'released', updated_at = excluded.updated_at"#, + params![user_id, username, now_millis()], + )?; + tx.commit()?; + Ok(()) + })?; + remove_user_credential(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) +} + +/// Authoritative hosted-data erasure used by local purge and future Omega +/// erasure delivery. Management metadata and credentials are left intact. +pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::StorageError> { + db::with_db(|conn| { + let tx = conn.unchecked_transaction()?; + tx.execute("DELETE FROM message_edits WHERE message_id IN (SELECT id FROM messages WHERE storage_owner = ?1)", params![user_id])?; + tx.execute("DELETE FROM reactions WHERE message_id IN (SELECT id FROM messages WHERE storage_owner = ?1)", params![user_id])?; + tx.execute("DELETE FROM messages WHERE storage_owner = ?1", params![user_id])?; + tx.execute("DELETE FROM contacts WHERE storage_owner = ?1", params![user_id])?; + tx.execute("DELETE FROM communities WHERE storage_owner = ?1", params![user_id])?; + tx.execute("DELETE FROM settings WHERE user_id = ?1", params![user_id])?; + tx.execute("DELETE FROM sync_events WHERE user_id = ?1", params![user_id])?; + tx.execute("DELETE FROM sync_heads WHERE user_id = ?1", params![user_id])?; + tx.execute("DELETE FROM client_sync_state WHERE user_id = ?1", params![user_id])?; + tx.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?; + tx.execute( + "UPDATE user_residency SET data_state = 'empty', updated_at = ?2 WHERE user_id = ?1", + params![user_id, now_millis()], + )?; + tx.commit()?; + Ok(()) + })?; + crate::util::e2ee_storage::purge_user(user_id) + .map_err(crate::storage_error::StorageError::Other)?; + delete_user_directory(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) +} + +/// Complete local erasure is idempotent and is the target for a durable +/// Omega-hosted erasure request after account deletion. +pub fn erase_user_locally(user_id: i64) -> Result<(), crate::storage_error::StorageError> { + purge_user_data(user_id)?; + db::with_db(|conn| { + conn.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?; + conn.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?; + conn.execute("DELETE FROM user_residency WHERE user_id = ?1", params![user_id])?; + Ok(()) + })?; + remove_user_credential(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) +} + +pub fn get_residency() -> Vec { + db::with_db(|conn| { + let mut stmt = conn.prepare("SELECT user_id, username, lifecycle_state, data_state FROM user_residency ORDER BY username")?; + let rows = stmt.query_map([], |row| { + let lifecycle: String = row.get(2)?; + Ok(UserResidency { + user_id: row.get(0)?, username: row.get(1)?, + state: if lifecycle == "managed" { LocalUserState::Managed } else { LocalUserState::Released }, + data_present: row.get::<_, String>(3)? == "present", + }) + })?; + rows.collect::, _>>().map_err(Into::into) + }).unwrap_or_default() +} + pub fn clear() { if let Err(e) = db::with_db(|conn| { - conn.execute_batch("DELETE FROM trusted_apps; DELETE FROM users;")?; + conn.execute_batch("DELETE FROM trusted_apps; DELETE FROM users; DELETE FROM user_residency;")?; Ok(()) }) { eprintln!("Failed to clear users: {}", e); } } -#[allow(dead_code)] -pub async fn load_from_tu(username: &str) -> Result<(), ()> { - let file_content = load_file("", &format!("{}.tu", username)); - let segments = file_content.split("::").collect::>(); - let (uuid_str, _omega_host) = segments[0].split_once('@').unwrap_or((segments[0], "")); - let uuid = uuid_str.parse::().unwrap_or(0); - let b64_private_key = segments[1]; - - let keyring = keyring_from_base64(b64_private_key).unwrap(); - let pub_key_bundle = keyring.public_key_bundle(); - let keyring_b64 = crypto_helper::keyring_to_base64(&keyring); - - let mut bytes = [0u8; 192]; - OsRng.fill_bytes(&mut bytes); - let reset_token = STANDARD.encode(&bytes); - - let user_profile = UserProfile::new( - uuid, - username.to_string(), - Some(username.to_string()), - public_key_bundle_to_base64(&pub_key_bundle), - hex_hash(&keyring_b64), - reset_token, - ); - add_user(user_profile); - Ok(()) -} pub fn save_users() { // No-op: users are auto-saved via SQLite. diff --git a/iota-storage/src/users/user_profile.rs b/iota-storage/src/users/user_profile.rs index 2ecb9cc..5f2abd5 100644 --- a/iota-storage/src/users/user_profile.rs +++ b/iota-storage/src/users/user_profile.rs @@ -1,7 +1,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use base64::{Engine as _, engine::general_purpose}; -use iota_util::file_util::{has_file, load_file, used_dir_space}; +use iota_util::file_util::{read_user_credential_with_legacy, used_dir_space}; use json::{JsonValue, object}; use rand::Rng; use rand::rngs::OsRng; @@ -55,9 +55,11 @@ impl UserProfile { if let Some(d) = &self.display_name { obj["display_name"] = d.clone().into(); } - if has_file("", &format!("{}.tu", self.username.clone())) { - obj["tu"] = load_file("", &format!("{}.tu", self.username.clone())).into(); - } + // Frontend consumers must never receive private credential material. + obj["has_tu"] = read_user_credential_with_legacy(self.user_id, &self.username) + .map(|credential| credential.is_some()) + .unwrap_or(false) + .into(); obj } diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index 1a4ca7e..82968e7 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -251,6 +251,21 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> { )?; } + if current_version < 7 { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS user_residency ( + user_id INTEGER PRIMARY KEY, + username TEXT NOT NULL, + lifecycle_state TEXT NOT NULL CHECK (lifecycle_state IN ('managed', 'released')), + data_state TEXT NOT NULL CHECK (data_state IN ('present', 'empty')), + updated_at INTEGER NOT NULL + ); + PRAGMA user_version = 7; + "#, + )?; + } + Ok(()) } @@ -320,7 +335,7 @@ mod tests { run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 6); + assert_eq!(version, 7); for column in ["height", "reply_to", "edited_count", "deleted_by_external"] { let mut statement = conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; @@ -337,8 +352,8 @@ mod tests { run_migrations_on_connection(&conn)?; run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 6); - for table in ["sync_heads", "sync_events", "client_sync_state"] { + assert_eq!(version, 7); + for table in ["sync_heads", "sync_events", "client_sync_state", "user_residency"] { let exists: i64 = conn.query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", [table], diff --git a/iota-storage/src/util/e2ee_storage.rs b/iota-storage/src/util/e2ee_storage.rs index 9d628d4..6b13018 100644 --- a/iota-storage/src/util/e2ee_storage.rs +++ b/iota-storage/src/util/e2ee_storage.rs @@ -188,6 +188,23 @@ pub fn delete_pending_chat_secret_forward( }) } +/// Erase every E2EE record owned by, or queued for, a user. The operation is +/// intentionally idempotent so it can be retried after an interrupted remote +/// erasure request. +pub fn purge_user(user_id: i64) -> Result<(), StorageError> { + let user_id = user_id.to_string(); + db::with_conn(&E2EE_DB, |conn| { + let tx = conn.unchecked_transaction()?; + tx.execute("DELETE FROM chat_secrets WHERE user_id = ?1", params![user_id])?; + tx.execute( + "DELETE FROM pending_chat_secret_forwards WHERE recipient_user_id = ?1 OR sender_user_id = ?1", + params![user_id], + )?; + tx.commit()?; + Ok(()) + }) +} + pub fn get_chat_secret(query: ChatSecretQuery) -> Result, StorageError> { if query.user_id.is_empty() || query.chat_id.is_empty() { return Ok(None); diff --git a/iota-util/src/file_util.rs b/iota-util/src/file_util.rs index 1e1cad4..8412b10 100755 --- a/iota-util/src/file_util.rs +++ b/iota-util/src/file_util.rs @@ -33,11 +33,70 @@ fn delete_dir_recursive(directory: &Path) -> bool { } #[allow(dead_code)] -pub fn delete_user_directory(user_id: i64) { +pub fn delete_user_directory(user_id: i64) -> io::Result<()> { let user_dir = Path::new(&get_directory()) .join("users") .join(user_id.to_string()); - let _ = delete_dir_recursive(&user_dir); + if !user_dir.exists() { + return Ok(()); + } + fs::remove_dir_all(user_dir) +} + +pub fn credential_path(user_id: i64) -> PathBuf { + storage_directory().join("credentials").join(format!("{user_id}.tu")) +} + +pub fn read_user_credential(user_id: i64) -> io::Result> { + let path = credential_path(user_id); + match fs::read_to_string(path) { + Ok(value) => Ok(Some(value)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +/// Resolve a credential by immutable account id. A valid legacy +/// `.tu` is migrated atomically the first time it is encountered. +pub fn read_user_credential_with_legacy(user_id: i64, username: &str) -> io::Result> { + if let Some(credential) = read_user_credential(user_id)? { + return Ok(Some(credential)); + } + let legacy = storage_file("", format!("{username}.tu"))?; + let credential = match fs::read_to_string(&legacy) { + Ok(value) => value, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + let parsed = crate::tu::TuCredential::parse(&credential) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if parsed.user_id != user_id { + return Err(io::Error::new(io::ErrorKind::InvalidData, "legacy credential user id mismatch")); + } + write_user_credential(user_id, &parsed.to_canonical_string())?; + fs::remove_file(legacy)?; + Ok(Some(parsed.to_canonical_string())) +} + +pub fn write_user_credential(user_id: i64, credential: &str) -> io::Result<()> { + let path = credential_path(user_id); + let parent = path.parent().expect("credential path has parent"); + fs::create_dir_all(parent)?; + let temporary = parent.join(format!(".{user_id}.tu.tmp")); + fs::write(&temporary, credential)?; + if let Err(error) = fs::rename(&temporary, &path) { + let _ = fs::remove_file(&temporary); + return Err(error); + } + Ok(()) +} + +pub fn remove_user_credential(user_id: i64) -> io::Result<()> { + match fs::remove_file(credential_path(user_id)) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } } pub fn load_file_buf(path: &str, name: &str) -> io::Result> { diff --git a/iota-util/src/lib.rs b/iota-util/src/lib.rs index 2277b2b..5d32aef 100644 --- a/iota-util/src/lib.rs +++ b/iota-util/src/lib.rs @@ -1,3 +1,4 @@ pub mod crypto_helper; pub mod crypto_util; pub mod file_util; +pub mod tu; diff --git a/iota-util/src/tu.rs b/iota-util/src/tu.rs new file mode 100644 index 0000000..e1a8334 --- /dev/null +++ b/iota-util/src/tu.rs @@ -0,0 +1,95 @@ +//! Strict parsing and storage-independent handling of user credentials. +//! +//! A `.tu` file is deliberately identified by the account id embedded in its +//! contents. Its filename is presentation data owned by the CLI, never an +//! account authority. + +use crate::crypto_helper::{keyring_from_base64, keyring_to_base64}; +use mtp::crypto::{Keyring, PublicKeyBundle}; +use std::fmt; + +pub const MAX_PROTOCOL_ID: i64 = (1_i64 << 48) - 1; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TuError { + InvalidFormat, + InvalidUserId, + InvalidKeyring, +} + +impl fmt::Display for TuError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::InvalidFormat => "invalid .tu credential format", + Self::InvalidUserId => "invalid .tu user id", + Self::InvalidKeyring => "invalid .tu keyring", + }) + } +} + +impl std::error::Error for TuError {} + +pub struct TuCredential { + pub user_id: i64, + pub omega_host: String, + pub keyring: Keyring, +} + +impl fmt::Debug for TuCredential { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TuCredential") + .field("user_id", &self.user_id) + .field("omega_host", &self.omega_host) + .field("keyring", &"") + .finish() + } +} + +impl TuCredential { + pub fn parse(input: &str) -> Result { + let (identity, encoded_keyring) = input.trim().split_once("::").ok_or(TuError::InvalidFormat)?; + if encoded_keyring.is_empty() || encoded_keyring.contains("::") { + return Err(TuError::InvalidFormat); + } + let (user_id, omega_host) = identity.split_once('@').ok_or(TuError::InvalidFormat)?; + if omega_host.trim().is_empty() || omega_host.contains('@') { + return Err(TuError::InvalidFormat); + } + let user_id = user_id.parse::().map_err(|_| TuError::InvalidUserId)?; + if !(1..=MAX_PROTOCOL_ID).contains(&user_id) { + return Err(TuError::InvalidUserId); + } + let keyring = keyring_from_base64(encoded_keyring).ok_or(TuError::InvalidKeyring)?; + Ok(Self { user_id, omega_host: omega_host.trim().to_owned(), keyring }) + } + + pub fn public_key_bundle(&self) -> PublicKeyBundle { + self.keyring.public_key_bundle() + } + + pub fn to_canonical_string(&self) -> String { + format!("{}@{}::{}", self.user_id, self.omega_host, keyring_to_base64(&self.keyring)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto_helper::generate_keyring; + + #[test] + fn round_trip_is_canonical() { + let credential = TuCredential { user_id: 42, omega_host: "omega.example:443".into(), keyring: generate_keyring() }; + let parsed = TuCredential::parse(&credential.to_canonical_string()).unwrap(); + assert_eq!(parsed.user_id, 42); + assert_eq!(parsed.omega_host, "omega.example:443"); + assert_eq!(parsed.to_canonical_string(), credential.to_canonical_string()); + } + + #[test] + fn rejects_malformed_credentials() { + for value in ["", "1@omega", "@omega::abc", "0@omega::abc", "281474976710656@omega::abc", "1@::abc", "1@omega::abc::def"] { + assert!(TuCredential::parse(value).is_err(), "{value}"); + } + } +} diff --git a/iota/Cargo.toml b/iota/Cargo.toml index 3c38d65..b05cfe8 100644 --- a/iota/Cargo.toml +++ b/iota/Cargo.toml @@ -11,6 +11,7 @@ iota-core = { path = "../iota-core" } iota-process-manager = { path = "../iota-process-manager" } iota-paths = { path = "../iota-paths" } iota-terms = { path = "../iota-terms" } +iota-util = { path = "../iota-util" } tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } serde_json = "1" diff --git a/iota/src/cli_args.rs b/iota/src/cli_args.rs index 48c8ead..b1447a0 100644 --- a/iota/src/cli_args.rs +++ b/iota/src/cli_args.rs @@ -1,6 +1,7 @@ use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind}; use iota_cli::theme::{CliOutputFormat, ThemeName, UiConfig}; use iota_terms::TermsType; +use std::path::PathBuf; #[derive(Debug)] pub struct CliInvocation { @@ -115,15 +116,33 @@ enum UsersAction { user_id: i64, }, Add { - username: String, + username: Option, + #[arg(long, value_name = "PATH")] + tu: Option, }, - Remove { + Release { user_id: i64, #[arg(long)] yes: bool, }, - Import { - username: String, + Data { + #[command(subcommand)] + action: UserDataAction, + }, + CompleteDelete { + user_id: i64, + #[arg(long, value_name = "PATH")] + tu: Option, + #[arg(long)] + yes: bool, + }, +} +#[derive(Subcommand, Debug)] +enum UserDataAction { + Purge { + user_id: i64, + #[arg(long)] + yes: bool, }, } #[derive(Args, Debug)] @@ -264,14 +283,21 @@ pub enum Command { user_id: i64, }, UsersAdd { - username: String, + username: Option, + tu: Option, }, - UsersRemove { + UsersRelease { user_id: i64, confirmed: bool, }, - UsersImport { - username: String, + UsersPurgeData { + user_id: i64, + confirmed: bool, + }, + UsersCompleteDelete { + user_id: i64, + tu: Option, + confirmed: bool, }, OmikronReconnect, IdentityRotate { @@ -367,12 +393,29 @@ impl CliInvocation { Some(CliCommand::Users(users)) => match users.action { UsersAction::List => Command::UsersList, UsersAction::Show { user_id } => Command::UsersShow { user_id }, - UsersAction::Add { username } => Command::UsersAdd { username }, - UsersAction::Remove { user_id, yes } => Command::UsersRemove { + UsersAction::Add { username, tu } => { + if username.is_some() == tu.is_some() { + return Err( + "users add requires exactly one of or --tu ".into(), + ); + } + Command::UsersAdd { username, tu } + } + UsersAction::Release { user_id, yes } => Command::UsersRelease { user_id, confirmed: resolve_confirmed(yes), }, - UsersAction::Import { username } => Command::UsersImport { username }, + UsersAction::Data { + action: UserDataAction::Purge { user_id, yes }, + } => Command::UsersPurgeData { + user_id, + confirmed: resolve_confirmed(yes), + }, + UsersAction::CompleteDelete { user_id, tu, yes } => Command::UsersCompleteDelete { + user_id, + tu, + confirmed: resolve_confirmed(yes), + }, }, Some(CliCommand::Omikron(omikron)) => match omikron.action { OmikronAction::Reconnect => Command::OmikronReconnect, @@ -445,6 +488,7 @@ impl CliInvocation { } } + #[allow(unused)] pub fn help_text() -> String { Cli::command().render_long_help().to_string() } @@ -528,7 +572,7 @@ mod tests { #[test] fn command_schema_drives_help_and_completion_paths() { let paths = CliInvocation::command_paths(); - assert!(paths.contains(&"users remove".to_owned())); + assert!(paths.contains(&"users release".to_owned())); assert!(paths.contains(&"daemon install".to_owned())); let help = CliInvocation::help_text(); assert!(help.contains("users")); @@ -558,12 +602,7 @@ mod tests { #[test] fn parses_unconfirmed_destructive_commands_explicitly() { let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap(); - assert_eq!( - invocation.command, - Command::DaemonStop { - confirmed: true - } - ); + assert_eq!(invocation.command, Command::DaemonStop { confirmed: true }); } #[test] @@ -573,7 +612,8 @@ mod tests { assert_eq!( invocation.command, Command::UsersAdd { - username: "alice".into() + username: Some("alice".into()), + tu: None, } ); } @@ -597,26 +637,24 @@ mod tests { } #[test] - fn parses_users_remove_without_confirmation() { - let invocation = - CliInvocation::parse(["users".into(), "remove".into(), "42".into()]).unwrap(); - assert_eq!( - invocation.command, - Command::UsersRemove { - user_id: 42, - confirmed: true, - } - ); + fn rejects_ambiguous_users_remove() { + let error = + CliInvocation::parse(["users".into(), "remove".into(), "42".into()]).unwrap_err(); + assert!(error.contains("remove")); } #[test] - fn parses_users_remove_with_confirmation() { - let invocation = - CliInvocation::parse(["users".into(), "remove".into(), "42".into(), "--yes".into()]) - .unwrap(); + fn parses_users_release_with_confirmation() { + let invocation = CliInvocation::parse([ + "users".into(), + "release".into(), + "42".into(), + "--yes".into(), + ]) + .unwrap(); assert_eq!( invocation.command, - Command::UsersRemove { + Command::UsersRelease { user_id: 42, confirmed: true, } diff --git a/iota/src/main.rs b/iota/src/main.rs index 192f1c8..e05b63f 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -403,7 +403,9 @@ fn print_help() { println!(" users list List all users"); println!(" users show Show user details"); println!(" users add Create a new user"); - println!(" users remove Remove a user (requires --yes)"); + println!(" users add --tu Add an existing account credential"); + println!(" users data purge Purge hosted data (requires --yes)"); + println!(" users release Release this Iota (requires --yes)"); println!(" omikron status Show Omikron connection status"); println!(" omikron reconnect Reconnect to Omikron"); println!(" identity rotate Rotate identity keys (requires --yes)"); @@ -451,7 +453,7 @@ fn print_help() { println!(" iota status Show daemon status"); println!(" iota users list --output=json List users in JSON format"); println!(" iota users add alice Create a user named 'alice'"); - println!(" iota users remove 42 --yes Remove user 42"); + println!(" iota users data purge 42 --yes Purge hosted data"); println!(" iota config get --output=yaml Show config in YAML format"); println!(" iota logs --limit 50 Show last 50 log entries"); println!(" iota completions bash Generate bash completions"); @@ -564,12 +566,40 @@ async fn run_command( Command::Tasks => LocalRequest::ListTasks, Command::UsersList => LocalRequest::ListUsers, Command::UsersShow { user_id } => LocalRequest::GetUser { user_id }, - Command::UsersAdd { username } => LocalRequest::CreateUser { username }, - Command::UsersRemove { + Command::UsersAdd { username: Some(username), tu: None } => LocalRequest::CreateUser { username }, + Command::UsersAdd { username: None, tu: Some(path) } => { + let contents = std::fs::read_to_string(&path) + .map_err(|error| StartupError::InvalidCommand(format!("Cannot read {}: {error}", path.display())))?; + iota_util::tu::TuCredential::parse(&contents) + .map_err(|error| StartupError::InvalidCommand(format!("Invalid credential {}: {error}", path.display())))?; + LocalRequest::AttachUserFromTu { credential: iota_ipc::SecretString(contents) } + } + Command::UsersAdd { .. } => { + return Err(StartupError::InvalidCommand( + "users add requires exactly one of or --tu ".into(), + )); + } + Command::UsersRelease { user_id, confirmed: true, - } => LocalRequest::RemoveUser { user_id }, - Command::UsersImport { username } => LocalRequest::ImportUser { username }, + } => LocalRequest::ReleaseUser { user_id }, + Command::UsersPurgeData { user_id, confirmed: true } => LocalRequest::PurgeUserData { user_id }, + Command::UsersCompleteDelete { user_id, tu, confirmed: true } => { + let credential = match tu { + Some(path) => { + let contents = std::fs::read_to_string(&path) + .map_err(|error| StartupError::InvalidCommand(format!("Cannot read {}: {error}", path.display())))?; + let parsed = iota_util::tu::TuCredential::parse(&contents) + .map_err(|error| StartupError::InvalidCommand(format!("Invalid credential {}: {error}", path.display())))?; + if parsed.user_id != user_id { + return Err(StartupError::InvalidCommand("credential user ID does not match complete-delete target".into())); + } + Some(iota_ipc::SecretString(contents)) + } + None => None, + }; + LocalRequest::CompleteDeleteUser { user_id, credential } + } Command::OmikronReconnect => LocalRequest::ReconnectOmikron, Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity, Command::RegenerateKeys { confirmed: true } => LocalRequest::RotateIotaIdentity, @@ -588,9 +618,11 @@ async fn run_command( Command::Logs { limit } => LocalRequest::GetLogs { limit }, Command::UpdateCheck => LocalRequest::CheckUpdate, Command::CommunityList => LocalRequest::ListCommunities, - Command::UsersRemove { + Command::UsersRelease { confirmed: false, .. } + | Command::UsersPurgeData { confirmed: false, .. } + | Command::UsersCompleteDelete { confirmed: false, .. } | Command::IdentityRotate { confirmed: false } | Command::RegenerateKeys { confirmed: false } | Command::DaemonRestart { confirmed: false } @@ -689,6 +721,9 @@ async fn run_command( user_id ); } + ResponsePayload::UserDataPurged { user_id } => { + println!("{} hosted data for {}. Account remains managed by this Iota.", cli_color::success(&color, "Purged"), user_id); + } ResponsePayload::Acknowledged { message } => { println!("{}", message); } @@ -935,6 +970,9 @@ fn render_table(payload: &ResponsePayload) { ResponsePayload::UserRemoved { user_id } => { println!("Removed user {}", user_id); } + ResponsePayload::UserDataPurged { user_id } => { + println!("Purged hosted data for {}", user_id); + } ResponsePayload::Acknowledged { message } => { println!("{}", message); } diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 81d6ae6..a555398 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -896,12 +896,41 @@ impl OmikronConnection { dispatch!(SettingsSave, handle_settings_save); dispatch!(SettingsLoad, handle_settings_load); dispatch!(SettingsList, handle_settings_list); + dispatch!(EraseHostedUserData, handle_erase_hosted_user_data); } // ------------------------------------------------------------------------- // Message Handlers // ------------------------------------------------------------------------- + /// Omega-authorized account cleanup. The storage operation is idempotent; + /// acknowledgement is therefore safe to retry after a reconnect. + async fn handle_erase_hosted_user_data(self: Arc, cv: &CommunicationValue) { + let Some(user_id) = cv + .get_data(DataType::UserId) + .as_signed_number() + .and_then(|id| i64::try_from(id).ok()) + .filter(|id| *id > 0) + else { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + .await; + return; + }; + + if iota_storage::users::user_manager::erase_user_locally(user_id).is_err() { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInternal)) + .await; + return; + } + + let acknowledgement = CommunicationValue::new(CommunicationType::EraseHostedUserDataAck) + .with_id(cv.get_id()) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into())); + let _ = self.send_message(&acknowledgement).await; + } + async fn handle_set_chat_secret(self: Arc, cv: &CommunicationValue) { let sender_id = cv.get_sender().to_string(); let recipients = match chat_secret_recipients(cv) { diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs index fde043f..241fd46 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -2,9 +2,12 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use iota_logger::{PrintType, log, log_cv, log_t}; use iota_storage::users::user_manager::try_add_user; use iota_storage::users::user_profile::UserProfile; +use iota_storage::util::config_util::CONFIG; use iota_util::crypto_helper::{self, hex_hash, public_key_bundle_to_base64}; -use iota_util::file_util::try_save_file; +use iota_util::file_util::write_user_credential; +use iota_util::tu::TuCredential; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use mtp::crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme}; use rand_core::{OsRng, RngCore}; use std::time::Duration; @@ -20,6 +23,133 @@ pub enum CreateUserError { LocalPersistence(String), } +#[derive(Debug)] +pub enum LifecycleUserError { + InvalidCredential(String), + OmegaHostMismatch, + RemoteRejected, + Transport(crate::OmikronError), + LocalPersistence(String), +} + +impl From for LifecycleUserError { + fn from(value: crate::OmikronError) -> Self { Self::Transport(value) } +} + +fn lifecycle_payload(domain: &[u8], user_id: i64, iota_id: i64, nonce: u64) -> Vec { + let mut payload = Vec::with_capacity(domain.len() + 24); + payload.extend_from_slice(domain); + payload.extend_from_slice(&user_id.to_be_bytes()); + payload.extend_from_slice(&iota_id.to_be_bytes()); + payload.extend_from_slice(&nonce.to_be_bytes()); + payload +} + +fn configured_iota_id() -> Result { + CONFIG.load().iota_id + .and_then(|id| i64::try_from(id).ok()) + .filter(|id| *id > 0) + .ok_or_else(|| LifecycleUserError::InvalidCredential("Iota identity is not registered".into())) +} + +fn sign_lifecycle_payload(credential: &TuCredential, payload: &[u8]) -> Result<(Vec, Vec), LifecycleUserError> { + let classical = Ed25519Signer::new(&credential.keyring.sig_cl_secret_key) + .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))? + .sign(payload) + .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; + let pq = MlDsaSigner::new(&credential.keyring.sig_pq_secret_key, &credential.keyring.sig_pq_public_key) + .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))? + .sign(payload) + .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; + Ok((classical, pq)) +} + +async fn inspect_credential_account( + connection: &dyn OmikronClient, + credential: &TuCredential, +) -> Result<(String, String), LifecycleUserError> { + if credential.omega_host != omega_discovery::omega_host() { + return Err(LifecycleUserError::OmegaHostMismatch); + } + let request = CommunicationValue::new(CommunicationType::GetUserData) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(credential.user_id.into())); + let response = connection.await_response(&request, Duration::from_secs(20)).await?; + if !response.is_type(CommunicationType::GetUserData) { + return Err(LifecycleUserError::RemoteRejected); + } + let username = response.get_data(DataType::Username).as_str().map(str::to_owned) + .ok_or(LifecycleUserError::RemoteRejected)?; + let public_key = response.get_data(DataType::PublicKey).as_str().map(str::to_owned) + .ok_or(LifecycleUserError::RemoteRejected)?; + if public_key != public_key_bundle_to_base64(&credential.public_key_bundle()) { + return Err(LifecycleUserError::RemoteRejected); + } + Ok((username, public_key)) +} + +async fn credential_proof( + connection: &dyn OmikronClient, + credential: &TuCredential, + begin: CommunicationType, + challenge: CommunicationType, + complete: CommunicationType, + domain: &[u8], +) -> Result<(), LifecycleUserError> { + let iota_id = configured_iota_id()?; + let begin_request = CommunicationValue::new(begin) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(credential.user_id.into())); + let challenge_response = connection.await_response(&begin_request, Duration::from_secs(20)).await?; + if !challenge_response.is_type(challenge) { + return Err(LifecycleUserError::RemoteRejected); + } + let nonce = challenge_response.get_data(DataType::ServerNonce).as_signed_number() + .and_then(|value| u64::try_from(value).ok()) + .ok_or(LifecycleUserError::RemoteRejected)?; + let (signature, pq_signature) = sign_lifecycle_payload(credential, &lifecycle_payload(domain, credential.user_id, iota_id, nonce))?; + let complete_request = CommunicationValue::new(complete) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(credential.user_id.into())) + .add_typed_default(DataType::ServerNonce, DataValue::SignedNumber(nonce.into())) + .add_typed_default(DataType::Signature, DataValue::Bytes(signature)) + .add_typed_default(DataType::PqSignature, DataValue::Bytes(pq_signature)); + let response = connection.await_response(&complete_request, Duration::from_secs(20)).await?; + if response.is_type(CommunicationType::Success) { Ok(()) } else { Err(LifecycleUserError::RemoteRejected) } +} + +/// Attach or migrate an existing account. Local state is written only after +/// Omega has accepted the credential proof and changed its assignment. +pub async fn attach_user_from_tu(connection: &dyn OmikronClient, contents: &str) -> Result { + let credential = TuCredential::parse(contents).map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; + let (username, public_key) = inspect_credential_account(connection, &credential).await?; + credential_proof(connection, &credential, CommunicationType::AttachUserBegin, CommunicationType::AttachUserChallenge, CommunicationType::AttachUserComplete, b"tensamin:user-attach:v1\0").await?; + let profile = UserProfile::new(credential.user_id, username, None, public_key, hex_hash(contents), String::new()); + write_user_credential(profile.user_id, &credential.to_canonical_string()) + .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; + try_add_user(profile.clone()).map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; + Ok(profile) +} + +pub async fn complete_delete_user_with_tu(connection: &dyn OmikronClient, contents: &str, expected_user_id: i64) -> Result<(), LifecycleUserError> { + let credential = TuCredential::parse(contents).map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; + if credential.user_id != expected_user_id { return Err(LifecycleUserError::InvalidCredential("credential user ID does not match deletion target".into())); } + inspect_credential_account(connection, &credential).await?; + credential_proof(connection, &credential, CommunicationType::DeleteUserCredentialBegin, CommunicationType::DeleteUserCredentialChallenge, CommunicationType::DeleteUserCredentialComplete, b"tensamin:user-delete:v1\0").await +} + +/// Repair local management state after a release or migration committed in +/// Omega but local cleanup was interrupted. Hosted data is retained. +pub async fn reconcile_managed_users(connection: &dyn OmikronClient) { + let Ok(local_iota_id) = configured_iota_id() else { return; }; + for user in iota_storage::users::user_manager::get_users() { + let request = CommunicationValue::new(CommunicationType::GetUserData) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(user.user_id.into())); + let Ok(response) = connection.await_response(&request, Duration::from_secs(10)).await else { continue; }; + let remote_iota_id = response.get_data(DataType::IotaId).as_signed_number().and_then(|value| i64::try_from(value).ok()); + if remote_iota_id != Some(local_iota_id) { + let _ = iota_storage::users::user_manager::release_user(user.user_id); + } + } +} + fn valid_username(username: &str) -> bool { !username.is_empty() && username.chars().count() <= 15 @@ -136,15 +266,9 @@ pub async fn create_user( } } log!("Created User"); - try_save_file( - "", - &format!("{}.tu", username), - &format!( - "{}@{}::{}", - user_id, - omega_discovery::omega_host(), - keyring_b64 - ), + write_user_credential( + user_id, + &format!("{}@{}::{}", user_id, omega_discovery::omega_host(), keyring_b64), ) .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?;