[Fix] User deletion & migration
This commit is contained in:
parent
326ebf3b37
commit
7dc98ef29b
20 changed files with 742 additions and 129 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>> {
|
||||||
|
|
|
||||||
|
|
@ -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
95
iota-util/src/tu.rs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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()))?;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue