[Fix] User deletion & migration

This commit is contained in:
Alex 2026-08-09 02:50:54 +02:00
commit d3bcf56b59
Signed by: alex
SSH key fingerprint: SHA256:D1+Ub8o0v4K5y1JNivW8IxEOelqLSvPmUzBbDIoZkRQ
14 changed files with 456 additions and 148 deletions

View file

@ -2,8 +2,9 @@ use super::super::omikron_connection::{OmikronConnection, OmikronResult};
use crate::{
db::{iota_repo, user_repo},
models::{IotaId, UserId},
state::AccountChallengeOperation,
};
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use mtp::{codec::{CommunicationType, CommunicationValue, DataType, DataValue}, crypto::{verify_ed25519, verify_ml_dsa}};
use std::sync::Arc;
async fn delete(
@ -23,12 +24,8 @@ pub async fn user(
connection: Arc<OmikronConnection>,
value: CommunicationValue,
) -> OmikronResult<()> {
delete(
connection,
value.clone(),
user_repo::delete_user(UserId::from(value.get_sender() as i64)),
)
.await
let user_id = UserId::from(value.get_sender() as i64);
complete_delete(connection, value, user_id).await
}
pub async fn iota(
connection: Arc<OmikronConnection>,
@ -41,3 +38,157 @@ pub async fn iota(
)
.await
}
pub async fn release_from_iota(
connection: Arc<OmikronConnection>,
value: CommunicationValue,
) -> OmikronResult<()> {
let Some(user_id) = value
.get_data(DataType::UserId)
.as_signed_number()
.and_then(|id| i64::try_from(id).ok())
.filter(|id| *id > 0)
else {
return connection
.send_error_response(value.get_id(), CommunicationType::ErrorInvalidUserId)
.await;
};
let requester = IotaId::from(value.get_sender() as i64);
let Ok(user) = user_repo::get_by_user_id(UserId::from(user_id)).await else {
return connection
.send_error_response(value.get_id(), CommunicationType::ErrorNotFound)
.await;
};
if user.iota_id != Some(requester) {
return connection
.send_error_response(value.get_id(), CommunicationType::ErrorNotAuthenticated)
.await;
}
let previous_iota = user.iota_id;
match user_repo::change_iota_id(user.id, None).await {
Ok(()) => {
if let Some(iota) = previous_iota { crate::transport::omikron_manager::publish_iota_user_snapshot(iota.0).await; }
connection.send(&CommunicationValue::new(CommunicationType::Success).with_id(value.get_id())).await
},
Err(error) => connection
.send(&CommunicationValue::new(CommunicationType::ErrorInternal)
.with_id(value.get_id())
.add_typed_default(DataType::ErrorType, DataValue::Str(error.to_string())))
.await,
}
}
fn lifecycle_payload(domain: &[u8], user_id: i64, iota_id: i64, nonce: u64) -> Vec<u8> {
let mut payload = Vec::with_capacity(domain.len() + 32);
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
}
pub async fn attach_begin(connection: Arc<OmikronConnection>, value: CommunicationValue) -> OmikronResult<()> {
let Some(user_id) = value.get_data(DataType::UserId).as_signed_number().and_then(|v| i64::try_from(v).ok()).filter(|v| *v > 0) else {
return connection.send_error_response(value.get_id(), CommunicationType::ErrorInvalidUserId).await;
};
if user_repo::get_by_user_id(UserId::from(user_id)).await.is_err() {
return connection.send_error_response(value.get_id(), CommunicationType::ErrorNotFound).await;
}
let requester = value.get_sender() as i64;
let nonce = connection.state().issue_challenge(AccountChallengeOperation::Attach, user_id, requester);
connection.send(&CommunicationValue::new(CommunicationType::AttachUserChallenge).with_id(value.get_id())
.add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into()))
.add_typed_default(DataType::ServerNonce, DataValue::SignedNumber(nonce.into()))).await
}
pub async fn attach_complete(connection: Arc<OmikronConnection>, value: CommunicationValue) -> OmikronResult<()> {
let Some(user_id) = value.get_data(DataType::UserId).as_signed_number().and_then(|v| i64::try_from(v).ok()).filter(|v| *v > 0) else { return connection.send_error_response(value.get_id(), CommunicationType::ErrorInvalidUserId).await; };
let requester = value.get_sender() as i64;
let Some(nonce) = value.get_data(DataType::ServerNonce).as_signed_number().and_then(|v| u64::try_from(v).ok()) else { return connection.send_error_response(value.get_id(), CommunicationType::ErrorInvalidChallenge).await; };
let signature = value.get_data(DataType::Signature).as_bytes();
let pq_signature = value.get_data(DataType::PqSignature).as_bytes();
let (Some(signature), Some(pq_signature)) = (signature, pq_signature) else { return connection.send_error_response(value.get_id(), CommunicationType::ErrorInvalidChallenge).await; };
if !connection.state().consume_challenge(AccountChallengeOperation::Attach, user_id, requester, nonce) { return connection.send_error_response(value.get_id(), CommunicationType::ErrorInvalidChallenge).await; }
let Ok(user) = user_repo::get_by_user_id(UserId::from(user_id)).await else { return connection.send_error_response(value.get_id(), CommunicationType::ErrorNotFound).await; };
let payload = lifecycle_payload(b"tensamin:user-attach:v1\0", user_id, requester, nonce);
if verify_ed25519(&user.public_key.sig_cl_public_key, &payload, &signature).is_err() || verify_ml_dsa(&user.public_key.sig_pq_public_key, &payload, &pq_signature).is_err() { return connection.send_error_response(value.get_id(), CommunicationType::ErrorNotAuthenticated).await; }
let previous_iota = user.iota_id;
match user_repo::change_iota_id(user.id, Some(IotaId::from(requester))).await {
Ok(()) => {
if let Some(iota) = previous_iota.filter(|id| id.0 != requester) { crate::transport::omikron_manager::publish_iota_user_snapshot(iota.0).await; }
crate::transport::omikron_manager::publish_iota_user_snapshot(requester).await;
connection.send(&CommunicationValue::new(CommunicationType::Success).with_id(value.get_id())).await
},
Err(_) => connection.send_error_response(value.get_id(), CommunicationType::ErrorInternal).await,
}
}
async fn complete_delete(connection: Arc<OmikronConnection>, value: CommunicationValue, user_id: UserId) -> OmikronResult<()> {
match user_repo::delete_user_with_pending_erasure(user_id).await {
Ok(iota_id) => {
let cleanup_pending = iota_id.is_some();
if let Some(iota_id) = iota_id {
crate::transport::omikron_manager::publish_iota_user_snapshot(iota_id.0).await;
crate::transport::omikron_manager::deliver_pending_erasures(iota_id.0).await;
}
connection.send(&CommunicationValue::new(CommunicationType::Success).with_id(value.get_id())
.add_typed_default(DataType::CleanupPending, DataValue::Bool(cleanup_pending))).await
}
Err(crate::error::OmegaError::NotFound) => connection.send_error_response(value.get_id(), CommunicationType::ErrorNotFound).await,
Err(error) => connection.send(&CommunicationValue::new(CommunicationType::ErrorInternal).with_id(value.get_id()).add_typed_default(DataType::ErrorType, DataValue::Str(error.to_string()))).await,
}
}
pub async fn delete_credential_begin(connection: Arc<OmikronConnection>, value: CommunicationValue) -> OmikronResult<()> {
let Some(user_id) = value.get_data(DataType::UserId).as_signed_number().and_then(|v| i64::try_from(v).ok()).filter(|v| *v > 0) else {
return connection.send_error_response(value.get_id(), CommunicationType::ErrorInvalidUserId).await;
};
if user_repo::get_by_user_id(UserId::from(user_id)).await.is_err() { return connection.send_error_response(value.get_id(), CommunicationType::ErrorNotFound).await; }
let requester = value.get_sender() as i64;
let nonce = connection.state().issue_challenge(AccountChallengeOperation::Delete, user_id, requester);
connection.send(&CommunicationValue::new(CommunicationType::DeleteUserCredentialChallenge).with_id(value.get_id())
.add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into()))
.add_typed_default(DataType::ServerNonce, DataValue::SignedNumber(nonce.into()))).await
}
pub async fn delete_credential_complete(connection: Arc<OmikronConnection>, value: CommunicationValue) -> OmikronResult<()> {
let Some(user_id) = value.get_data(DataType::UserId).as_signed_number().and_then(|v| i64::try_from(v).ok()).filter(|v| *v > 0) else { return connection.send_error_response(value.get_id(), CommunicationType::ErrorInvalidUserId).await; };
let requester = value.get_sender() as i64;
let Some(nonce) = value.get_data(DataType::ServerNonce).as_signed_number().and_then(|v| u64::try_from(v).ok()) else { return connection.send_error_response(value.get_id(), CommunicationType::ErrorInvalidChallenge).await; };
let (Some(signature), Some(pq_signature)) = (value.get_data(DataType::Signature).as_bytes(), value.get_data(DataType::PqSignature).as_bytes()) else { return connection.send_error_response(value.get_id(), CommunicationType::ErrorInvalidChallenge).await; };
if !connection.state().consume_challenge(AccountChallengeOperation::Delete, user_id, requester, nonce) { return connection.send_error_response(value.get_id(), CommunicationType::ErrorInvalidChallenge).await; }
let Ok(user) = user_repo::get_by_user_id(UserId::from(user_id)).await else { return connection.send_error_response(value.get_id(), CommunicationType::ErrorNotFound).await; };
let payload = lifecycle_payload(b"tensamin:user-delete:v1\0", user_id, requester, nonce);
if verify_ed25519(&user.public_key.sig_cl_public_key, &payload, &signature).is_err() || verify_ml_dsa(&user.public_key.sig_pq_public_key, &payload, &pq_signature).is_err() { return connection.send_error_response(value.get_id(), CommunicationType::ErrorNotAuthenticated).await; }
complete_delete(connection, value, user.id).await
}
pub async fn erase_hosted_user_data_ack(connection: Arc<OmikronConnection>, value: CommunicationValue) -> OmikronResult<()> {
let Some(user_id) = value.get_data(DataType::UserId).as_signed_number().and_then(|v| i64::try_from(v).ok()).filter(|v| *v > 0) else { return connection.send_error_response(value.get_id(), CommunicationType::ErrorInvalidUserId).await; };
let iota_id = IotaId::from(value.get_sender() as i64);
match user_repo::acknowledge_pending_erasure(UserId::from(user_id), iota_id).await {
Ok(true) => connection.send(&CommunicationValue::new(CommunicationType::Success).with_id(value.get_id())).await,
Ok(false) => connection.send_error_response(value.get_id(), CommunicationType::ErrorNotAuthenticated).await,
Err(_) => connection.send_error_response(value.get_id(), CommunicationType::ErrorInternal).await,
}
}
/// New lifecycle operation names are intentionally fail-closed until their
/// proof and durable-erasure handlers are enabled. This explicit dispatch
/// prevents either a bare Iota request or the legacy DeleteUser path from
/// acquiring account-deletion authority during a staged rollout.
pub async fn lifecycle_unavailable(
connection: Arc<OmikronConnection>,
value: CommunicationValue,
) -> OmikronResult<()> {
connection
.send(
&CommunicationValue::new(CommunicationType::ErrorNotAuthenticated)
.with_id(value.get_id())
.add_typed_default(
DataType::ErrorType,
DataValue::Str("user lifecycle proof handler is not enabled".into()),
),
)
.await
}

