use base64::Engine; use db::conn::sea_orm::{ActiveModelTrait, EntityTrait, QueryFilter, Set}; use rand::TryRng; use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::error::{UserError, UserResult}; use crate::features::UserService; use crate::metrics::{self, SecurityResource, SecurityResourceOp, record_security_operation}; use super::{ AccessKeyScope, AccessKeyView, AddSshKeyCmd, CreateAccessKeyCmd, CreateAccessKeyResp, PushPlatform, PushSubscriptionView, RecoveryCodeView, RegenerateRecoveryResp, SshKeyType, SshKeyView, UpsertPushCmd, }; fn random_token(bytes: usize) -> String { let mut buf = vec![0u8; bytes]; rand::rng().try_fill_bytes(&mut buf).expect("rng"); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&buf) } fn sha256_hex(data: &[u8]) -> String { let mut h = Sha256::new(); h.update(data); let out = h.finalize(); out.iter().map(|b| format!("{:02x}", b)).collect() } pub async fn list_recovery(svc: &UserService, caller: Uuid) -> UserResult> { let _timer = metrics::Timer::start(metrics::FEATURE_RECOVERY_CODES_LIST); let result: UserResult> = async { let db = svc.db.reader(); let rows = db::user::user_recovery_codes::Entity::find() .filter(db::user::user_recovery_codes::COLUMN.user_id.eq(caller)) .all(&db) .await?; Ok(rows.into_iter().map(RecoveryCodeView::from).collect()) } .await; metrics::record_outcome(metrics::FEATURE_RECOVERY_CODES_LIST, &result); result } pub async fn regenerate_recovery( svc: &UserService, caller: Uuid, ) -> UserResult { let _timer = metrics::Timer::start(metrics::FEATURE_RECOVERY_CODES_REGENERATE); let result: UserResult = async { let writer = svc.db.writer(); db::user::user_recovery_codes::Entity::delete_many() .filter(db::user::user_recovery_codes::COLUMN.user_id.eq(caller)) .exec(&writer) .await?; let max = svc.config.max_recovery_codes; let mut codes: Vec = Vec::with_capacity(max); let now = time::OffsetDateTime::now_utc(); for _ in 0..max { let raw = format!("{}-{}", random_token(4), random_token(4),); let raw = raw .replace('-', "") .chars() .take(10) .collect::() .to_uppercase(); let hash = sha256_hex(raw.as_bytes()); let active: db::user::user_recovery_codes::ActiveModel = db::user::user_recovery_codes::ActiveModel::builder() .set_id(Uuid::new_v4()) .set_user_id(caller) .set_code_hash(hash) .set_used_at(None) .set_expires_at(None) .set_created_at(now) .into(); active.insert(&writer).await?; codes.push(raw); } Ok(RegenerateRecoveryResp { codes }) } .await; metrics::record_outcome(metrics::FEATURE_RECOVERY_CODES_REGENERATE, &result); if result.is_ok() { record_security_operation( SecurityResource::RecoveryCode, SecurityResourceOp::Regenerate, metrics::OUTCOME_SUCCESS, ); } result } pub async fn list_access_keys(svc: &UserService, caller: Uuid) -> UserResult> { let _timer = metrics::Timer::start(metrics::FEATURE_ACCESS_KEYS_LIST); let result: UserResult> = async { let db = svc.db.reader(); let rows = db::user::user_access_keys::Entity::find() .filter(db::user::user_access_keys::COLUMN.user_id.eq(caller)) .all(&db) .await?; Ok(rows.into_iter().map(AccessKeyView::from).collect()) } .await; metrics::record_outcome(metrics::FEATURE_ACCESS_KEYS_LIST, &result); result } pub async fn create_access_key( svc: &UserService, caller: Uuid, cmd: CreateAccessKeyCmd, ) -> UserResult { let _timer = metrics::Timer::start(metrics::FEATURE_ACCESS_KEYS_CREATE); let result: UserResult = async { let bytes = svc.config.access_key_token_bytes; let raw = random_token(bytes); let salt = random_token(16); let hash = sha256_hex(format!("{}{}", raw, salt).as_bytes()); let last_eight: String = raw .chars() .rev() .take(8) .collect::() .chars() .rev() .collect(); let now = time::OffsetDateTime::now_utc(); let writer = svc.db.writer(); let active: db::user::user_access_keys::ActiveModel = db::user::user_access_keys::ActiveModel::builder() .set_id(Uuid::new_v4()) .set_user_id(caller) .set_name(cmd.name) .set_token_hash(hash) .set_token_salt(salt) .set_token_last_eight(last_eight) .set_scope(cmd.scope) .set_version(1) .set_created_at(now) .set_updated_at(now) .into(); let saved = active.insert(&writer).await?; Ok(CreateAccessKeyResp { key: AccessKeyView::from(saved), token: raw, }) } .await; metrics::record_outcome(metrics::FEATURE_ACCESS_KEYS_CREATE, &result); if result.is_ok() { record_security_operation( SecurityResource::AccessKey, SecurityResourceOp::Create, metrics::OUTCOME_SUCCESS, ); } result } pub async fn delete_access_key(svc: &UserService, caller: Uuid, key_id: Uuid) -> UserResult<()> { let _timer = metrics::Timer::start(metrics::FEATURE_ACCESS_KEYS_DELETE); let result: UserResult<()> = async { let writer = svc.db.writer(); let row = db::user::user_access_keys::Entity::find_by_id(key_id) .one(&writer) .await? .ok_or_else(|| UserError::NotFound(format!("access key {key_id}")))?; if row.user_id != caller { return Err(UserError::Forbidden( "cannot delete other user's key".into(), )); } db::user::user_access_keys::Entity::delete_by_id(key_id) .exec(&writer) .await?; Ok(()) } .await; metrics::record_outcome(metrics::FEATURE_ACCESS_KEYS_DELETE, &result); match &result { Ok(_) => record_security_operation( SecurityResource::AccessKey, SecurityResourceOp::Delete, metrics::OUTCOME_SUCCESS, ), Err(UserError::Forbidden(_)) => record_security_operation( SecurityResource::AccessKey, SecurityResourceOp::Delete, "forbidden", ), Err(UserError::NotFound(_)) => record_security_operation( SecurityResource::AccessKey, SecurityResourceOp::Delete, "not_found", ), Err(_) => {} } result } pub async fn list_ssh_keys(svc: &UserService, caller: Uuid) -> UserResult> { let _timer = metrics::Timer::start(metrics::FEATURE_SSH_KEYS_LIST); let result: UserResult> = async { let db = svc.db.reader(); let rows = db::user::user_ssh_keys::Entity::find() .filter(db::user::user_ssh_keys::COLUMN.user_id.eq(caller)) .all(&db) .await?; Ok(rows.into_iter().map(SshKeyView::from).collect()) } .await; metrics::record_outcome(metrics::FEATURE_SSH_KEYS_LIST, &result); result } pub async fn add_ssh_key( svc: &UserService, caller: Uuid, cmd: AddSshKeyCmd, ) -> UserResult { let _timer = metrics::Timer::start(metrics::FEATURE_SSH_KEYS_ADD); let result: UserResult = async { if cmd.name.is_empty() || cmd.name.len() > 64 { return Err(UserError::BadRequest( "ssh key name length must be 1..=64".into(), )); } let detected = detect_ssh_key_type(&cmd.content).ok_or_else(|| { UserError::BadRequest("ssh key content malformed or unsupported".into()) })?; let key_type = cmd.key_type.map(Into::into).unwrap_or(detected); let fingerprint = sha256_hex(cmd.content.as_bytes()); let writer = svc.db.writer(); let now = time::OffsetDateTime::now_utc(); let active: db::user::user_ssh_keys::ActiveModel = db::user::user_ssh_keys::ActiveModel::builder() .set_id(Uuid::new_v4()) .set_user_id(caller) .set_key_type(key_type) .set_fingerprint(fingerprint.clone()) .set_name(cmd.name) .set_content(cmd.content) .set_created_at(now) .set_updated_at(now) .set_last_used_at(None) .into(); let saved = active.insert(&writer).await?; Ok(SshKeyView::from(saved)) } .await; metrics::record_outcome(metrics::FEATURE_SSH_KEYS_ADD, &result); match &result { Ok(_) => record_security_operation( SecurityResource::SshKey, SecurityResourceOp::Add, metrics::OUTCOME_SUCCESS, ), Err(UserError::BadRequest(_)) => record_security_operation( SecurityResource::SshKey, SecurityResourceOp::Add, "bad_request", ), Err(_) => {} } result } pub async fn delete_ssh_key(svc: &UserService, caller: Uuid, key_id: Uuid) -> UserResult<()> { let _timer = metrics::Timer::start(metrics::FEATURE_SSH_KEYS_DELETE); let result: UserResult<()> = async { let writer = svc.db.writer(); let row = db::user::user_ssh_keys::Entity::find_by_id(key_id) .one(&writer) .await? .ok_or_else(|| UserError::NotFound(format!("ssh key {key_id}")))?; if row.user_id != caller { return Err(UserError::Forbidden( "cannot delete other user's ssh key".into(), )); } db::user::user_ssh_keys::Entity::delete_by_id(key_id) .exec(&writer) .await?; Ok(()) } .await; metrics::record_outcome(metrics::FEATURE_SSH_KEYS_DELETE, &result); match &result { Ok(_) => record_security_operation( SecurityResource::SshKey, SecurityResourceOp::Delete, metrics::OUTCOME_SUCCESS, ), Err(UserError::Forbidden(_)) => record_security_operation( SecurityResource::SshKey, SecurityResourceOp::Delete, "forbidden", ), Err(UserError::NotFound(_)) => record_security_operation( SecurityResource::SshKey, SecurityResourceOp::Delete, "not_found", ), Err(_) => {} } result } fn detect_ssh_key_type(content: &str) -> Option { use db::user::user_ssh_keys::SshKeyType as K; if content.starts_with("ssh-rsa") { Some(K::SshRsa) } else if content.starts_with("ssh-ed25519") { Some(K::SshEd25519) } else if content.starts_with("ecdsa-sha2-nistp256") { Some(K::EcdsaSha2Nistp256) } else { None } } pub async fn list_push(svc: &UserService, caller: Uuid) -> UserResult> { let _timer = metrics::Timer::start(metrics::FEATURE_PUSH_SUBS_LIST); let result: UserResult> = async { let db = svc.db.reader(); let rows = db::user::user_push_subscriptions::Entity::find() .filter(db::user::user_push_subscriptions::COLUMN.user_id.eq(caller)) .all(&db) .await?; Ok(rows.into_iter().map(PushSubscriptionView::from).collect()) } .await; metrics::record_outcome(metrics::FEATURE_PUSH_SUBS_LIST, &result); result } pub async fn upsert_push( svc: &UserService, caller: Uuid, cmd: UpsertPushCmd, ) -> UserResult { let _timer = metrics::Timer::start(metrics::FEATURE_PUSH_SUBS_UPSERT); let result: UserResult = async { if cmd.endpoint.is_empty() || cmd.endpoint.len() > 2048 { return Err(UserError::BadRequest("endpoint invalid".into())); } let writer = svc.db.writer(); let now = time::OffsetDateTime::now_utc(); let existing = db::user::user_push_subscriptions::Entity::find() .filter(db::user::user_push_subscriptions::COLUMN.user_id.eq(caller)) .filter( db::user::user_push_subscriptions::COLUMN .endpoint .eq(&cmd.endpoint), ) .one(&writer) .await?; let saved = if let Some(row) = existing { let mut am: db::user::user_push_subscriptions::ActiveModel = row.into(); am.p256dh_key = Set(cmd.p256dh_key); am.auth_key = Set(cmd.auth_key); am.platform = Set(cmd.platform.into()); am.user_agent = Set(cmd.user_agent); am.updated_at = Set(Some(now)); am.update(&writer).await? } else { let active: db::user::user_push_subscriptions::ActiveModel = db::user::user_push_subscriptions::ActiveModel::builder() .set_id(Uuid::new_v4()) .set_user_id(caller) .set_endpoint(cmd.endpoint) .set_p256dh_key(cmd.p256dh_key) .set_auth_key(cmd.auth_key) .set_platform(cmd.platform) .set_user_agent(cmd.user_agent) .set_created_at(now) .set_updated_at(Some(now)) .into(); active.insert(&writer).await? }; Ok(PushSubscriptionView::from(saved)) } .await; metrics::record_outcome(metrics::FEATURE_PUSH_SUBS_UPSERT, &result); if result.is_ok() { record_security_operation( SecurityResource::PushSubscription, SecurityResourceOp::Upsert, metrics::OUTCOME_SUCCESS, ); } result } pub async fn delete_push(svc: &UserService, caller: Uuid, push_id: Uuid) -> UserResult<()> { let _timer = metrics::Timer::start(metrics::FEATURE_PUSH_SUBS_DELETE); let result: UserResult<()> = async { let writer = svc.db.writer(); let row = db::user::user_push_subscriptions::Entity::find_by_id(push_id) .one(&writer) .await? .ok_or_else(|| UserError::NotFound(format!("push subscription {push_id}")))?; if row.user_id != caller { return Err(UserError::Forbidden( "cannot delete other user's push subscription".into(), )); } db::user::user_push_subscriptions::Entity::delete_by_id(push_id) .exec(&writer) .await?; Ok(()) } .await; metrics::record_outcome(metrics::FEATURE_PUSH_SUBS_DELETE, &result); match &result { Ok(_) => record_security_operation( SecurityResource::PushSubscription, SecurityResourceOp::Delete, metrics::OUTCOME_SUCCESS, ), Err(UserError::Forbidden(_)) => record_security_operation( SecurityResource::PushSubscription, SecurityResourceOp::Delete, "forbidden", ), Err(UserError::NotFound(_)) => record_security_operation( SecurityResource::PushSubscription, SecurityResourceOp::Delete, "not_found", ), Err(_) => {} } result } #[allow(dead_code)] fn _use_unused(_: &AccessKeyScope, _: &SshKeyType, _: &PushPlatform) {}