use base64::{Engine as _, engine::general_purpose}; use db::sqlx; use model::users::UserSshKeyModel; use serde::{Deserialize, Serialize}; use session::Session; use sha2::{Digest, Sha256}; use crate::{AppService, error::AppError, session_user}; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct UserSshKey { pub id: i64, pub title: String, pub public_key: String, pub fingerprint: String, pub key_type: String, pub key_bits: Option, pub is_verified: bool, #[schema(value_type = Option)] pub last_used_at: Option>, #[schema(value_type = Option)] pub expires_at: Option>, pub is_revoked: bool, #[schema(value_type = String)] pub created_at: chrono::DateTime, #[schema(value_type = String)] pub updated_at: chrono::DateTime, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct CreateUserSshKey { pub title: String, pub public_key: String, #[schema(value_type = Option)] pub expires_at: Option>, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct UpdateUserSshKey { pub title: Option, #[schema(value_type = Option)] pub expires_at: Option>>, } impl AppService { pub async fn user_ssh_keys( &self, ctx: &Session, ) -> Result, AppError> { let user_uid = session_user(ctx)?; let rows = sqlx::query_as::<_, UserSshKeyModel>( "SELECT id, \"user\", title, public_key, fingerprint, key_type, key_bits, is_verified, last_used_at, expires_at, is_revoked, created_at, updated_at \ FROM user_ssh_key WHERE \"user\" = $1 ORDER BY created_at DESC", ) .bind(user_uid) .fetch_all(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(rows.into_iter().map(Into::into).collect()) } pub async fn user_add_ssh_key( &self, ctx: &Session, params: CreateUserSshKey, ) -> Result { let user_uid = session_user(ctx)?; let title = params.title.trim(); if title.is_empty() { return Err(AppError::BadRequest( "ssh key title is required".to_string(), )); } let parsed = parse_public_key(¶ms.public_key)?; let now = chrono::Utc::now(); let row = sqlx::query_as::<_, UserSshKeyModel>( "INSERT INTO user_ssh_key \ (\"user\", title, public_key, fingerprint, key_type, key_bits, is_verified, last_used_at, expires_at, is_revoked, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, true, NULL, $7, false, $8, $8) \ RETURNING id, \"user\", title, public_key, fingerprint, key_type, key_bits, is_verified, last_used_at, expires_at, is_revoked, created_at, updated_at", ) .bind(user_uid) .bind(title) .bind(parsed.public_key) .bind(parsed.fingerprint) .bind(parsed.key_type) .bind(parsed.key_bits) .bind(params.expires_at) .bind(now) .fetch_one(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(row.into()) } pub async fn user_revoke_ssh_key( &self, ctx: &Session, key_id: i64, ) -> Result<(), AppError> { let user_uid = session_user(ctx)?; let affected = sqlx::query( "UPDATE user_ssh_key SET is_revoked = true, updated_at = $1 \ WHERE id = $2 AND \"user\" = $3 AND is_revoked = false", ) .bind(chrono::Utc::now()) .bind(key_id) .bind(user_uid) .execute(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .rows_affected(); if affected == 0 { return Err(AppError::NotFound("ssh key not found".to_string())); } Ok(()) } pub async fn user_update_ssh_key( &self, ctx: &Session, key_id: i64, params: UpdateUserSshKey, ) -> Result { let user_uid = session_user(ctx)?; let mut key = self.user_ssh_key_by_id(user_uid, key_id).await?; if let Some(title) = params.title { let title = title.trim(); if title.is_empty() { return Err(AppError::BadRequest( "ssh key title is required".to_string(), )); } key.title = title.to_string(); } if let Some(expires_at) = params.expires_at { key.expires_at = expires_at; } let row = sqlx::query_as::<_, UserSshKeyModel>( "UPDATE user_ssh_key SET title = $1, expires_at = $2, updated_at = $3 \ WHERE id = $4 AND \"user\" = $5 AND is_revoked = false \ RETURNING id, \"user\", title, public_key, fingerprint, key_type, key_bits, is_verified, last_used_at, expires_at, is_revoked, created_at, updated_at", ) .bind(&key.title) .bind(key.expires_at) .bind(chrono::Utc::now()) .bind(key_id) .bind(user_uid) .fetch_optional(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .ok_or_else(|| AppError::NotFound("ssh key not found".to_string()))?; Ok(row.into()) } async fn user_ssh_key_by_id( &self, user_uid: uuid::Uuid, key_id: i64, ) -> Result { sqlx::query_as::<_, UserSshKeyModel>( "SELECT id, \"user\", title, public_key, fingerprint, key_type, key_bits, is_verified, last_used_at, expires_at, is_revoked, created_at, updated_at \ FROM user_ssh_key WHERE id = $1 AND \"user\" = $2 AND is_revoked = false", ) .bind(key_id) .bind(user_uid) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .map(Into::into) .ok_or_else(|| AppError::NotFound("ssh key not found".to_string())) } } struct ParsedPublicKey { public_key: String, fingerprint: String, key_type: String, key_bits: Option, } fn parse_public_key(public_key: &str) -> Result { let public_key = public_key.trim(); let mut parts = public_key.split_whitespace(); let key_type = parts.next().ok_or_else(|| { AppError::BadRequest("invalid ssh public key".to_string()) })?; let key_data_base64 = parts.next().ok_or_else(|| { AppError::BadRequest("invalid ssh public key".to_string()) })?; let key_data = general_purpose::STANDARD .decode(key_data_base64) .map_err(|_| { AppError::BadRequest("invalid ssh public key".to_string()) })?; let mut hasher = Sha256::new(); hasher.update(&key_data); let fingerprint = format!( "SHA256:{}", general_purpose::STANDARD_NO_PAD.encode(hasher.finalize()) ); Ok(ParsedPublicKey { public_key: public_key.to_string(), fingerprint, key_type: key_type.to_string(), key_bits: key_bits(key_type), }) } fn key_bits(key_type: &str) -> Option { match key_type { "ssh-ed25519" => Some(256), "ecdsa-sha2-nistp256" => Some(256), "ecdsa-sha2-nistp384" => Some(384), "ecdsa-sha2-nistp521" => Some(521), _ => None, } } impl From for UserSshKey { fn from(value: UserSshKeyModel) -> Self { Self { id: value.id, title: value.title, public_key: value.public_key, fingerprint: value.fingerprint, key_type: value.key_type, key_bits: value.key_bits, is_verified: value.is_verified, last_used_at: value.last_used_at, expires_at: value.expires_at, is_revoked: value.is_revoked, created_at: value.created_at, updated_at: value.updated_at, } } }