Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c502dfe83a | |||
|
7dc98ef29b |
|||
|
326ebf3b37 |
31 changed files with 1132 additions and 301 deletions
428
Cargo.lock
generated
428
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -14,7 +14,7 @@ iota-auth = { path = "../iota-auth" }
|
|||
|
||||
actix-web = { version = "4", features = ["rustls-0_23"] }
|
||||
actix-web-actors = "4"
|
||||
aes-gcm = "0.10.3"
|
||||
aes-gcm = "0.11.0"
|
||||
async-trait = "0.1.89"
|
||||
base64 = "0.22.1"
|
||||
chrono = "0.4.43"
|
||||
|
|
|
|||
|
|
@ -407,6 +407,12 @@ impl ClientConnection {
|
|||
return;
|
||||
}
|
||||
|
||||
if cv.is_type(CommunicationType::MessageGet) {
|
||||
self.send_message(&message_handlers::handle_message_get(&cv))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
if cv.is_type(CommunicationType::GetChats) {
|
||||
self.send_message(&message_handlers::handle_get_chats(&cv))
|
||||
.await;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ iota-auth = { path = "../iota-auth" }
|
|||
|
||||
actix-web = { version = "4", features = ["rustls-0_23"] }
|
||||
actix-web-actors = "4"
|
||||
aes-gcm = "0.10.3"
|
||||
aes-gcm = "0.11.0"
|
||||
async-trait = "0.1.89"
|
||||
base64 = "0.22.1"
|
||||
chrono = "0.4.43"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ iota-util = { path = "../iota-util" }
|
|||
iota-storage = { path = "../iota-storage" }
|
||||
iota-state = { path = "../iota-state" }
|
||||
|
||||
aes-gcm = "0.10.3"
|
||||
aes-gcm = "0.11.0"
|
||||
async-trait = "0.1.89"
|
||||
base64 = "0.22.1"
|
||||
chrono = "0.4.43"
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ mtp = { git = "https://git.methanium.net/Methanium/mtp.git", optional = true }
|
|||
|
||||
actix-web = { version = "4", features = ["rustls-0_23"] }
|
||||
actix-web-actors = "4"
|
||||
aes-gcm = "0.10.3"
|
||||
aes-gcm = "0.11.0"
|
||||
async-trait = "0.1.89"
|
||||
base64 = "0.22.1"
|
||||
chrono = "0.4.43"
|
||||
|
|
|
|||
|
|
@ -483,6 +483,9 @@ impl IpcClient {
|
|||
ResponsePayload::UserRemoved { user_id } => {
|
||||
format!("Removed user {}", user_id)
|
||||
}
|
||||
ResponsePayload::UserDataPurged { user_id } => {
|
||||
format!("Purged hosted data for {}", user_id)
|
||||
}
|
||||
ResponsePayload::Acknowledged { message } => message.clone(),
|
||||
ResponsePayload::DaemonStatus(status) => status.formatted.clone(),
|
||||
ResponsePayload::Config(config) => config.yaml.clone(),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ use std::{
|
|||
pub struct UserEntry {
|
||||
pub user_id: i64,
|
||||
pub username: String,
|
||||
pub state: iota_ipc::LocalUserState,
|
||||
pub data_present: bool,
|
||||
pub credential_present: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -146,7 +149,9 @@ impl UsersScreen {
|
|||
let user = &self.users[*user_index];
|
||||
(
|
||||
*user_index,
|
||||
format!("{:>6} {}", user.user_id, user.username),
|
||||
format!("{:>6} {} {}{}", user.user_id, user.username,
|
||||
match user.state { iota_ipc::LocalUserState::Managed => "managed", iota_ipc::LocalUserState::Released => "released" },
|
||||
if user.data_present { "" } else { ", data purged" }),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -211,7 +216,7 @@ impl UsersScreen {
|
|||
f,
|
||||
buttons_area[2],
|
||||
ActionButton {
|
||||
label: "Remove",
|
||||
label: "Release",
|
||||
intent: ButtonIntent::Destructive,
|
||||
focused: self.focus == Focus::RemoveButton,
|
||||
enabled: !self.loading && !self.pending && !self.users.is_empty(),
|
||||
|
|
@ -235,7 +240,7 @@ impl UsersScreen {
|
|||
return InteractionResult::AppTask {
|
||||
task: Box::pin(async move {
|
||||
let result = match ipc.send_request(iota_ipc::LocalRequest::CreateUser { username: name }).await {
|
||||
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { user_id, username })) => Ok(UserEntry { user_id, username }),
|
||||
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { user_id, username })) => Ok(UserEntry { user_id, username, state: iota_ipc::LocalUserState::Managed, data_present: true, credential_present: true }),
|
||||
Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot create user: {error}")),
|
||||
Ok(_) => Err("Daemon returned an unexpected response while creating the user.".into()),
|
||||
Err(error) => Err(format!("Cannot create user: {error}")),
|
||||
|
|
@ -249,14 +254,14 @@ impl UsersScreen {
|
|||
let ipc = self.ipc.clone();
|
||||
let id = user.user_id;
|
||||
self.pending = true;
|
||||
self.message = Some(format!("Removing {}…", user.username));
|
||||
self.message = Some(format!("Releasing {}…", user.username));
|
||||
return InteractionResult::AppTask {
|
||||
task: Box::pin(async move {
|
||||
let result = match ipc.send_request(iota_ipc::LocalRequest::RemoveUser { user_id: id }).await {
|
||||
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserRemoved { .. })) => Ok(()),
|
||||
Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot remove user: {error}")),
|
||||
Ok(_) => Err("Daemon returned an unexpected response while removing the user.".into()),
|
||||
Err(error) => Err(format!("Cannot remove user: {error}")),
|
||||
let result = match ipc.send_request(iota_ipc::LocalRequest::ReleaseUser { user_id: id }).await {
|
||||
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Acknowledged { .. })) => Ok(()),
|
||||
Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot release user: {error}")),
|
||||
Ok(_) => Err("Daemon returned an unexpected response while releasing the user.".into()),
|
||||
Err(error) => Err(format!("Cannot release user: {error}")),
|
||||
};
|
||||
UiEvent::App(AppEvent::UserRemoved {
|
||||
user_id: id,
|
||||
|
|
@ -528,10 +533,11 @@ impl Screen for UsersScreen {
|
|||
match result {
|
||||
Ok(()) => {
|
||||
self.pending_dialog = None;
|
||||
self.users.retain(|user| user.user_id != user_id);
|
||||
self.focused_index =
|
||||
self.focused_index.min(self.users.len().saturating_sub(1));
|
||||
self.message = Some(format!("Removed user {user_id}."));
|
||||
if let Some(user) = self.users.iter_mut().find(|user| user.user_id == user_id) {
|
||||
user.state = iota_ipc::LocalUserState::Released;
|
||||
user.credential_present = false;
|
||||
}
|
||||
self.message = Some(format!("Released user {user_id}; hosted data retained."));
|
||||
}
|
||||
Err(error) => {
|
||||
self.dialog = self.pending_dialog.take();
|
||||
|
|
|
|||
|
|
@ -602,6 +602,9 @@ impl UI {
|
|||
.map(|u| UserEntry {
|
||||
user_id: u.user_id,
|
||||
username: u.username,
|
||||
state: u.state,
|
||||
data_present: u.data_present,
|
||||
credential_present: u.credential_present,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,11 +102,11 @@ pub fn handle_message_delete(cv: &CommunicationValue) -> CommunicationValue {
|
|||
}
|
||||
}
|
||||
|
||||
fn stored_message_value(
|
||||
fn stored_message_fields(
|
||||
message: &chat_files::StoredMessage,
|
||||
storage_owner: i64,
|
||||
partner_id: i64,
|
||||
) -> DataValue {
|
||||
) -> Vec<(DataType, DataValue)> {
|
||||
let mut fields = vec![
|
||||
(
|
||||
DataType::MessageId,
|
||||
|
|
@ -162,7 +162,15 @@ fn stored_message_value(
|
|||
.collect();
|
||||
fields.push((DataType::Reactions, DataValue::Array(reactions)));
|
||||
}
|
||||
typed_container(fields)
|
||||
fields
|
||||
}
|
||||
|
||||
fn stored_message_value(
|
||||
message: &chat_files::StoredMessage,
|
||||
storage_owner: i64,
|
||||
partner_id: i64,
|
||||
) -> DataValue {
|
||||
typed_container(stored_message_fields(message, storage_owner, partner_id))
|
||||
}
|
||||
|
||||
pub fn handle_get_chat_secret(cv: &CommunicationValue) -> CommunicationValue {
|
||||
|
|
@ -520,6 +528,28 @@ pub fn handle_messages_get(cv: &CommunicationValue) -> CommunicationValue {
|
|||
.add_typed_default(DataType::Messages, DataValue::Array(msg_array))
|
||||
}
|
||||
|
||||
pub fn handle_message_get(cv: &CommunicationValue) -> CommunicationValue {
|
||||
let Some(send_time) = data_i64(cv, DataType::SendTime) else {
|
||||
return error_response(cv, CommunicationType::ErrorInvalidData);
|
||||
};
|
||||
let partner_id = data_i64(cv, DataType::ChatPartnerId);
|
||||
let owner = cv.get_sender() as i64;
|
||||
|
||||
let message = match chat_files::get_message(owner, send_time, partner_id) {
|
||||
Ok(Some(message)) => message,
|
||||
Ok(None) => return error_response(cv, CommunicationType::ErrorNotFound),
|
||||
Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData),
|
||||
};
|
||||
|
||||
let mut response = CommunicationValue::new(CommunicationType::MessageGet)
|
||||
.with_id(cv.get_id())
|
||||
.with_receiver(cv.get_sender());
|
||||
for (data_type, value) in stored_message_fields(&message, owner, message.external_user) {
|
||||
response = response.add_typed_default(data_type, value);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
pub fn handle_get_chats(cv: &CommunicationValue) -> CommunicationValue {
|
||||
let user_id = cv.get_sender();
|
||||
let users = chats_util::get_users(user_id as i64);
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ use iota_ipc::{
|
|||
use iota_logger::{log, log_command};
|
||||
use iota_storage::users::user_manager;
|
||||
use iota_storage::util::config_util::{self};
|
||||
use mtp::codec::{CommunicationType, CommunicationValue};
|
||||
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::daemon_state::{ShutdownReason, StartupPhase};
|
||||
|
||||
|
|
@ -51,7 +52,10 @@ impl CommandRouter {
|
|||
}
|
||||
let needs_omikron = matches!(
|
||||
request,
|
||||
LocalRequest::CreateUser { .. } | LocalRequest::RemoveUser { .. }
|
||||
LocalRequest::CreateUser { .. }
|
||||
| LocalRequest::AttachUserFromTu { .. }
|
||||
| LocalRequest::ReleaseUser { .. }
|
||||
| LocalRequest::CompleteDeleteUser { .. }
|
||||
);
|
||||
if needs_omikron && !self.services.omikron.is_connected().await {
|
||||
return ResponseResult::Error(
|
||||
|
|
@ -92,11 +96,15 @@ impl CommandRouter {
|
|||
ResponseResult::Ok(ResponsePayload::Tasks(tasks))
|
||||
}
|
||||
LocalRequest::ListUsers => {
|
||||
let users: Vec<UserSummary> = user_manager::get_users()
|
||||
let users: Vec<UserSummary> = user_manager::get_residency()
|
||||
.into_iter()
|
||||
.map(|user| UserSummary {
|
||||
credential_present: user.state == user_manager::LocalUserState::Managed
|
||||
&& user_manager::get_user(user.user_id).is_some_and(|profile| iota_util::file_util::read_user_credential_with_legacy(user.user_id, &profile.username).ok().flatten().is_some()),
|
||||
user_id: user.user_id,
|
||||
username: user.username,
|
||||
state: match user.state { user_manager::LocalUserState::Managed => iota_ipc::LocalUserState::Managed, user_manager::LocalUserState::Released => iota_ipc::LocalUserState::Released },
|
||||
data_present: user.data_present,
|
||||
})
|
||||
.collect();
|
||||
ResponseResult::Ok(ResponsePayload::Users(users))
|
||||
|
|
@ -137,18 +145,61 @@ impl CommandRouter {
|
|||
}
|
||||
}
|
||||
}
|
||||
LocalRequest::RemoveUser { user_id } => {
|
||||
let user = match user_manager::get_user(user_id) {
|
||||
Some(user) => user,
|
||||
None => return ResponseResult::Error(IpcErrorCode::NotFound),
|
||||
LocalRequest::PurgeUserData { user_id } => match user_manager::purge_user_data(user_id) {
|
||||
Ok(()) => ResponseResult::Ok(ResponsePayload::UserDataPurged { user_id }),
|
||||
Err(error) => {
|
||||
log!("User data purge failed for {user_id}: {error}");
|
||||
ResponseResult::Error(IpcErrorCode::StorageFailure)
|
||||
}
|
||||
},
|
||||
LocalRequest::AttachUserFromTu { credential } => {
|
||||
match omikron_connector::user_ops::attach_user_from_tu(self.services.omikron.as_ref(), &credential.0).await {
|
||||
Ok(user) => ResponseResult::Ok(ResponsePayload::Acknowledged { message: format!("Added {} ({}) to this Iota", user.username, user.user_id) }),
|
||||
Err(error) => {
|
||||
log!("Credential attach failed: {error:?}");
|
||||
ResponseResult::Error(IpcErrorCode::Unauthorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
LocalRequest::CompleteDeleteUser { user_id, credential } => {
|
||||
let contents = match credential {
|
||||
Some(value) => Ok(value.0),
|
||||
None => user_manager::get_user(user_id)
|
||||
.ok_or(())
|
||||
.and_then(|user| iota_util::file_util::read_user_credential_with_legacy(user_id, &user.username).map_err(|_| ()))
|
||||
.and_then(|value| value.ok_or(())),
|
||||
};
|
||||
let message = CommunicationValue::new(CommunicationType::DeleteUser)
|
||||
.with_sender(user.user_id as u64);
|
||||
if let Err(_e) = self.services.omikron.send_message(&message).await {
|
||||
return ResponseResult::Error(IpcErrorCode::OmikronUnavailable);
|
||||
let Ok(contents) = contents else { return ResponseResult::Error(IpcErrorCode::Unauthorized); };
|
||||
match omikron_connector::user_ops::complete_delete_user_with_tu(self.services.omikron.as_ref(), &contents, user_id).await {
|
||||
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { message: format!("Deleted Tensamin account {user_id}") }),
|
||||
Err(error) => {
|
||||
log!("Credential deletion failed for {user_id}: {error:?}");
|
||||
ResponseResult::Error(IpcErrorCode::Unauthorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
LocalRequest::RemoveUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest),
|
||||
LocalRequest::ReleaseUser { user_id } => {
|
||||
if user_manager::get_user(user_id).is_none() {
|
||||
return ResponseResult::Error(IpcErrorCode::NotFound);
|
||||
}
|
||||
let request = CommunicationValue::new(CommunicationType::ReleaseUserFromIota)
|
||||
.add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into()));
|
||||
match self.services.omikron.await_response(&request, Duration::from_secs(20)).await {
|
||||
Ok(response) if response.is_type(CommunicationType::Success) => match user_manager::release_user(user_id) {
|
||||
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
|
||||
message: format!("Released user {user_id}; hosted data was retained"),
|
||||
}),
|
||||
Err(error) => {
|
||||
log!("Remote release succeeded but local cleanup failed for {user_id}: {error}");
|
||||
ResponseResult::Error(IpcErrorCode::StorageFailure)
|
||||
}
|
||||
},
|
||||
Ok(response) if response.is_type(CommunicationType::ErrorNotAuthenticated) => ResponseResult::Error(IpcErrorCode::Unauthorized),
|
||||
Ok(_) => ResponseResult::Error(IpcErrorCode::Conflict),
|
||||
Err(omikron_connector::OmikronError::Timeout(_)) => ResponseResult::Error(IpcErrorCode::Timeout),
|
||||
Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
|
||||
}
|
||||
user_manager::remove_user(user.user_id);
|
||||
ResponseResult::Ok(ResponsePayload::UserRemoved { user_id })
|
||||
}
|
||||
LocalRequest::ReconnectOmikron => match self.services.omikron.reconnect().await {
|
||||
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
|
||||
|
|
@ -242,23 +293,22 @@ impl CommandRouter {
|
|||
ResponseResult::Ok(ResponsePayload::Components(components))
|
||||
}
|
||||
LocalRequest::GetUser { user_id } => match user_manager::get_user(user_id) {
|
||||
Some(user) => ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse {
|
||||
Some(user) => {
|
||||
let credential_present = iota_util::file_util::read_user_credential_with_legacy(user_id, &user.username).ok().flatten().is_some();
|
||||
ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse {
|
||||
user_id: user.user_id,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
created_at: user.created_at,
|
||||
trusted_apps: user.trusted_apps.keys().cloned().collect(),
|
||||
})),
|
||||
state: iota_ipc::LocalUserState::Managed,
|
||||
data_present: user_manager::get_residency().iter().find(|entry| entry.user_id == user_id).is_none_or(|entry| entry.data_present),
|
||||
credential_present,
|
||||
}))
|
||||
},
|
||||
None => ResponseResult::Error(IpcErrorCode::NotFound),
|
||||
},
|
||||
LocalRequest::ImportUser { username } => {
|
||||
match user_manager::load_from_tu(&username).await {
|
||||
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
|
||||
message: format!("Imported user {username}"),
|
||||
}),
|
||||
Err(()) => ResponseResult::Error(IpcErrorCode::StorageFailure),
|
||||
}
|
||||
}
|
||||
LocalRequest::ImportUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest),
|
||||
LocalRequest::GetLogs { limit } => {
|
||||
let entries = if let Ok(buf) = self.log_buffer.lock() {
|
||||
buf.recent(limit)
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ async fn main() -> ExitCode {
|
|||
}
|
||||
};
|
||||
let omikron_health = omikron.clone();
|
||||
let omikron_reconcile = omikron.clone();
|
||||
let services = DaemonServices::new(omikron);
|
||||
let health_runtime = runtime.clone();
|
||||
runtime
|
||||
|
|
@ -209,6 +210,22 @@ async fn main() -> ExitCode {
|
|||
Ok(())
|
||||
})
|
||||
.await;
|
||||
runtime
|
||||
.tasks
|
||||
.spawn_tracked("user-lifecycle-reconciliation", async move {
|
||||
let mut states = omikron_reconcile.connection_state();
|
||||
loop {
|
||||
if matches!(*states.borrow(), omikron_connector::omikron_connection::ConnectionState::Connected { .. }) {
|
||||
omikron_connector::user_ops::reconcile_managed_users(omikron_reconcile.as_ref()).await;
|
||||
}
|
||||
tokio::select! {
|
||||
changed = states.changed() => if changed.is_err() { break },
|
||||
_ = tokio::time::sleep(Duration::from_secs(30)) => {},
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
let ipc_server = match IpcServer::bind(
|
||||
socket.clone(),
|
||||
runtime.clone(),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ pub use protocol::{
|
|||
ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest,
|
||||
LogEntriesResponse, LogEntry, MetricSample, OmikronStatusResponse, RequestEnvelope,
|
||||
ResponseEnvelope, ResponsePayload, ResponseResult, StartupPhase, StateSnapshot, StatusResponse,
|
||||
SupervisorKind, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary,
|
||||
SecretString, SupervisorKind, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, LocalUserState,
|
||||
};
|
||||
pub use transport::{read_msg, write_msg};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// IPC credentials are supplied by the interactive CLI, never a daemon-side
|
||||
/// path lookup. Debug is deliberately redacted because command routing logs
|
||||
/// the request value.
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct SecretString(pub String);
|
||||
|
||||
impl std::fmt::Debug for SecretString {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("<redacted>")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client → Daemon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -36,6 +48,21 @@ pub enum LocalRequest {
|
|||
CreateUser {
|
||||
username: String,
|
||||
},
|
||||
AttachUserFromTu {
|
||||
credential: SecretString,
|
||||
},
|
||||
PurgeUserData {
|
||||
user_id: i64,
|
||||
},
|
||||
ReleaseUser {
|
||||
user_id: i64,
|
||||
},
|
||||
CompleteDeleteUser {
|
||||
user_id: i64,
|
||||
credential: Option<SecretString>,
|
||||
},
|
||||
/// Retained only to return an actionable deprecation error to old IPC
|
||||
/// clients. It must never select lifecycle semantics implicitly.
|
||||
RemoveUser {
|
||||
user_id: i64,
|
||||
},
|
||||
|
|
@ -137,7 +164,9 @@ pub enum ResponsePayload {
|
|||
Tasks(Vec<TaskSummary>),
|
||||
Users(Vec<UserSummary>),
|
||||
UserCreated { user_id: i64, username: String },
|
||||
/// Retained only for wire compatibility. New lifecycle code never emits it.
|
||||
UserRemoved { user_id: i64 },
|
||||
UserDataPurged { user_id: i64 },
|
||||
Acknowledged { message: String },
|
||||
DaemonStatus(DaemonStatusResponse),
|
||||
Config(ConfigResponse),
|
||||
|
|
@ -174,6 +203,9 @@ pub struct UserDetailResponse {
|
|||
pub display_name: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub trusted_apps: Vec<String>,
|
||||
pub state: LocalUserState,
|
||||
pub data_present: bool,
|
||||
pub credential_present: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
|
|
@ -208,6 +240,16 @@ pub struct TaskSummary {
|
|||
pub struct UserSummary {
|
||||
pub user_id: i64,
|
||||
pub username: String,
|
||||
pub state: LocalUserState,
|
||||
pub data_present: bool,
|
||||
pub credential_present: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LocalUserState {
|
||||
Managed,
|
||||
Released,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ iota-paths = { path = "../iota-paths" }
|
|||
|
||||
mtp = { git = "https://git.methanium.net/Methanium/mtp.git" }
|
||||
|
||||
aes-gcm = "0.10.3"
|
||||
aes-gcm = "0.11.0"
|
||||
base64 = "0.22.1"
|
||||
hex = "*"
|
||||
hkdf = "0.12.4"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,26 @@
|
|||
use crate::users::user_profile::UserProfile;
|
||||
use crate::util::db;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use iota_util::crypto_helper::{self, hex_hash, keyring_from_base64, public_key_bundle_to_base64};
|
||||
use iota_util::file_util::{load_file, save_file};
|
||||
use rand_core::{OsRng, RngCore};
|
||||
use iota_util::file_util::{delete_user_directory, load_file, remove_user_credential, save_file};
|
||||
use rusqlite::params;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LocalUserState {
|
||||
Managed,
|
||||
Released,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct UserResidency {
|
||||
pub user_id: i64,
|
||||
pub username: String,
|
||||
pub state: LocalUserState,
|
||||
pub data_present: bool,
|
||||
}
|
||||
|
||||
fn now_millis() -> i64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as i64
|
||||
}
|
||||
|
||||
pub fn add_user(user: UserProfile) {
|
||||
if let Err(e) = try_add_user(user) {
|
||||
|
|
@ -45,6 +61,12 @@ pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::Stora
|
|||
params![user.user_id, app_id, app_secret],
|
||||
)?;
|
||||
}
|
||||
conn.execute(
|
||||
r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, updated_at)
|
||||
VALUES (?1, ?2, 'managed', COALESCE((SELECT data_state FROM user_residency WHERE user_id = ?1), 'present'), ?3)
|
||||
ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, lifecycle_state = 'managed', updated_at = excluded.updated_at"#,
|
||||
params![user.user_id, user.username, now_millis()],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
|
@ -205,42 +227,92 @@ pub fn remove_user(user_id: i64) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Remove only local management authority. Hosted content is intentionally
|
||||
/// retained and is indexed as released for a later purge operation.
|
||||
pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
|
||||
let username = get_user(user_id).map(|user| user.username).ok_or_else(|| {
|
||||
crate::storage_error::StorageError::Other("managed user was not found".into())
|
||||
})?;
|
||||
db::with_db(|conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?;
|
||||
tx.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?;
|
||||
tx.execute(
|
||||
r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, updated_at)
|
||||
VALUES (?1, ?2, 'released', COALESCE((SELECT data_state FROM user_residency WHERE user_id = ?1), 'present'), ?3)
|
||||
ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, lifecycle_state = 'released', updated_at = excluded.updated_at"#,
|
||||
params![user_id, username, now_millis()],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})?;
|
||||
remove_user_credential(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))
|
||||
}
|
||||
|
||||
/// Authoritative hosted-data erasure used by local purge and future Omega
|
||||
/// erasure delivery. Management metadata and credentials are left intact.
|
||||
pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
|
||||
db::with_db(|conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute("DELETE FROM message_edits WHERE message_id IN (SELECT id FROM messages WHERE storage_owner = ?1)", params![user_id])?;
|
||||
tx.execute("DELETE FROM reactions WHERE message_id IN (SELECT id FROM messages WHERE storage_owner = ?1)", params![user_id])?;
|
||||
tx.execute("DELETE FROM messages WHERE storage_owner = ?1", params![user_id])?;
|
||||
tx.execute("DELETE FROM contacts WHERE storage_owner = ?1", params![user_id])?;
|
||||
tx.execute("DELETE FROM communities WHERE storage_owner = ?1", params![user_id])?;
|
||||
tx.execute("DELETE FROM settings WHERE user_id = ?1", params![user_id])?;
|
||||
tx.execute("DELETE FROM sync_events WHERE user_id = ?1", params![user_id])?;
|
||||
tx.execute("DELETE FROM sync_heads WHERE user_id = ?1", params![user_id])?;
|
||||
tx.execute("DELETE FROM client_sync_state WHERE user_id = ?1", params![user_id])?;
|
||||
tx.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?;
|
||||
tx.execute(
|
||||
"UPDATE user_residency SET data_state = 'empty', updated_at = ?2 WHERE user_id = ?1",
|
||||
params![user_id, now_millis()],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})?;
|
||||
crate::util::e2ee_storage::purge_user(user_id)
|
||||
.map_err(crate::storage_error::StorageError::Other)?;
|
||||
delete_user_directory(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))
|
||||
}
|
||||
|
||||
/// Complete local erasure is idempotent and is the target for a durable
|
||||
/// Omega-hosted erasure request after account deletion.
|
||||
pub fn erase_user_locally(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
|
||||
purge_user_data(user_id)?;
|
||||
db::with_db(|conn| {
|
||||
conn.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?;
|
||||
conn.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?;
|
||||
conn.execute("DELETE FROM user_residency WHERE user_id = ?1", params![user_id])?;
|
||||
Ok(())
|
||||
})?;
|
||||
remove_user_credential(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))
|
||||
}
|
||||
|
||||
pub fn get_residency() -> Vec<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() {
|
||||
if let Err(e) = db::with_db(|conn| {
|
||||
conn.execute_batch("DELETE FROM trusted_apps; DELETE FROM users;")?;
|
||||
conn.execute_batch("DELETE FROM trusted_apps; DELETE FROM users; DELETE FROM user_residency;")?;
|
||||
Ok(())
|
||||
}) {
|
||||
eprintln!("Failed to clear users: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn load_from_tu(username: &str) -> Result<(), ()> {
|
||||
let file_content = load_file("", &format!("{}.tu", username));
|
||||
let segments = file_content.split("::").collect::<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() {
|
||||
// No-op: users are auto-saved via SQLite.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose};
|
||||
use iota_util::file_util::{has_file, load_file, used_dir_space};
|
||||
use iota_util::file_util::{read_user_credential_with_legacy, used_dir_space};
|
||||
use json::{JsonValue, object};
|
||||
use rand::Rng;
|
||||
use rand::rngs::OsRng;
|
||||
|
|
@ -55,9 +55,11 @@ impl UserProfile {
|
|||
if let Some(d) = &self.display_name {
|
||||
obj["display_name"] = d.clone().into();
|
||||
}
|
||||
if has_file("", &format!("{}.tu", self.username.clone())) {
|
||||
obj["tu"] = load_file("", &format!("{}.tu", self.username.clone())).into();
|
||||
}
|
||||
// Frontend consumers must never receive private credential material.
|
||||
obj["has_tu"] = read_user_credential_with_legacy(self.user_id, &self.username)
|
||||
.map(|credential| credential.is_some())
|
||||
.unwrap_or(false)
|
||||
.into();
|
||||
obj
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -644,6 +644,62 @@ pub fn get_messages(
|
|||
}
|
||||
}
|
||||
|
||||
pub fn get_message(
|
||||
storage_owner: i64,
|
||||
message_time: i64,
|
||||
external_user: Option<i64>,
|
||||
) -> Result<Option<StoredMessage>, StorageError> {
|
||||
db::with_db(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
r#"
|
||||
SELECT id, message_time, content, sent_by_self, message_state, height,
|
||||
reply_to, edited_count, external_user
|
||||
FROM messages
|
||||
WHERE storage_owner = ?1
|
||||
AND message_time = ?2
|
||||
AND deleted_by_external = 0
|
||||
AND (?3 IS NULL OR external_user = ?3)
|
||||
ORDER BY id DESC
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map(params![storage_owner, message_time, external_user], |row| {
|
||||
Ok(StoredMessage {
|
||||
id: row.get(0)?,
|
||||
message_time: row.get(1)?,
|
||||
content: row.get(2)?,
|
||||
sent_by_self: row.get::<_, i64>(3)? != 0,
|
||||
message_state: row.get(4)?,
|
||||
height: row.get(5).unwrap_or(0),
|
||||
reply_to: row.get(6).ok().flatten(),
|
||||
edited: row.get::<_, i64>(7).unwrap_or(0) > 0,
|
||||
external_user: row.get(8)?,
|
||||
reactions: Vec::new(),
|
||||
})
|
||||
})?;
|
||||
|
||||
let messages: Vec<StoredMessage> = rows.collect::<Result<_, _>>()?;
|
||||
if messages.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if external_user.is_none()
|
||||
&& messages
|
||||
.iter()
|
||||
.map(|message| message.external_user)
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len()
|
||||
> 1
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut message = messages.into_iter().next().expect("checked non-empty");
|
||||
let reaction_map = load_reactions(conn, &[message.id]);
|
||||
message.reactions = reaction_map.get(&message.id).cloned().unwrap_or_default();
|
||||
Ok(Some(message))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_messages_by_ids(storage_owner: i64, ids: &[i64]) -> Vec<StoredMessage> {
|
||||
if ids.is_empty() {
|
||||
return Vec::new();
|
||||
|
|
|
|||
|
|
@ -251,6 +251,21 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> {
|
|||
)?;
|
||||
}
|
||||
|
||||
if current_version < 7 {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS user_residency (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
lifecycle_state TEXT NOT NULL CHECK (lifecycle_state IN ('managed', 'released')),
|
||||
data_state TEXT NOT NULL CHECK (data_state IN ('present', 'empty')),
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
PRAGMA user_version = 7;
|
||||
"#,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -320,7 +335,7 @@ mod tests {
|
|||
run_migrations_on_connection(&conn)?;
|
||||
|
||||
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
|
||||
assert_eq!(version, 6);
|
||||
assert_eq!(version, 7);
|
||||
for column in ["height", "reply_to", "edited_count", "deleted_by_external"] {
|
||||
let mut statement =
|
||||
conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?;
|
||||
|
|
@ -337,8 +352,8 @@ mod tests {
|
|||
run_migrations_on_connection(&conn)?;
|
||||
run_migrations_on_connection(&conn)?;
|
||||
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
|
||||
assert_eq!(version, 6);
|
||||
for table in ["sync_heads", "sync_events", "client_sync_state"] {
|
||||
assert_eq!(version, 7);
|
||||
for table in ["sync_heads", "sync_events", "client_sync_state", "user_residency"] {
|
||||
let exists: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||
[table],
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
if query.user_id.is_empty() || query.chat_id.is_empty() {
|
||||
return Ok(None);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ sysinfo = "0.39.0"
|
|||
uuid = { version = "*", features = ["v4"] }
|
||||
walkdir = "2.5.0"
|
||||
zip = "6.0.0"
|
||||
aes-gcm = "0.10.3"
|
||||
aes-gcm = "0.11.0"
|
||||
base64 = "0.22.1"
|
||||
rand_core = { version = "0.6", features = ["getrandom", "std"] }
|
||||
sha2 = "0.11.0"
|
||||
|
|
|
|||
|
|
@ -33,11 +33,70 @@ fn delete_dir_recursive(directory: &Path) -> bool {
|
|||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn delete_user_directory(user_id: i64) {
|
||||
pub fn delete_user_directory(user_id: i64) -> io::Result<()> {
|
||||
let user_dir = Path::new(&get_directory())
|
||||
.join("users")
|
||||
.join(user_id.to_string());
|
||||
let _ = delete_dir_recursive(&user_dir);
|
||||
if !user_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
fs::remove_dir_all(user_dir)
|
||||
}
|
||||
|
||||
pub fn credential_path(user_id: i64) -> PathBuf {
|
||||
storage_directory().join("credentials").join(format!("{user_id}.tu"))
|
||||
}
|
||||
|
||||
pub fn read_user_credential(user_id: i64) -> io::Result<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>> {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod crypto_helper;
|
||||
pub mod crypto_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-paths = { path = "../iota-paths" }
|
||||
iota-terms = { path = "../iota-terms" }
|
||||
iota-util = { path = "../iota-util" }
|
||||
tokio = { version = "1.50.0", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
serde_json = "1"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind};
|
||||
use iota_cli::theme::{CliOutputFormat, ThemeName, UiConfig};
|
||||
use iota_terms::TermsType;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CliInvocation {
|
||||
|
|
@ -115,15 +116,33 @@ enum UsersAction {
|
|||
user_id: i64,
|
||||
},
|
||||
Add {
|
||||
username: String,
|
||||
username: Option<String>,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
tu: Option<PathBuf>,
|
||||
},
|
||||
Remove {
|
||||
Release {
|
||||
user_id: i64,
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
Import {
|
||||
username: String,
|
||||
Data {
|
||||
#[command(subcommand)]
|
||||
action: UserDataAction,
|
||||
},
|
||||
CompleteDelete {
|
||||
user_id: i64,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
tu: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum UserDataAction {
|
||||
Purge {
|
||||
user_id: i64,
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
#[derive(Args, Debug)]
|
||||
|
|
@ -264,14 +283,21 @@ pub enum Command {
|
|||
user_id: i64,
|
||||
},
|
||||
UsersAdd {
|
||||
username: String,
|
||||
username: Option<String>,
|
||||
tu: Option<PathBuf>,
|
||||
},
|
||||
UsersRemove {
|
||||
UsersRelease {
|
||||
user_id: i64,
|
||||
confirmed: bool,
|
||||
},
|
||||
UsersImport {
|
||||
username: String,
|
||||
UsersPurgeData {
|
||||
user_id: i64,
|
||||
confirmed: bool,
|
||||
},
|
||||
UsersCompleteDelete {
|
||||
user_id: i64,
|
||||
tu: Option<PathBuf>,
|
||||
confirmed: bool,
|
||||
},
|
||||
OmikronReconnect,
|
||||
IdentityRotate {
|
||||
|
|
@ -367,12 +393,29 @@ impl CliInvocation {
|
|||
Some(CliCommand::Users(users)) => match users.action {
|
||||
UsersAction::List => Command::UsersList,
|
||||
UsersAction::Show { user_id } => Command::UsersShow { user_id },
|
||||
UsersAction::Add { username } => Command::UsersAdd { username },
|
||||
UsersAction::Remove { user_id, yes } => Command::UsersRemove {
|
||||
UsersAction::Add { username, tu } => {
|
||||
if username.is_some() == tu.is_some() {
|
||||
return Err(
|
||||
"users add requires exactly one of <username> or --tu <PATH>".into(),
|
||||
);
|
||||
}
|
||||
Command::UsersAdd { username, tu }
|
||||
}
|
||||
UsersAction::Release { user_id, yes } => Command::UsersRelease {
|
||||
user_id,
|
||||
confirmed: resolve_confirmed(yes),
|
||||
},
|
||||
UsersAction::Import { username } => Command::UsersImport { username },
|
||||
UsersAction::Data {
|
||||
action: UserDataAction::Purge { user_id, yes },
|
||||
} => Command::UsersPurgeData {
|
||||
user_id,
|
||||
confirmed: resolve_confirmed(yes),
|
||||
},
|
||||
UsersAction::CompleteDelete { user_id, tu, yes } => Command::UsersCompleteDelete {
|
||||
user_id,
|
||||
tu,
|
||||
confirmed: resolve_confirmed(yes),
|
||||
},
|
||||
},
|
||||
Some(CliCommand::Omikron(omikron)) => match omikron.action {
|
||||
OmikronAction::Reconnect => Command::OmikronReconnect,
|
||||
|
|
@ -445,6 +488,7 @@ impl CliInvocation {
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub fn help_text() -> String {
|
||||
Cli::command().render_long_help().to_string()
|
||||
}
|
||||
|
|
@ -528,7 +572,7 @@ mod tests {
|
|||
#[test]
|
||||
fn command_schema_drives_help_and_completion_paths() {
|
||||
let paths = CliInvocation::command_paths();
|
||||
assert!(paths.contains(&"users remove".to_owned()));
|
||||
assert!(paths.contains(&"users release".to_owned()));
|
||||
assert!(paths.contains(&"daemon install".to_owned()));
|
||||
let help = CliInvocation::help_text();
|
||||
assert!(help.contains("users"));
|
||||
|
|
@ -558,12 +602,7 @@ mod tests {
|
|||
#[test]
|
||||
fn parses_unconfirmed_destructive_commands_explicitly() {
|
||||
let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap();
|
||||
assert_eq!(
|
||||
invocation.command,
|
||||
Command::DaemonStop {
|
||||
confirmed: true
|
||||
}
|
||||
);
|
||||
assert_eq!(invocation.command, Command::DaemonStop { confirmed: true });
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -573,7 +612,8 @@ mod tests {
|
|||
assert_eq!(
|
||||
invocation.command,
|
||||
Command::UsersAdd {
|
||||
username: "alice".into()
|
||||
username: Some("alice".into()),
|
||||
tu: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -597,26 +637,24 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn parses_users_remove_without_confirmation() {
|
||||
let invocation =
|
||||
CliInvocation::parse(["users".into(), "remove".into(), "42".into()]).unwrap();
|
||||
assert_eq!(
|
||||
invocation.command,
|
||||
Command::UsersRemove {
|
||||
user_id: 42,
|
||||
confirmed: true,
|
||||
}
|
||||
);
|
||||
fn rejects_ambiguous_users_remove() {
|
||||
let error =
|
||||
CliInvocation::parse(["users".into(), "remove".into(), "42".into()]).unwrap_err();
|
||||
assert!(error.contains("remove"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_users_remove_with_confirmation() {
|
||||
let invocation =
|
||||
CliInvocation::parse(["users".into(), "remove".into(), "42".into(), "--yes".into()])
|
||||
.unwrap();
|
||||
fn parses_users_release_with_confirmation() {
|
||||
let invocation = CliInvocation::parse([
|
||||
"users".into(),
|
||||
"release".into(),
|
||||
"42".into(),
|
||||
"--yes".into(),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
invocation.command,
|
||||
Command::UsersRemove {
|
||||
Command::UsersRelease {
|
||||
user_id: 42,
|
||||
confirmed: true,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -403,7 +403,9 @@ fn print_help() {
|
|||
println!(" users list List all users");
|
||||
println!(" users show <ID> Show user details");
|
||||
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 reconnect Reconnect to Omikron");
|
||||
println!(" identity rotate Rotate identity keys (requires --yes)");
|
||||
|
|
@ -451,7 +453,7 @@ fn print_help() {
|
|||
println!(" iota status Show daemon status");
|
||||
println!(" iota users list --output=json List users in JSON format");
|
||||
println!(" iota users add alice Create a user named 'alice'");
|
||||
println!(" iota users remove 42 --yes Remove user 42");
|
||||
println!(" iota users data purge 42 --yes Purge hosted data");
|
||||
println!(" iota config get --output=yaml Show config in YAML format");
|
||||
println!(" iota logs --limit 50 Show last 50 log entries");
|
||||
println!(" iota completions bash Generate bash completions");
|
||||
|
|
@ -564,12 +566,40 @@ async fn run_command(
|
|||
Command::Tasks => LocalRequest::ListTasks,
|
||||
Command::UsersList => LocalRequest::ListUsers,
|
||||
Command::UsersShow { user_id } => LocalRequest::GetUser { user_id },
|
||||
Command::UsersAdd { username } => LocalRequest::CreateUser { username },
|
||||
Command::UsersRemove {
|
||||
Command::UsersAdd { username: Some(username), tu: None } => LocalRequest::CreateUser { username },
|
||||
Command::UsersAdd { username: None, tu: Some(path) } => {
|
||||
let contents = std::fs::read_to_string(&path)
|
||||
.map_err(|error| StartupError::InvalidCommand(format!("Cannot read {}: {error}", path.display())))?;
|
||||
iota_util::tu::TuCredential::parse(&contents)
|
||||
.map_err(|error| StartupError::InvalidCommand(format!("Invalid credential {}: {error}", path.display())))?;
|
||||
LocalRequest::AttachUserFromTu { credential: iota_ipc::SecretString(contents) }
|
||||
}
|
||||
Command::UsersAdd { .. } => {
|
||||
return Err(StartupError::InvalidCommand(
|
||||
"users add requires exactly one of <username> or --tu <PATH>".into(),
|
||||
));
|
||||
}
|
||||
Command::UsersRelease {
|
||||
user_id,
|
||||
confirmed: true,
|
||||
} => LocalRequest::RemoveUser { user_id },
|
||||
Command::UsersImport { username } => LocalRequest::ImportUser { username },
|
||||
} => LocalRequest::ReleaseUser { user_id },
|
||||
Command::UsersPurgeData { user_id, confirmed: true } => LocalRequest::PurgeUserData { user_id },
|
||||
Command::UsersCompleteDelete { user_id, tu, confirmed: true } => {
|
||||
let credential = match tu {
|
||||
Some(path) => {
|
||||
let contents = std::fs::read_to_string(&path)
|
||||
.map_err(|error| StartupError::InvalidCommand(format!("Cannot read {}: {error}", path.display())))?;
|
||||
let parsed = iota_util::tu::TuCredential::parse(&contents)
|
||||
.map_err(|error| StartupError::InvalidCommand(format!("Invalid credential {}: {error}", path.display())))?;
|
||||
if parsed.user_id != user_id {
|
||||
return Err(StartupError::InvalidCommand("credential user ID does not match complete-delete target".into()));
|
||||
}
|
||||
Some(iota_ipc::SecretString(contents))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
LocalRequest::CompleteDeleteUser { user_id, credential }
|
||||
}
|
||||
Command::OmikronReconnect => LocalRequest::ReconnectOmikron,
|
||||
Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity,
|
||||
Command::RegenerateKeys { confirmed: true } => LocalRequest::RotateIotaIdentity,
|
||||
|
|
@ -588,9 +618,11 @@ async fn run_command(
|
|||
Command::Logs { limit } => LocalRequest::GetLogs { limit },
|
||||
Command::UpdateCheck => LocalRequest::CheckUpdate,
|
||||
Command::CommunityList => LocalRequest::ListCommunities,
|
||||
Command::UsersRemove {
|
||||
Command::UsersRelease {
|
||||
confirmed: false, ..
|
||||
}
|
||||
| Command::UsersPurgeData { confirmed: false, .. }
|
||||
| Command::UsersCompleteDelete { confirmed: false, .. }
|
||||
| Command::IdentityRotate { confirmed: false }
|
||||
| Command::RegenerateKeys { confirmed: false }
|
||||
| Command::DaemonRestart { confirmed: false }
|
||||
|
|
@ -689,6 +721,9 @@ async fn run_command(
|
|||
user_id
|
||||
);
|
||||
}
|
||||
ResponsePayload::UserDataPurged { user_id } => {
|
||||
println!("{} hosted data for {}. Account remains managed by this Iota.", cli_color::success(&color, "Purged"), user_id);
|
||||
}
|
||||
ResponsePayload::Acknowledged { message } => {
|
||||
println!("{}", message);
|
||||
}
|
||||
|
|
@ -935,6 +970,9 @@ fn render_table(payload: &ResponsePayload) {
|
|||
ResponsePayload::UserRemoved { user_id } => {
|
||||
println!("Removed user {}", user_id);
|
||||
}
|
||||
ResponsePayload::UserDataPurged { user_id } => {
|
||||
println!("Purged hosted data for {}", user_id);
|
||||
}
|
||||
ResponsePayload::Acknowledged { message } => {
|
||||
println!("{}", message);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -884,6 +884,7 @@ impl OmikronConnection {
|
|||
dispatch!(MessageReactionLive, handle_message_reaction_live);
|
||||
dispatch!(MessageDeleteLive, handle_message_delete_live);
|
||||
dispatch!(MessageOtherIota, handle_message_other_iota);
|
||||
dispatch!(MessageGet, handle_message_get);
|
||||
dispatch!(MessagesGet, handle_messages_get);
|
||||
dispatch!(GetChats, handle_get_chats);
|
||||
dispatch!(AddConversation, handle_add_conversation);
|
||||
|
|
@ -895,12 +896,41 @@ impl OmikronConnection {
|
|||
dispatch!(SettingsSave, handle_settings_save);
|
||||
dispatch!(SettingsLoad, handle_settings_load);
|
||||
dispatch!(SettingsList, handle_settings_list);
|
||||
dispatch!(EraseHostedUserData, handle_erase_hosted_user_data);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Message Handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Omega-authorized account cleanup. The storage operation is idempotent;
|
||||
/// acknowledgement is therefore safe to retry after a reconnect.
|
||||
async fn handle_erase_hosted_user_data(self: Arc<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) {
|
||||
let sender_id = cv.get_sender().to_string();
|
||||
let recipients = match chat_secret_recipients(cv) {
|
||||
|
|
@ -1447,6 +1477,24 @@ impl OmikronConnection {
|
|||
let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64;
|
||||
let reply_to = cv.get_data(DataType::ReplyId).as_number().map(|n| n as i64);
|
||||
|
||||
if let Some(reply_to) = reply_to {
|
||||
match chat_files::get_message(sender_id as i64, reply_to, Some(receiver_id)) {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
let _ = self
|
||||
.send_message(&error_response(cv, CommunicationType::ErrorNotFound))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = self
|
||||
.send_message(&error_response(cv, CommunicationType::ErrorInvalidData))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let is_local = iota_storage::users::user_manager::get_user(receiver_id).is_some();
|
||||
|
||||
if is_local {
|
||||
|
|
@ -1701,6 +1749,12 @@ impl OmikronConnection {
|
|||
.await;
|
||||
}
|
||||
|
||||
async fn handle_message_get(self: Arc<Self>, cv: &CommunicationValue) {
|
||||
let _ = self
|
||||
.send_message(&message_handlers::handle_message_get(cv))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn handle_get_chats(self: Arc<Self>, cv: &CommunicationValue) {
|
||||
let _ = self
|
||||
.send_message(&message_handlers::handle_get_chats(cv))
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@ use base64::{Engine as _, engine::general_purpose::STANDARD};
|
|||
use iota_logger::{PrintType, log, log_cv, log_t};
|
||||
use iota_storage::users::user_manager::try_add_user;
|
||||
use iota_storage::users::user_profile::UserProfile;
|
||||
use iota_storage::util::config_util::CONFIG;
|
||||
use iota_util::crypto_helper::{self, hex_hash, public_key_bundle_to_base64};
|
||||
use iota_util::file_util::try_save_file;
|
||||
use iota_util::file_util::write_user_credential;
|
||||
use iota_util::tu::TuCredential;
|
||||
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||
use mtp::crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme};
|
||||
use rand_core::{OsRng, RngCore};
|
||||
use std::time::Duration;
|
||||
|
||||
|
|
@ -20,6 +23,133 @@ pub enum CreateUserError {
|
|||
LocalPersistence(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LifecycleUserError {
|
||||
InvalidCredential(String),
|
||||
OmegaHostMismatch,
|
||||
RemoteRejected,
|
||||
Transport(crate::OmikronError),
|
||||
LocalPersistence(String),
|
||||
}
|
||||
|
||||
impl From<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 {
|
||||
!username.is_empty()
|
||||
&& username.chars().count() <= 15
|
||||
|
|
@ -136,15 +266,9 @@ pub async fn create_user(
|
|||
}
|
||||
}
|
||||
log!("Created User");
|
||||
try_save_file(
|
||||
"",
|
||||
&format!("{}.tu", username),
|
||||
&format!(
|
||||
"{}@{}::{}",
|
||||
user_id,
|
||||
omega_discovery::omega_host(),
|
||||
keyring_b64
|
||||
),
|
||||
write_user_credential(
|
||||
user_id,
|
||||
&format!("{}@{}::{}", user_id, omega_discovery::omega_host(), keyring_b64),
|
||||
)
|
||||
.map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?;
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ iota-auth = { path = "../iota-auth" }
|
|||
|
||||
actix-web = { version = "4", features = ["rustls-0_23"] }
|
||||
actix-web-actors = "4"
|
||||
aes-gcm = "0.10.3"
|
||||
aes-gcm = "0.11.0"
|
||||
async-trait = "0.1.89"
|
||||
base64 = "0.22.1"
|
||||
chrono = "0.4.43"
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ mtp = { git = "https://git.methanium.net/Methanium/mtp.git" }
|
|||
|
||||
actix-web = { version = "4", features = ["rustls-0_23"] }
|
||||
actix-web-actors = "4"
|
||||
aes-gcm = "0.10.3"
|
||||
aes-gcm = "0.11.0"
|
||||
async-trait = "0.1.89"
|
||||
base64 = "0.22.1"
|
||||
chrono = "0.4.43"
|
||||
|
|
|
|||
Loading…
Reference in a new issue