[Fix] User deletion & migration

This commit is contained in:
Alex 2026-08-09 02:51:47 +02:00
commit 7dc98ef29b
Signed by: alex
SSH key fingerprint: SHA256:D1+Ub8o0v4K5y1JNivW8IxEOelqLSvPmUzBbDIoZkRQ
20 changed files with 742 additions and 129 deletions

1
Cargo.lock generated
View file

@ -2250,6 +2250,7 @@ dependencies = [
"iota-paths", "iota-paths",
"iota-process-manager", "iota-process-manager",
"iota-terms", "iota-terms",
"iota-util",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"tokio", "tokio",

View file

@ -483,6 +483,9 @@ impl IpcClient {
ResponsePayload::UserRemoved { user_id } => { ResponsePayload::UserRemoved { user_id } => {
format!("Removed user {}", user_id) format!("Removed user {}", user_id)
} }
ResponsePayload::UserDataPurged { user_id } => {
format!("Purged hosted data for {}", user_id)
}
ResponsePayload::Acknowledged { message } => message.clone(), ResponsePayload::Acknowledged { message } => message.clone(),
ResponsePayload::DaemonStatus(status) => status.formatted.clone(), ResponsePayload::DaemonStatus(status) => status.formatted.clone(),
ResponsePayload::Config(config) => config.yaml.clone(), ResponsePayload::Config(config) => config.yaml.clone(),

View file

@ -27,6 +27,9 @@ use std::{
pub struct UserEntry { pub struct UserEntry {
pub user_id: i64, pub user_id: i64,
pub username: String, pub username: String,
pub state: iota_ipc::LocalUserState,
pub data_present: bool,
pub credential_present: bool,
} }
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
@ -146,7 +149,9 @@ impl UsersScreen {
let user = &self.users[*user_index]; let user = &self.users[*user_index];
( (
*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(); .collect();
@ -211,7 +216,7 @@ impl UsersScreen {
f, f,
buttons_area[2], buttons_area[2],
ActionButton { ActionButton {
label: "Remove", label: "Release",
intent: ButtonIntent::Destructive, intent: ButtonIntent::Destructive,
focused: self.focus == Focus::RemoveButton, focused: self.focus == Focus::RemoveButton,
enabled: !self.loading && !self.pending && !self.users.is_empty(), enabled: !self.loading && !self.pending && !self.users.is_empty(),
@ -235,7 +240,7 @@ impl UsersScreen {
return InteractionResult::AppTask { return InteractionResult::AppTask {
task: Box::pin(async move { task: Box::pin(async move {
let result = match ipc.send_request(iota_ipc::LocalRequest::CreateUser { username: name }).await { 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(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot create user: {error}")),
Ok(_) => Err("Daemon returned an unexpected response while creating the user.".into()), Ok(_) => Err("Daemon returned an unexpected response while creating the user.".into()),
Err(error) => Err(format!("Cannot create user: {error}")), Err(error) => Err(format!("Cannot create user: {error}")),
@ -249,14 +254,14 @@ impl UsersScreen {
let ipc = self.ipc.clone(); let ipc = self.ipc.clone();
let id = user.user_id; let id = user.user_id;
self.pending = true; self.pending = true;
self.message = Some(format!("Removing {}", user.username)); self.message = Some(format!("Releasing {}", user.username));
return InteractionResult::AppTask { return InteractionResult::AppTask {
task: Box::pin(async move { task: Box::pin(async move {
let result = match ipc.send_request(iota_ipc::LocalRequest::RemoveUser { user_id: id }).await { let result = match ipc.send_request(iota_ipc::LocalRequest::ReleaseUser { user_id: id }).await {
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserRemoved { .. })) => Ok(()), Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Acknowledged { .. })) => Ok(()),
Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot remove user: {error}")), Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot release user: {error}")),
Ok(_) => Err("Daemon returned an unexpected response while removing the user.".into()), Ok(_) => Err("Daemon returned an unexpected response while releasing the user.".into()),
Err(error) => Err(format!("Cannot remove user: {error}")), Err(error) => Err(format!("Cannot release user: {error}")),
}; };
UiEvent::App(AppEvent::UserRemoved { UiEvent::App(AppEvent::UserRemoved {
user_id: id, user_id: id,
@ -528,10 +533,11 @@ impl Screen for UsersScreen {
match result { match result {
Ok(()) => { Ok(()) => {
self.pending_dialog = None; self.pending_dialog = None;
self.users.retain(|user| user.user_id != user_id); if let Some(user) = self.users.iter_mut().find(|user| user.user_id == user_id) {
self.focused_index = user.state = iota_ipc::LocalUserState::Released;
self.focused_index.min(self.users.len().saturating_sub(1)); user.credential_present = false;
self.message = Some(format!("Removed user {user_id}.")); }
self.message = Some(format!("Released user {user_id}; hosted data retained."));
} }
Err(error) => { Err(error) => {
self.dialog = self.pending_dialog.take(); self.dialog = self.pending_dialog.take();

View file

@ -602,6 +602,9 @@ impl UI {
.map(|u| UserEntry { .map(|u| UserEntry {
user_id: u.user_id, user_id: u.user_id,
username: u.username, username: u.username,
state: u.state,
data_present: u.data_present,
credential_present: u.credential_present,
}) })
.collect()) .collect())
} }

View file

@ -9,8 +9,9 @@ use iota_ipc::{
use iota_logger::{log, log_command}; use iota_logger::{log, log_command};
use iota_storage::users::user_manager; use iota_storage::users::user_manager;
use iota_storage::util::config_util::{self}; 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::sync::{Arc, Mutex};
use std::time::Duration;
use crate::daemon_state::{ShutdownReason, StartupPhase}; use crate::daemon_state::{ShutdownReason, StartupPhase};
@ -51,7 +52,10 @@ impl CommandRouter {
} }
let needs_omikron = matches!( let needs_omikron = matches!(
request, request,
LocalRequest::CreateUser { .. } | LocalRequest::RemoveUser { .. } LocalRequest::CreateUser { .. }
| LocalRequest::AttachUserFromTu { .. }
| LocalRequest::ReleaseUser { .. }
| LocalRequest::CompleteDeleteUser { .. }
); );
if needs_omikron && !self.services.omikron.is_connected().await { if needs_omikron && !self.services.omikron.is_connected().await {
return ResponseResult::Error( return ResponseResult::Error(
@ -92,11 +96,15 @@ impl CommandRouter {
ResponseResult::Ok(ResponsePayload::Tasks(tasks)) ResponseResult::Ok(ResponsePayload::Tasks(tasks))
} }
LocalRequest::ListUsers => { LocalRequest::ListUsers => {
let users: Vec<UserSummary> = user_manager::get_users() let users: Vec<UserSummary> = user_manager::get_residency()
.into_iter() .into_iter()
.map(|user| UserSummary { .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, user_id: user.user_id,
username: user.username, 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(); .collect();
ResponseResult::Ok(ResponsePayload::Users(users)) ResponseResult::Ok(ResponsePayload::Users(users))
@ -137,18 +145,61 @@ impl CommandRouter {
} }
} }
} }
LocalRequest::RemoveUser { user_id } => { LocalRequest::PurgeUserData { user_id } => match user_manager::purge_user_data(user_id) {
let user = match user_manager::get_user(user_id) { Ok(()) => ResponseResult::Ok(ResponsePayload::UserDataPurged { user_id }),
Some(user) => user, Err(error) => {
None => return ResponseResult::Error(IpcErrorCode::NotFound), 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) let Ok(contents) = contents else { return ResponseResult::Error(IpcErrorCode::Unauthorized); };
.with_sender(user.user_id as u64); match omikron_connector::user_ops::complete_delete_user_with_tu(self.services.omikron.as_ref(), &contents, user_id).await {
if let Err(_e) = self.services.omikron.send_message(&message).await { Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { message: format!("Deleted Tensamin account {user_id}") }),
return ResponseResult::Error(IpcErrorCode::OmikronUnavailable); 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 { LocalRequest::ReconnectOmikron => match self.services.omikron.reconnect().await {
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
@ -242,23 +293,22 @@ impl CommandRouter {
ResponseResult::Ok(ResponsePayload::Components(components)) ResponseResult::Ok(ResponsePayload::Components(components))
} }
LocalRequest::GetUser { user_id } => match user_manager::get_user(user_id) { 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, user_id: user.user_id,
username: user.username, username: user.username,
display_name: user.display_name, display_name: user.display_name,
created_at: user.created_at, created_at: user.created_at,
trusted_apps: user.trusted_apps.keys().cloned().collect(), 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), None => ResponseResult::Error(IpcErrorCode::NotFound),
}, },
LocalRequest::ImportUser { username } => { LocalRequest::ImportUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest),
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 } => { LocalRequest::GetLogs { limit } => {
let entries = if let Ok(buf) = self.log_buffer.lock() { let entries = if let Ok(buf) = self.log_buffer.lock() {
buf.recent(limit) buf.recent(limit)

View file

@ -156,6 +156,7 @@ async fn main() -> ExitCode {
} }
}; };
let omikron_health = omikron.clone(); let omikron_health = omikron.clone();
let omikron_reconcile = omikron.clone();
let services = DaemonServices::new(omikron); let services = DaemonServices::new(omikron);
let health_runtime = runtime.clone(); let health_runtime = runtime.clone();
runtime runtime
@ -209,6 +210,22 @@ async fn main() -> ExitCode {
Ok(()) Ok(())
}) })
.await; .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( let ipc_server = match IpcServer::bind(
socket.clone(), socket.clone(),
runtime.clone(), runtime.clone(),

View file

@ -8,7 +8,7 @@ pub use protocol::{
ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest, ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest,
LogEntriesResponse, LogEntry, MetricSample, OmikronStatusResponse, RequestEnvelope, LogEntriesResponse, LogEntry, MetricSample, OmikronStatusResponse, RequestEnvelope,
ResponseEnvelope, ResponsePayload, ResponseResult, StartupPhase, StateSnapshot, StatusResponse, 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}; pub use transport::{read_msg, write_msg};

View file

@ -1,5 +1,17 @@
use serde::{Deserialize, Serialize}; 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("<redacted>")
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Client → Daemon // Client → Daemon
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -36,6 +48,21 @@ pub enum LocalRequest {
CreateUser { CreateUser {
username: String, username: String,
}, },
AttachUserFromTu {
credential: SecretString,
},
PurgeUserData {
user_id: i64,
},
ReleaseUser {
user_id: i64,
},
CompleteDeleteUser {
user_id: i64,
credential: Option<SecretString>,
},
/// Retained only to return an actionable deprecation error to old IPC
/// clients. It must never select lifecycle semantics implicitly.
RemoveUser { RemoveUser {
user_id: i64, user_id: i64,
}, },
@ -137,7 +164,9 @@ pub enum ResponsePayload {
Tasks(Vec<TaskSummary>), Tasks(Vec<TaskSummary>),
Users(Vec<UserSummary>), Users(Vec<UserSummary>),
UserCreated { user_id: i64, username: String }, UserCreated { user_id: i64, username: String },
/// Retained only for wire compatibility. New lifecycle code never emits it.
UserRemoved { user_id: i64 }, UserRemoved { user_id: i64 },
UserDataPurged { user_id: i64 },
Acknowledged { message: String }, Acknowledged { message: String },
DaemonStatus(DaemonStatusResponse), DaemonStatus(DaemonStatusResponse),
Config(ConfigResponse), Config(ConfigResponse),
@ -174,6 +203,9 @@ pub struct UserDetailResponse {
pub display_name: Option<String>, pub display_name: Option<String>,
pub created_at: i64, pub created_at: i64,
pub trusted_apps: Vec<String>, pub trusted_apps: Vec<String>,
pub state: LocalUserState,
pub data_present: bool,
pub credential_present: bool,
} }
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
@ -208,6 +240,16 @@ pub struct TaskSummary {
pub struct UserSummary { pub struct UserSummary {
pub user_id: i64, pub user_id: i64,
pub username: String, 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)] #[derive(Clone, Debug, Deserialize, Serialize)]

View file

@ -1,10 +1,26 @@
use crate::users::user_profile::UserProfile; use crate::users::user_profile::UserProfile;
use crate::util::db; use crate::util::db;
use base64::{Engine as _, engine::general_purpose::STANDARD}; use iota_util::file_util::{delete_user_directory, load_file, remove_user_credential, save_file};
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 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) { pub fn add_user(user: UserProfile) {
if let Err(e) = try_add_user(user) { 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], 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(()) 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<UserResidency> {
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::<Result<Vec<_>, _>>().map_err(Into::into)
}).unwrap_or_default()
}
pub fn clear() { pub fn clear() {
if let Err(e) = db::with_db(|conn| { 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(()) Ok(())
}) { }) {
eprintln!("Failed to clear users: {}", e); 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::<Vec<&str>>();
let (uuid_str, _omega_host) = segments[0].split_once('@').unwrap_or((segments[0], ""));
let uuid = uuid_str.parse::<i64>().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() { pub fn save_users() {
// No-op: users are auto-saved via SQLite. // No-op: users are auto-saved via SQLite.

View file

@ -1,7 +1,7 @@
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use base64::{Engine as _, engine::general_purpose}; 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 json::{JsonValue, object};
use rand::Rng; use rand::Rng;
use rand::rngs::OsRng; use rand::rngs::OsRng;
@ -55,9 +55,11 @@ impl UserProfile {
if let Some(d) = &self.display_name { if let Some(d) = &self.display_name {
obj["display_name"] = d.clone().into(); obj["display_name"] = d.clone().into();
} }
if has_file("", &format!("{}.tu", self.username.clone())) { // Frontend consumers must never receive private credential material.
obj["tu"] = load_file("", &format!("{}.tu", self.username.clone())).into(); obj["has_tu"] = read_user_credential_with_legacy(self.user_id, &self.username)
} .map(|credential| credential.is_some())
.unwrap_or(false)
.into();
obj obj
} }

View file

@ -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(()) Ok(())
} }
@ -320,7 +335,7 @@ 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))?; 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"] { for column in ["height", "reply_to", "edited_count", "deleted_by_external"] {
let mut statement = let mut statement =
conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; 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)?;
run_migrations_on_connection(&conn)?; run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 6); assert_eq!(version, 7);
for table in ["sync_heads", "sync_events", "client_sync_state"] { for table in ["sync_heads", "sync_events", "client_sync_state", "user_residency"] {
let exists: i64 = conn.query_row( let exists: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
[table], [table],

View file

@ -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<Option<StoredChatSecret>, StorageError> { pub fn get_chat_secret(query: ChatSecretQuery) -> Result<Option<StoredChatSecret>, StorageError> {
if query.user_id.is_empty() || query.chat_id.is_empty() { if query.user_id.is_empty() || query.chat_id.is_empty() {
return Ok(None); return Ok(None);

View file

@ -33,11 +33,70 @@ fn delete_dir_recursive(directory: &Path) -> bool {
} }
#[allow(dead_code)] #[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()) let user_dir = Path::new(&get_directory())
.join("users") .join("users")
.join(user_id.to_string()); .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<Option<String>> {
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
/// `<username>.tu` is migrated atomically the first time it is encountered.
pub fn read_user_credential_with_legacy(user_id: i64, username: &str) -> io::Result<Option<String>> {
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<BufReader<File>> { pub fn load_file_buf(path: &str, name: &str) -> io::Result<BufReader<File>> {

View file

@ -1,3 +1,4 @@
pub mod crypto_helper; pub mod crypto_helper;
pub mod crypto_util; pub mod crypto_util;
pub mod file_util; pub mod file_util;
pub mod tu;

95
iota-util/src/tu.rs Normal file
View file

@ -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", &"<redacted>")
.finish()
}
}
impl TuCredential {
pub fn parse(input: &str) -> Result<Self, TuError> {
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::<i64>().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}");
}
}
}

View file

@ -11,6 +11,7 @@ iota-core = { path = "../iota-core" }
iota-process-manager = { path = "../iota-process-manager" } iota-process-manager = { path = "../iota-process-manager" }
iota-paths = { path = "../iota-paths" } iota-paths = { path = "../iota-paths" }
iota-terms = { path = "../iota-terms" } iota-terms = { path = "../iota-terms" }
iota-util = { path = "../iota-util" }
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] } tokio-util = { version = "0.7", features = ["rt"] }
serde_json = "1" serde_json = "1"

View file

@ -1,6 +1,7 @@
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind}; use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind};
use iota_cli::theme::{CliOutputFormat, ThemeName, UiConfig}; use iota_cli::theme::{CliOutputFormat, ThemeName, UiConfig};
use iota_terms::TermsType; use iota_terms::TermsType;
use std::path::PathBuf;
#[derive(Debug)] #[derive(Debug)]
pub struct CliInvocation { pub struct CliInvocation {
@ -115,15 +116,33 @@ enum UsersAction {
user_id: i64, user_id: i64,
}, },
Add { Add {
username: String, username: Option<String>,
#[arg(long, value_name = "PATH")]
tu: Option<PathBuf>,
}, },
Remove { Release {
user_id: i64, user_id: i64,
#[arg(long)] #[arg(long)]
yes: bool, yes: bool,
}, },
Import { Data {
username: String, #[command(subcommand)]
action: UserDataAction,
},
CompleteDelete {
user_id: i64,
#[arg(long, value_name = "PATH")]
tu: Option<PathBuf>,
#[arg(long)]
yes: bool,
},
}
#[derive(Subcommand, Debug)]
enum UserDataAction {
Purge {
user_id: i64,
#[arg(long)]
yes: bool,
}, },
} }
#[derive(Args, Debug)] #[derive(Args, Debug)]
@ -264,14 +283,21 @@ pub enum Command {
user_id: i64, user_id: i64,
}, },
UsersAdd { UsersAdd {
username: String, username: Option<String>,
tu: Option<PathBuf>,
}, },
UsersRemove { UsersRelease {
user_id: i64, user_id: i64,
confirmed: bool, confirmed: bool,
}, },
UsersImport { UsersPurgeData {
username: String, user_id: i64,
confirmed: bool,
},
UsersCompleteDelete {
user_id: i64,
tu: Option<PathBuf>,
confirmed: bool,
}, },
OmikronReconnect, OmikronReconnect,
IdentityRotate { IdentityRotate {
@ -367,12 +393,29 @@ impl CliInvocation {
Some(CliCommand::Users(users)) => match users.action { Some(CliCommand::Users(users)) => match users.action {
UsersAction::List => Command::UsersList, UsersAction::List => Command::UsersList,
UsersAction::Show { user_id } => Command::UsersShow { user_id }, UsersAction::Show { user_id } => Command::UsersShow { user_id },
UsersAction::Add { username } => Command::UsersAdd { username }, UsersAction::Add { username, tu } => {
UsersAction::Remove { user_id, yes } => Command::UsersRemove { if username.is_some() == tu.is_some() {
return Err(
"users add requires exactly one of <username> or --tu <PATH>".into(),
);
}
Command::UsersAdd { username, tu }
}
UsersAction::Release { user_id, yes } => Command::UsersRelease {
user_id, user_id,
confirmed: resolve_confirmed(yes), 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 { Some(CliCommand::Omikron(omikron)) => match omikron.action {
OmikronAction::Reconnect => Command::OmikronReconnect, OmikronAction::Reconnect => Command::OmikronReconnect,
@ -445,6 +488,7 @@ impl CliInvocation {
} }
} }
#[allow(unused)]
pub fn help_text() -> String { pub fn help_text() -> String {
Cli::command().render_long_help().to_string() Cli::command().render_long_help().to_string()
} }
@ -528,7 +572,7 @@ mod tests {
#[test] #[test]
fn command_schema_drives_help_and_completion_paths() { fn command_schema_drives_help_and_completion_paths() {
let paths = CliInvocation::command_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())); assert!(paths.contains(&"daemon install".to_owned()));
let help = CliInvocation::help_text(); let help = CliInvocation::help_text();
assert!(help.contains("users")); assert!(help.contains("users"));
@ -558,12 +602,7 @@ mod tests {
#[test] #[test]
fn parses_unconfirmed_destructive_commands_explicitly() { fn parses_unconfirmed_destructive_commands_explicitly() {
let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap(); let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap();
assert_eq!( assert_eq!(invocation.command, Command::DaemonStop { confirmed: true });
invocation.command,
Command::DaemonStop {
confirmed: true
}
);
} }
#[test] #[test]
@ -573,7 +612,8 @@ mod tests {
assert_eq!( assert_eq!(
invocation.command, invocation.command,
Command::UsersAdd { Command::UsersAdd {
username: "alice".into() username: Some("alice".into()),
tu: None,
} }
); );
} }
@ -597,26 +637,24 @@ mod tests {
} }
#[test] #[test]
fn parses_users_remove_without_confirmation() { fn rejects_ambiguous_users_remove() {
let invocation = let error =
CliInvocation::parse(["users".into(), "remove".into(), "42".into()]).unwrap(); CliInvocation::parse(["users".into(), "remove".into(), "42".into()]).unwrap_err();
assert_eq!( assert!(error.contains("remove"));
invocation.command,
Command::UsersRemove {
user_id: 42,
confirmed: true,
}
);
} }
#[test] #[test]
fn parses_users_remove_with_confirmation() { fn parses_users_release_with_confirmation() {
let invocation = let invocation = CliInvocation::parse([
CliInvocation::parse(["users".into(), "remove".into(), "42".into(), "--yes".into()]) "users".into(),
"release".into(),
"42".into(),
"--yes".into(),
])
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
invocation.command, invocation.command,
Command::UsersRemove { Command::UsersRelease {
user_id: 42, user_id: 42,
confirmed: true, confirmed: true,
} }

View file

@ -403,7 +403,9 @@ fn print_help() {
println!(" users list List all users"); println!(" users list List all users");
println!(" users show <ID> Show user details"); println!(" users show <ID> Show user details");
println!(" users add <NAME> Create a new user"); println!(" users add <NAME> Create a new user");
println!(" users remove <ID> Remove a user (requires --yes)"); println!(" users add --tu <PATH> Add an existing account credential");
println!(" users data purge <ID> Purge hosted data (requires --yes)");
println!(" users release <ID> Release this Iota (requires --yes)");
println!(" omikron status Show Omikron connection status"); println!(" omikron status Show Omikron connection status");
println!(" omikron reconnect Reconnect to Omikron"); println!(" omikron reconnect Reconnect to Omikron");
println!(" identity rotate Rotate identity keys (requires --yes)"); println!(" identity rotate Rotate identity keys (requires --yes)");
@ -451,7 +453,7 @@ fn print_help() {
println!(" iota status Show daemon status"); println!(" iota status Show daemon status");
println!(" iota users list --output=json List users in JSON format"); println!(" iota users list --output=json List users in JSON format");
println!(" iota users add alice Create a user named 'alice'"); 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 config get --output=yaml Show config in YAML format");
println!(" iota logs --limit 50 Show last 50 log entries"); println!(" iota logs --limit 50 Show last 50 log entries");
println!(" iota completions bash Generate bash completions"); println!(" iota completions bash Generate bash completions");
@ -564,12 +566,40 @@ async fn run_command(
Command::Tasks => LocalRequest::ListTasks, Command::Tasks => LocalRequest::ListTasks,
Command::UsersList => LocalRequest::ListUsers, Command::UsersList => LocalRequest::ListUsers,
Command::UsersShow { user_id } => LocalRequest::GetUser { user_id }, Command::UsersShow { user_id } => LocalRequest::GetUser { user_id },
Command::UsersAdd { username } => LocalRequest::CreateUser { username }, Command::UsersAdd { username: Some(username), tu: None } => LocalRequest::CreateUser { username },
Command::UsersRemove { 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 <username> or --tu <PATH>".into(),
));
}
Command::UsersRelease {
user_id, user_id,
confirmed: true, confirmed: true,
} => LocalRequest::RemoveUser { user_id }, } => LocalRequest::ReleaseUser { user_id },
Command::UsersImport { username } => LocalRequest::ImportUser { username }, 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::OmikronReconnect => LocalRequest::ReconnectOmikron,
Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity, Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity,
Command::RegenerateKeys { 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::Logs { limit } => LocalRequest::GetLogs { limit },
Command::UpdateCheck => LocalRequest::CheckUpdate, Command::UpdateCheck => LocalRequest::CheckUpdate,
Command::CommunityList => LocalRequest::ListCommunities, Command::CommunityList => LocalRequest::ListCommunities,
Command::UsersRemove { Command::UsersRelease {
confirmed: false, .. confirmed: false, ..
} }
| Command::UsersPurgeData { confirmed: false, .. }
| Command::UsersCompleteDelete { confirmed: false, .. }
| Command::IdentityRotate { confirmed: false } | Command::IdentityRotate { confirmed: false }
| Command::RegenerateKeys { confirmed: false } | Command::RegenerateKeys { confirmed: false }
| Command::DaemonRestart { confirmed: false } | Command::DaemonRestart { confirmed: false }
@ -689,6 +721,9 @@ async fn run_command(
user_id 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 } => { ResponsePayload::Acknowledged { message } => {
println!("{}", message); println!("{}", message);
} }
@ -935,6 +970,9 @@ fn render_table(payload: &ResponsePayload) {
ResponsePayload::UserRemoved { user_id } => { ResponsePayload::UserRemoved { user_id } => {
println!("Removed user {}", user_id); println!("Removed user {}", user_id);
} }
ResponsePayload::UserDataPurged { user_id } => {
println!("Purged hosted data for {}", user_id);
}
ResponsePayload::Acknowledged { message } => { ResponsePayload::Acknowledged { message } => {
println!("{}", message); println!("{}", message);
} }

View file

@ -896,12 +896,41 @@ impl OmikronConnection {
dispatch!(SettingsSave, handle_settings_save); dispatch!(SettingsSave, handle_settings_save);
dispatch!(SettingsLoad, handle_settings_load); dispatch!(SettingsLoad, handle_settings_load);
dispatch!(SettingsList, handle_settings_list); dispatch!(SettingsList, handle_settings_list);
dispatch!(EraseHostedUserData, handle_erase_hosted_user_data);
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Message Handlers // 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<Self>, 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<Self>, cv: &CommunicationValue) { async fn handle_set_chat_secret(self: Arc<Self>, cv: &CommunicationValue) {
let sender_id = cv.get_sender().to_string(); let sender_id = cv.get_sender().to_string();
let recipients = match chat_secret_recipients(cv) { let recipients = match chat_secret_recipients(cv) {

View file

@ -2,9 +2,12 @@ use base64::{Engine as _, engine::general_purpose::STANDARD};
use iota_logger::{PrintType, log, log_cv, log_t}; use iota_logger::{PrintType, log, log_cv, log_t};
use iota_storage::users::user_manager::try_add_user; use iota_storage::users::user_manager::try_add_user;
use iota_storage::users::user_profile::UserProfile; 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::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::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use mtp::crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme};
use rand_core::{OsRng, RngCore}; use rand_core::{OsRng, RngCore};
use std::time::Duration; use std::time::Duration;
@ -20,6 +23,133 @@ pub enum CreateUserError {
LocalPersistence(String), LocalPersistence(String),
} }
#[derive(Debug)]
pub enum LifecycleUserError {
InvalidCredential(String),
OmegaHostMismatch,
RemoteRejected,
Transport(crate::OmikronError),
LocalPersistence(String),
}
impl From<crate::OmikronError> 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<u8> {
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<i64, LifecycleUserError> {
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<u8>, Vec<u8>), 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<UserProfile, LifecycleUserError> {
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 { fn valid_username(username: &str) -> bool {
!username.is_empty() !username.is_empty()
&& username.chars().count() <= 15 && username.chars().count() <= 15
@ -136,15 +266,9 @@ pub async fn create_user(
} }
} }
log!("Created User"); log!("Created User");
try_save_file( write_user_credential(
"",
&format!("{}.tu", username),
&format!(
"{}@{}::{}",
user_id, user_id,
omega_discovery::omega_host(), &format!("{}@{}::{}", user_id, omega_discovery::omega_host(), keyring_b64),
keyring_b64
),
) )
.map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?; .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?;