use base64::{Engine as _, engine::general_purpose::STANDARD}; use iota_logger::{PrintType, log, log_cv, log_t}; use iota_storage::users::pending_operations::{ self, PendingUserOperation, PendingUserOperationKind, PendingUserOperationPhase, }; 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::{remove_user_credential, write_user_credential}; use iota_util::mtp_compat::OptionalDataValueExt; 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, SystemTime, UNIX_EPOCH}; use crate::OmikronClient; use crate::omega_discovery; #[derive(Debug)] pub enum CreateUserError { InvalidUsername, Transport(crate::OmikronError), InvalidResponse, RemoteRejected, LocalFinalizationPending { user_id: i64 }, LocalPersistence(String), } #[derive(Debug)] pub enum LifecycleUserError { InvalidCredential(String), OmegaHostMismatch, RemoteRejected, Transport(crate::OmikronError), LocalPersistence(String), } impl From for LifecycleUserError { fn from(value: crate::OmikronError) -> Self { Self::Transport(value) } } fn lifecycle_payload(domain: &[u8], user_id: i64, iota_id: i64, nonce: u64) -> Vec { let mut payload = Vec::with_capacity(domain.len() + 24); payload.extend_from_slice(domain); payload.extend_from_slice(&user_id.to_be_bytes()); payload.extend_from_slice(&iota_id.to_be_bytes()); payload.extend_from_slice(&nonce.to_be_bytes()); payload } fn configured_iota_id() -> Result { CONFIG .load() .iota_id .and_then(|id| i64::try_from(id).ok()) .filter(|id| *id > 0) .ok_or_else(|| { LifecycleUserError::InvalidCredential("Iota identity is not registered".into()) }) } fn sign_lifecycle_payload( credential: &TuCredential, payload: &[u8], ) -> Result<(Vec, Vec), LifecycleUserError> { let classical = Ed25519Signer::new(&credential.keyring.sig_cl_secret_key) .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))? .sign(payload) .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; let pq = MlDsaSigner::new( &credential.keyring.sig_pq_secret_key, &credential.keyring.sig_pq_public_key, ) .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))? .sign(payload) .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; Ok((classical, pq)) } async fn inspect_credential_account( connection: &dyn OmikronClient, credential: &TuCredential, ) -> Result<(String, String), LifecycleUserError> { if credential.omega_host != omega_discovery::omega_host() { return Err(LifecycleUserError::OmegaHostMismatch); } let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( DataType::UserId, DataValue::SignedNumber(credential.user_id.into()), ); let response = connection .await_response(&request, Duration::from_secs(20)) .await?; if !response.is_type(CommunicationType::GetUserData) { return Err(LifecycleUserError::RemoteRejected); } let username = response .get_data(DataType::Username) .as_str() .map(str::to_owned) .ok_or(LifecycleUserError::RemoteRejected)?; let public_key = response .get_data(DataType::PublicKey) .as_str() .map(str::to_owned) .ok_or(LifecycleUserError::RemoteRejected)?; if public_key != public_key_bundle_to_base64(&credential.public_key_bundle()) { return Err(LifecycleUserError::RemoteRejected); } Ok((username, public_key)) } async fn credential_proof( connection: &dyn OmikronClient, credential: &TuCredential, begin: CommunicationType, challenge: CommunicationType, complete: CommunicationType, domain: &[u8], ) -> Result<(), LifecycleUserError> { let iota_id = configured_iota_id()?; let begin_request = CommunicationValue::new(begin).add_typed_default( DataType::UserId, DataValue::SignedNumber(credential.user_id.into()), ); let challenge_response = connection .await_response(&begin_request, Duration::from_secs(20)) .await?; if !challenge_response.is_type(challenge) { return Err(LifecycleUserError::RemoteRejected); } let nonce = challenge_response .get_data(DataType::ServerNonce) .as_signed_number() .and_then(|value| u64::try_from(value).ok()) .ok_or(LifecycleUserError::RemoteRejected)?; let (signature, pq_signature) = sign_lifecycle_payload( credential, &lifecycle_payload(domain, credential.user_id, iota_id, nonce), )?; let complete_request = CommunicationValue::new(complete) .add_typed_default( DataType::UserId, DataValue::SignedNumber(credential.user_id.into()), ) .add_typed_default(DataType::ServerNonce, DataValue::SignedNumber(nonce.into())) .add_typed_default(DataType::Signature, DataValue::Bytes(signature)) .add_typed_default(DataType::PqSignature, DataValue::Bytes(pq_signature)); let response = connection .await_response(&complete_request, Duration::from_secs(20)) .await?; if response.is_type(CommunicationType::Success) { Ok(()) } else { Err(LifecycleUserError::RemoteRejected) } } /// Attach or migrate an existing account. Local state is written only after /// Omega has accepted the credential proof and changed its assignment. pub async fn attach_user_from_tu( connection: &dyn OmikronClient, contents: &str, ) -> Result { let credential = TuCredential::parse(contents) .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; let (username, public_key) = inspect_credential_account(connection, &credential).await?; let profile = UserProfile::new( credential.user_id, username, None, public_key, hex_hash(contents), String::new(), ); write_user_credential(&profile.username, &credential.to_canonical_string()) .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; pending_operations::upsert(&PendingUserOperation { user_id: profile.user_id, operation: PendingUserOperationKind::Attach, username: profile.username.clone(), public_key: Some(profile.public_key.clone()), private_key_hash: Some(profile.private_key_hash.clone()), reset_token: Some(profile.reset_token.clone()), registration_token: None, phase: PendingUserOperationPhase::Prepared, created_at: now_millis(), }) .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; if let Err(error) = credential_proof( connection, &credential, CommunicationType::AttachUserBegin, CommunicationType::AttachUserChallenge, CommunicationType::AttachUserComplete, b"tensamin:user-attach:v1\0", ) .await { if matches!(error, LifecycleUserError::RemoteRejected) { let _ = pending_operations::remove(profile.user_id); let _ = remove_user_credential(profile.user_id, Some(&profile.username)); } return Err(error); } try_add_user(profile.clone()) .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; pending_operations::remove(profile.user_id) .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; }; let pending = match pending_operations::get_all() { Ok(pending) => pending, Err(error) => { log!("Pending user operation reconciliation could not read storage: {error}"); return; } }; for operation in pending { let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( DataType::UserId, DataValue::SignedNumber(operation.user_id.into()), ); let response = connection .await_response(&request, Duration::from_secs(10)) .await .ok(); let remote_iota_id = response.as_ref().and_then(|response| { response .get_data(DataType::IotaId) .as_signed_number() .and_then(|value| i64::try_from(value).ok()) }); let remote_matches = response.as_ref().is_some_and(|response| { response.is_type(CommunicationType::GetUserData) && remote_iota_id == Some(local_iota_id) && response.get_data(DataType::Username).as_str() == Some(&operation.username) && response.get_data(DataType::PublicKey).as_str() == operation.public_key.as_deref() }); let completion_retried = matches!(operation.operation, PendingUserOperationKind::Create) && matches!( operation.phase, PendingUserOperationPhase::Prepared | PendingUserOperationPhase::CredentialWritten ) && !remote_matches && complete_pending_create(connection, &operation).await; match operation.operation { PendingUserOperationKind::Create | PendingUserOperationKind::Attach if remote_matches || completion_retried => { let credential_present = iota_util::file_util::read_user_credential_with_legacy( operation.user_id, &operation.username, ) .ok() .flatten() .is_some(); if !credential_present { log!( "Pending user {} has no credential; leaving it unresolved", operation.user_id ); continue; } let Some(public_key) = operation.public_key else { continue; }; let profile = UserProfile::new( operation.user_id, operation.username, None, public_key, operation.private_key_hash.unwrap_or_default(), operation.reset_token.unwrap_or_default(), ); if try_add_user(profile).is_ok() { let _ = pending_operations::remove(operation.user_id); } } PendingUserOperationKind::Release if remote_iota_id != Some(local_iota_id) => { if iota_storage::users::user_manager::release_user(operation.user_id).is_ok() { let _ = pending_operations::remove(operation.user_id); } } _ => {} } } 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); } } } /* * Retry completion only while the locally persisted operation still owns a * valid registration lease. Omega treats an exact repeat as idempotent, which * repairs an interrupted request without allocating another user ID. */ async fn complete_pending_create( connection: &dyn OmikronClient, operation: &PendingUserOperation, ) -> bool { let Some(public_key) = operation.public_key.as_ref() else { return false; }; let Some(reset_token) = operation.reset_token.as_ref() else { return false; }; let Some(registration_token) = operation.registration_token.as_ref() else { return false; }; let request = CommunicationValue::new(CommunicationType::CompleteRegisterUser) .add_typed_default( DataType::UserId, DataValue::SignedNumber(operation.user_id.into()), ) .add_typed_default( DataType::Username, DataValue::Str(operation.username.clone()), ) .add_typed_default(DataType::PublicKey, DataValue::Str(public_key.clone())) .add_typed_default(DataType::ResetToken, DataValue::Str(reset_token.clone())) .add_typed_default( DataType::RegisterId, DataValue::Str(registration_token.clone()), ); match connection .await_response(&request, Duration::from_secs(20)) .await { Ok(response) if response.is_type(CommunicationType::Success) => { if let Err(error) = pending_operations::update_phase( operation.user_id, PendingUserOperationPhase::RemoteCommitted, ) { log!( "Pending user {} completed remotely but could not update its phase: {error}", operation.user_id ); } true } Ok(response) => { log!( "Pending user {} registration retry was rejected with {}", operation.user_id, response.get_type() ); false } Err(error) => { log!( "Pending user {} registration retry failed: {error}", operation.user_id ); false } } } fn valid_username(username: &str) -> bool { !username.is_empty() && username.len() <= 15 && username .bytes() .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) } fn now_millis() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as i64 } async fn request_user_id(connection: &dyn OmikronClient) -> Result<(i64, String), CreateUserError> { let request = CommunicationValue::new(CommunicationType::GetRegister); let response = connection .await_response(&request, Duration::from_secs(20)) .await .map_err(CreateUserError::Transport)?; if !response.is_type(CommunicationType::GetRegister) { return Err(CreateUserError::InvalidResponse); } let user_id = response .get_data(DataType::UserId) .as_number() .and_then(|id| i64::try_from(id).ok()) .filter(|id| (1..(1_i64 << 48)).contains(id)) .ok_or(CreateUserError::InvalidResponse)?; let registration_token = response .get_data(DataType::RegisterId) .as_str() .filter(|token| uuid::Uuid::parse_str(token).is_ok()) .map(str::to_owned) .ok_or(CreateUserError::InvalidResponse)?; Ok((user_id, registration_token)) } /// A completion response can be lost after Omega commits the user. Confirm /// the exact remote record before treating that transport failure as success. async fn registration_committed(connection: &dyn OmikronClient, profile: &UserProfile) -> bool { let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( DataType::UserId, DataValue::SignedNumber(profile.user_id.into()), ); let Ok(response) = connection .await_response(&request, Duration::from_secs(5)) .await else { return false; }; response.get_data(DataType::UserId).as_number() == Some(profile.user_id.into()) && response.get_data(DataType::Username).as_str() == Some(profile.username.as_str()) && response.get_data(DataType::PublicKey).as_str() == Some(profile.public_key.as_str()) } pub async fn create_user( connection: &dyn OmikronClient, username: &str, ) -> Result { if !valid_username(username) { return Err(CreateUserError::InvalidUsername); } let (user_id, registration_token) = request_user_id(connection).await?; log!("User creation: Omega allocated user ID {user_id}"); let keyring = crypto_helper::generate_keyring(); let pub_key_bundle = keyring.public_key_bundle(); let keyring_b64 = crypto_helper::keyring_to_base64(&keyring); let private_key_hash = hex_hash(&keyring_b64); let mut bytes = [0u8; 192]; OsRng.fill_bytes(&mut bytes); let reset_token = STANDARD.encode(&bytes); let user_profile = UserProfile::new( user_id, username.to_string(), None, public_key_bundle_to_base64(&pub_key_bundle), private_key_hash, reset_token.clone(), ); let credential = format!( "{}@{}::{}", user_id, omega_discovery::omega_host(), keyring_b64 ); pending_operations::upsert(&PendingUserOperation { user_id, operation: PendingUserOperationKind::Create, username: user_profile.username.clone(), public_key: Some(user_profile.public_key.clone()), private_key_hash: Some(user_profile.private_key_hash.clone()), reset_token: Some(user_profile.reset_token.clone()), registration_token: Some(registration_token.clone()), phase: PendingUserOperationPhase::Prepared, created_at: now_millis(), }) .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?; write_user_credential(username, &credential) .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?; pending_operations::update_phase(user_id, PendingUserOperationPhase::CredentialWritten) .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?; let communication_value = CommunicationValue::new(CommunicationType::CompleteRegisterUser) .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into())) .add_typed_default(DataType::Username, DataValue::Str(username.to_string())) .add_typed_default( DataType::PublicKey, DataValue::Str(public_key_bundle_to_base64(&pub_key_bundle)), ) .add_typed_default(DataType::ResetToken, DataValue::Str(reset_token)) .add_typed_default(DataType::RegisterId, DataValue::Str(registration_token)); let response_communication_value = connection .await_response(&communication_value, Duration::from_secs(20)) .await; match response_communication_value { Ok(response) => { log_cv!(PrintType::Omega, response); if !response.is_type(CommunicationType::Success) { let _ = pending_operations::remove(user_id); let _ = remove_user_credential(user_id, Some(username)); return Err(CreateUserError::RemoteRejected); } } Err(error) => { if registration_committed(connection, &user_profile).await { log!( "User creation: completion response was lost; verified user {} remotely", user_id ); } else { log_t!("User creation: {}", error.to_string()); return Err(match error { crate::OmikronError::Internal(_) => CreateUserError::RemoteRejected, error => CreateUserError::Transport(error), }); } } } pending_operations::update_phase(user_id, PendingUserOperationPhase::RemoteCommitted) .map_err(|_| CreateUserError::LocalFinalizationPending { user_id })?; try_add_user(user_profile.clone()) .map_err(|_| CreateUserError::LocalFinalizationPending { user_id })?; pending_operations::update_phase(user_id, PendingUserOperationPhase::LocalCommitted) .map_err(|_| CreateUserError::LocalFinalizationPending { user_id })?; pending_operations::remove(user_id) .map_err(|_| CreateUserError::LocalFinalizationPending { user_id })?; log!("Created User"); Ok(user_profile) } #[cfg(test)] mod tests { use super::{CreateUserError, request_user_id, valid_username}; use crate::{OmikronClient, OmikronError}; use async_trait::async_trait; use iota_connection::message_common::CommunicationResponseExt; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use std::time::Duration; struct RegistrationClient { response: CommunicationValue, } #[async_trait] impl OmikronClient for RegistrationClient { async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> { unreachable!() } async fn await_response( &self, request: &CommunicationValue, _: Duration, ) -> Result { assert!(request.is_type(CommunicationType::GetRegister)); Ok(self.response.clone().with_request_id(request)) } async fn reconnect(&self) -> Result<(), OmikronError> { unreachable!() } async fn rotate_identity(&self) -> Result<(), OmikronError> { unreachable!() } async fn is_connected(&self) -> bool { true } } #[test] fn validates_usernames_before_remote_registration() { assert!(valid_username("alice")); assert!(valid_username("abc123def456ghi")); assert!(!valid_username("")); assert!(!valid_username("sixteen_chars_bad")); assert!(!valid_username("path/name")); assert!(!valid_username("upperCase")); assert!(!valid_username("underscore_name")); assert!(!valid_username("line\nbreak")); } #[tokio::test] async fn uses_user_id_allocated_by_omega() { let client = RegistrationClient { response: CommunicationValue::new(CommunicationType::GetRegister) .add_typed_default(DataType::UserId, DataValue::SignedNumber(4_294_967_311)) .add_typed_default( DataType::RegisterId, DataValue::Str("00000000-0000-4000-8000-000000000001".into()), ), }; assert_eq!( request_user_id(&client).await.unwrap(), (4_294_967_311, "00000000-0000-4000-8000-000000000001".into()) ); } #[tokio::test] async fn rejects_registration_response_without_a_positive_user_id() { let client = RegistrationClient { response: CommunicationValue::new(CommunicationType::GetRegister) .add_typed_default(DataType::UserId, DataValue::SignedNumber(0)), }; assert!(matches!( request_user_id(&client).await, Err(CreateUserError::InvalidResponse) )); } }