diff --git a/Cargo.lock b/Cargo.lock index 4eb6e3c..616e92c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2250,7 +2250,6 @@ 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 ca912fc..ad0796f 100644 --- a/iota-cli/src/ipc_client.rs +++ b/iota-cli/src/ipc_client.rs @@ -483,9 +483,6 @@ 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 93d4b13..fab710c 100644 --- a/iota-cli/src/screens/users.rs +++ b/iota-cli/src/screens/users.rs @@ -27,9 +27,6 @@ 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)] @@ -149,9 +146,7 @@ impl UsersScreen { let user = &self.users[*user_index]; ( *user_index, - 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" }), + format!("{:>6} {}", user.user_id, user.username), ) }) .collect(); @@ -216,7 +211,7 @@ impl UsersScreen { f, buttons_area[2], ActionButton { - label: "Release", + label: "Remove", intent: ButtonIntent::Destructive, focused: self.focus == Focus::RemoveButton, enabled: !self.loading && !self.pending && !self.users.is_empty(), @@ -240,7 +235,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, state: iota_ipc::LocalUserState::Managed, data_present: true, credential_present: true }), + Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { user_id, username })) => Ok(UserEntry { user_id, username }), 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}")), @@ -254,14 +249,14 @@ impl UsersScreen { let ipc = self.ipc.clone(); let id = user.user_id; self.pending = true; - self.message = Some(format!("Releasing {}…", user.username)); + self.message = Some(format!("Removing {}…", user.username)); return InteractionResult::AppTask { task: Box::pin(async move { - 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}")), + 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}")), }; UiEvent::App(AppEvent::UserRemoved { user_id: id, @@ -533,11 +528,10 @@ impl Screen for UsersScreen { match result { Ok(()) => { self.pending_dialog = None; - 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.")); + 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}.")); } Err(error) => { self.dialog = self.pending_dialog.take(); diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index dad4c80..b06dab1 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -602,9 +602,6 @@ 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 74f5f96..58e627d 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -9,9 +9,8 @@ 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, DataType, DataValue}; +use mtp::codec::{CommunicationType, CommunicationValue}; use std::sync::{Arc, Mutex}; -use std::time::Duration; use crate::daemon_state::{ShutdownReason, StartupPhase}; @@ -52,10 +51,7 @@ impl CommandRouter { } let needs_omikron = matches!( request, - LocalRequest::CreateUser { .. } - | LocalRequest::AttachUserFromTu { .. } - | LocalRequest::ReleaseUser { .. } - | LocalRequest::CompleteDeleteUser { .. } + LocalRequest::CreateUser { .. } | LocalRequest::RemoveUser { .. } ); if needs_omikron && !self.services.omikron.is_connected().await { return ResponseResult::Error( @@ -96,15 +92,11 @@ impl CommandRouter { ResponseResult::Ok(ResponsePayload::Tasks(tasks)) } LocalRequest::ListUsers => { - let users: Vec = user_manager::get_residency() + let users: Vec = user_manager::get_users() .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)) @@ -145,61 +137,18 @@ impl CommandRouter { } } } - 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(())), + LocalRequest::RemoveUser { user_id } => { + let user = match user_manager::get_user(user_id) { + Some(user) => user, + None => return ResponseResult::Error(IpcErrorCode::NotFound), }; - 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), + 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); } + 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 { @@ -293,22 +242,23 @@ impl CommandRouter { ResponseResult::Ok(ResponsePayload::Components(components)) } LocalRequest::GetUser { user_id } => match user_manager::get_user(user_id) { - 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 { + Some(user) => 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 { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest), + 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::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 76eeaf0..47a0a51 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -156,7 +156,6 @@ 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 @@ -210,22 +209,6 @@ 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 bd3d102..0ff6284 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, - SecretString, SupervisorKind, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, LocalUserState, + SupervisorKind, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, }; pub use transport::{read_msg, write_msg}; diff --git a/iota-ipc/src/protocol.rs b/iota-ipc/src/protocol.rs index 2e2be1a..d31bee4 100644 --- a/iota-ipc/src/protocol.rs +++ b/iota-ipc/src/protocol.rs @@ -1,17 +1,5 @@ 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 // --------------------------------------------------------------------------- @@ -48,21 +36,6 @@ 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, }, @@ -164,9 +137,7 @@ 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), @@ -203,9 +174,6 @@ 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)] @@ -240,16 +208,6 @@ 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 ccd3c9e..af842e5 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -1,26 +1,10 @@ use crate::users::user_profile::UserProfile; use crate::util::db; -use iota_util::file_util::{delete_user_directory, load_file, remove_user_credential, save_file}; +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 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) { @@ -61,12 +45,6 @@ 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(()) }) } @@ -227,92 +205,42 @@ 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; DELETE FROM user_residency;")?; + conn.execute_batch("DELETE FROM trusted_apps; DELETE FROM users;")?; 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 5f2abd5..2ecb9cc 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::{read_user_credential_with_legacy, used_dir_space}; +use iota_util::file_util::{has_file, load_file, used_dir_space}; use json::{JsonValue, object}; use rand::Rng; use rand::rngs::OsRng; @@ -55,11 +55,9 @@ impl UserProfile { if let Some(d) = &self.display_name { obj["display_name"] = d.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(); + if has_file("", &format!("{}.tu", self.username.clone())) { + obj["tu"] = load_file("", &format!("{}.tu", self.username.clone())).into(); + } obj } diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index 82968e7..1a4ca7e 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -251,21 +251,6 @@ 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(()) } @@ -335,7 +320,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, 7); + assert_eq!(version, 6); 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")?; @@ -352,8 +337,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, 7); - for table in ["sync_heads", "sync_events", "client_sync_state", "user_residency"] { + assert_eq!(version, 6); + for table in ["sync_heads", "sync_events", "client_sync_state"] { 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 6b13018..9d628d4 100644 --- a/iota-storage/src/util/e2ee_storage.rs +++ b/iota-storage/src/util/e2ee_storage.rs @@ -188,23 +188,6 @@ 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 8412b10..1e1cad4 100755 --- a/iota-util/src/file_util.rs +++ b/iota-util/src/file_util.rs @@ -33,70 +33,11 @@ fn delete_dir_recursive(directory: &Path) -> bool { } #[allow(dead_code)] -pub fn delete_user_directory(user_id: i64) -> io::Result<()> { +pub fn delete_user_directory(user_id: i64) { let user_dir = Path::new(&get_directory()) .join("users") .join(user_id.to_string()); - 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), - } + let _ = delete_dir_recursive(&user_dir); } 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 5d32aef..2277b2b 100644 --- a/iota-util/src/lib.rs +++ b/iota-util/src/lib.rs @@ -1,4 +1,3 @@ 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 deleted file mode 100644 index e1a8334..0000000 --- a/iota-util/src/tu.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! 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 b05cfe8..3c38d65 100644 --- a/iota/Cargo.toml +++ b/iota/Cargo.toml @@ -11,7 +11,6 @@ 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 b1447a0..48c8ead 100644 --- a/iota/src/cli_args.rs +++ b/iota/src/cli_args.rs @@ -1,7 +1,6 @@ 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 { @@ -116,33 +115,15 @@ enum UsersAction { user_id: i64, }, Add { - username: Option, - #[arg(long, value_name = "PATH")] - tu: Option, + username: String, }, - Release { + Remove { user_id: i64, #[arg(long)] yes: bool, }, - 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, + Import { + username: String, }, } #[derive(Args, Debug)] @@ -283,21 +264,14 @@ pub enum Command { user_id: i64, }, UsersAdd { - username: Option, - tu: Option, + username: String, }, - UsersRelease { + UsersRemove { user_id: i64, confirmed: bool, }, - UsersPurgeData { - user_id: i64, - confirmed: bool, - }, - UsersCompleteDelete { - user_id: i64, - tu: Option, - confirmed: bool, + UsersImport { + username: String, }, OmikronReconnect, IdentityRotate { @@ -393,29 +367,12 @@ impl CliInvocation { Some(CliCommand::Users(users)) => match users.action { UsersAction::List => Command::UsersList, UsersAction::Show { user_id } => Command::UsersShow { user_id }, - 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 { + UsersAction::Add { username } => Command::UsersAdd { username }, + UsersAction::Remove { user_id, yes } => Command::UsersRemove { user_id, confirmed: resolve_confirmed(yes), }, - 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), - }, + UsersAction::Import { username } => Command::UsersImport { username }, }, Some(CliCommand::Omikron(omikron)) => match omikron.action { OmikronAction::Reconnect => Command::OmikronReconnect, @@ -488,7 +445,6 @@ impl CliInvocation { } } - #[allow(unused)] pub fn help_text() -> String { Cli::command().render_long_help().to_string() } @@ -572,7 +528,7 @@ mod tests { #[test] fn command_schema_drives_help_and_completion_paths() { let paths = CliInvocation::command_paths(); - assert!(paths.contains(&"users release".to_owned())); + assert!(paths.contains(&"users remove".to_owned())); assert!(paths.contains(&"daemon install".to_owned())); let help = CliInvocation::help_text(); assert!(help.contains("users")); @@ -602,7 +558,12 @@ 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] @@ -612,8 +573,7 @@ mod tests { assert_eq!( invocation.command, Command::UsersAdd { - username: Some("alice".into()), - tu: None, + username: "alice".into() } ); } @@ -637,24 +597,26 @@ mod tests { } #[test] - fn rejects_ambiguous_users_remove() { - let error = - CliInvocation::parse(["users".into(), "remove".into(), "42".into()]).unwrap_err(); - assert!(error.contains("remove")); + 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, + } + ); } #[test] - fn parses_users_release_with_confirmation() { - let invocation = CliInvocation::parse([ - "users".into(), - "release".into(), - "42".into(), - "--yes".into(), - ]) - .unwrap(); + fn parses_users_remove_with_confirmation() { + let invocation = + CliInvocation::parse(["users".into(), "remove".into(), "42".into(), "--yes".into()]) + .unwrap(); assert_eq!( invocation.command, - Command::UsersRelease { + Command::UsersRemove { user_id: 42, confirmed: true, } diff --git a/iota/src/main.rs b/iota/src/main.rs index e05b63f..192f1c8 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -403,9 +403,7 @@ fn print_help() { println!(" users list List all users"); println!(" users show Show user details"); println!(" users add Create a new user"); - 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!(" users remove Remove a user (requires --yes)"); println!(" omikron status Show Omikron connection status"); println!(" omikron reconnect Reconnect to Omikron"); println!(" identity rotate Rotate identity keys (requires --yes)"); @@ -453,7 +451,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 data purge 42 --yes Purge hosted data"); + println!(" iota users remove 42 --yes Remove user 42"); 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"); @@ -566,40 +564,12 @@ async fn run_command( Command::Tasks => LocalRequest::ListTasks, Command::UsersList => LocalRequest::ListUsers, Command::UsersShow { user_id } => LocalRequest::GetUser { user_id }, - 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 { + Command::UsersAdd { username } => LocalRequest::CreateUser { username }, + Command::UsersRemove { user_id, confirmed: true, - } => 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 } - } + } => LocalRequest::RemoveUser { user_id }, + Command::UsersImport { username } => LocalRequest::ImportUser { username }, Command::OmikronReconnect => LocalRequest::ReconnectOmikron, Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity, Command::RegenerateKeys { confirmed: true } => LocalRequest::RotateIotaIdentity, @@ -618,11 +588,9 @@ async fn run_command( Command::Logs { limit } => LocalRequest::GetLogs { limit }, Command::UpdateCheck => LocalRequest::CheckUpdate, Command::CommunityList => LocalRequest::ListCommunities, - Command::UsersRelease { + Command::UsersRemove { confirmed: false, .. } - | Command::UsersPurgeData { confirmed: false, .. } - | Command::UsersCompleteDelete { confirmed: false, .. } | Command::IdentityRotate { confirmed: false } | Command::RegenerateKeys { confirmed: false } | Command::DaemonRestart { confirmed: false } @@ -721,9 +689,6 @@ 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); } @@ -970,9 +935,6 @@ 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 a555398..81d6ae6 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -896,41 +896,12 @@ 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 241fd46..fde043f 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -2,12 +2,9 @@ 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::write_user_credential; -use iota_util::tu::TuCredential; +use iota_util::file_util::try_save_file; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; -use mtp::crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme}; use rand_core::{OsRng, RngCore}; use std::time::Duration; @@ -23,133 +20,6 @@ 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 @@ -266,9 +136,15 @@ pub async fn create_user( } } log!("Created User"); - write_user_credential( - user_id, - &format!("{}@{}::{}", user_id, omega_discovery::omega_host(), keyring_b64), + try_save_file( + "", + &format!("{}.tu", username), + &format!( + "{}@{}::{}", + user_id, + omega_discovery::omega_host(), + keyring_b64 + ), ) .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?;