View file

@ -53,7 +53,7 @@ fn states_for_users(state: &OmegaState, users: &[crate::models::User]) -> HashMa
user.id.0,
state
.presence
.resolve_public_state(user.id.0, user.iota_id.0),
.resolve_public_state(user.id.0, user.iota_id.map(|id| id.0).unwrap_or_default()),
)
})
.collect()
@ -69,7 +69,7 @@ fn changed_states(
.filter_map(|user| {
let after = state
.presence
.resolve_public_state(user.id.0, user.iota_id.0);
.resolve_public_state(user.id.0, user.iota_id.map(|id| id.0).unwrap_or_default());
(before.get(&user.id.0) != Some(&after)).then_some((user.id.0, after))
})
.collect::<Vec<_>>();
@ -275,7 +275,7 @@ pub async fn user_connected(
Ok(preferences) => preferences,
Err(error) => return Err(error.into()),
};
if user.iota_id.0 != iota_id || !state.presence.has_iota_route(iota_id) {
if user.iota_id.map(|id| id.0) != Some(iota_id) || !state.presence.has_iota_route(iota_id) {
return connection
.send_error_response(value.get_id(), CommunicationType::ErrorNoIota)
.await;
@ -497,6 +497,7 @@ pub async fn iota_connected(
.add_typed_default(DataType::IotaId, DataValue::SignedNumber(iota_id.into()))
.add_typed_default(DataType::UserIds, DataValue::Array(user_ids));
connection.clone().send(&response).await?;
crate::transport::omikron_manager::deliver_pending_erasures(iota_id).await;
publish_changed_states(&state, &before, &users).await;
connection
.send(&CommunicationValue::new(CommunicationType::Success).with_id(value.get_id()))

View file

@ -110,7 +110,7 @@ pub async fn get(
};
let status = state
.presence
.resolve_public_state(user_id, user.iota_id.0)
.resolve_public_state(user_id, user.iota_id.map(|id| id.0).unwrap_or_default())
.to_string();
let mut map = Vec::new();
if let Some(kind) = DataType::UserId.try_to_id(&tm) {

View file

@ -43,7 +43,7 @@ pub async fn get_user(
.await;
};
let id = user.id.0;
let iota_id = user.iota_id.0;
let iota_id = user.iota_id.map(|id| id.0);
let username = user.username.clone();
let display = user
.display
@ -57,7 +57,6 @@ pub async fn get_user(
DataValue::Str(user.public_key.to_base64()),
)
.add_typed_default(DataType::UserId, DataValue::SignedNumber(id.into()))
.add_typed_default(DataType::IotaId, DataValue::SignedNumber(iota_id.into()))
.add_typed_default(DataType::Display, DataValue::Str(display))
.add_typed_default(
DataType::SubLevel,
@ -92,7 +91,7 @@ pub async fn get_user(
}
state.presence.resolve_private_state(id)
} else {
state.presence.resolve_public_state(id, iota_id)
iota_id.map(|iota_id| state.presence.resolve_public_state(id, iota_id)).unwrap_or(crate::sql::connection_status::UserStatus::user_offline)
};
response = response
.add_typed_default(
@ -101,8 +100,11 @@ pub async fn get_user(
)
.add_typed_default(
DataType::OmikronConnections,
connections(&connection, iota_id),
iota_id.map(|iota_id| connections(&connection, iota_id)).unwrap_or_else(|| DataValue::Array(Vec::new())),
);
if let Some(iota_id) = iota_id {
response = response.add_typed_default(DataType::IotaId, DataValue::SignedNumber(iota_id.into()));
}
if let Some(route) = route {
response = response.add_typed_default(
DataType::OmikronId,
@ -123,26 +125,25 @@ pub async fn get_iota(
.map(|iota| (iota.id.0, iota.public_key, None, None))
} else if let Some(id) = value.get_data(DataType::UserId).as_number() {
if let Ok(user) = user_repo::get_by_user_id(UserId::from(id as i64)).await {
iota_repo::get_iota_by_id(user.iota_id)
.await
.ok()
.map(|iota| (iota.id.0, iota.public_key, Some(user.id.0), None))
match user.iota_id {
Some(iota_id) => iota_repo::get_iota_by_id(iota_id)
.await
.ok()
.map(|iota| (iota.id.0, iota.public_key, Some(user.id.0), None)),
None => None,
}
} else {
None
}
} else if let Some(name) = value.get_data(DataType::Username).as_str() {
if let Ok(user) = user_repo::get_by_username(name).await {
iota_repo::get_iota_by_id(user.iota_id)
.await
.ok()
.map(|iota| {
(
iota.id.0,
iota.public_key,
Some(user.id.0),
Some(name.to_owned()),
)
})
match user.iota_id {
Some(iota_id) => iota_repo::get_iota_by_id(iota_id)
.await
.ok()
.map(|iota| (iota.id.0, iota.public_key, Some(user.id.0), Some(name.to_owned()))),
None => None,
}
} else {
None
}
@ -269,7 +270,7 @@ pub async fn change_iota(
.await;
}
let result =
match user_repo::change_iota_id(user_id, IotaId::from(value.get_sender() as i64)).await {
match user_repo::change_iota_id(user_id, Some(IotaId::from(value.get_sender() as i64))).await {
Ok(()) => user_repo::change_token(user_id, new_token.to_owned()).await,
Err(error) => Err(error),
};