use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::error::AppError; use crate::models::common::KeyType; use crate::models::users::{UserGpgKey, UserSshKey}; use crate::service::UserService; use crate::session::Session; use super::util::{ensure_affected, required_text}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct AddSshKeyParams { pub title: String, pub public_key: String, pub key_type: String, pub expires_at: Option>, } #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct AddGpgKeyParams { pub public_key: String, pub key_id: String, pub primary_email: Option, pub expires_at: Option>, } impl UserService { pub async fn user_ssh_keys( &self, ctx: &Session, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let limit = limit.clamp(1, 100); let offset = offset.max(0); sqlx::query_as::<_, UserSshKey>( "SELECT id, user_id, title, public_key, fingerprint_sha256, key_type, last_used_at, \ expires_at, revoked_at, created_at, updated_at FROM user_ssh_key \ WHERE user_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC LIMIT $2 OFFSET $3", ) .bind(user_uid) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } pub async fn user_add_ssh_key( &self, ctx: &Session, params: AddSshKeyParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let title = required_text(params.title, "title")?; let public_key = required_text(params.public_key, "public_key")?; let key_type = parse_key_type(¶ms.key_type)?; let fingerprint = ssh_fingerprint(&public_key, key_type)?; let now = chrono::Utc::now(); let existing = sqlx::query( "SELECT id FROM user_ssh_key WHERE fingerprint_sha256 = $1 AND user_id = $2 AND revoked_at IS NULL LIMIT 1", ) .bind(&fingerprint) .bind(user_uid) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)?; if existing.is_some() { return Err(AppError::Conflict("SSH key already exists".into())); } sqlx::query_as::<_, UserSshKey>( "INSERT INTO user_ssh_key (id, user_id, title, public_key, fingerprint_sha256, key_type, \ expires_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \ RETURNING id, user_id, title, public_key, fingerprint_sha256, key_type, last_used_at, \ expires_at, revoked_at, created_at, updated_at", ) .bind(Uuid::now_v7()) .bind(user_uid) .bind(title) .bind(&public_key) .bind(fingerprint) .bind(key_type) .bind(params.expires_at) .bind(now) .fetch_one(self.ctx.db.writer()) .await .map_err(AppError::Database) } pub async fn user_delete_ssh_key(&self, ctx: &Session, key_uid: Uuid) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let result = sqlx::query( "UPDATE user_ssh_key SET revoked_at = $1, updated_at = $1 \ WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL", ) .bind(chrono::Utc::now()) .bind(key_uid) .bind(user_uid) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "key not found") } pub async fn user_gpg_keys( &self, ctx: &Session, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let limit = limit.clamp(1, 100); let offset = offset.max(0); sqlx::query_as::<_, UserGpgKey>( "SELECT id, user_id, key_id, public_key, fingerprint, primary_email, expires_at, \ verified_at, revoked_at, created_at, updated_at FROM user_gpg_key \ WHERE user_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC LIMIT $2 OFFSET $3", ) .bind(user_uid) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } pub async fn user_add_gpg_key( &self, ctx: &Session, params: AddGpgKeyParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let public_key = required_text(params.public_key, "public_key")?; let key_id = required_text(params.key_id, "key_id")?; let primary_email = params .primary_email .map(|v| v.trim().to_lowercase()) .filter(|v| !v.is_empty()); let fingerprint = gpg_fingerprint(&public_key)?; let now = chrono::Utc::now(); let existing = sqlx::query( "SELECT id FROM user_gpg_key WHERE fingerprint = $1 AND user_id = $2 AND revoked_at IS NULL LIMIT 1", ) .bind(&fingerprint) .bind(user_uid) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)?; if existing.is_some() { return Err(AppError::Conflict("GPG key already exists".into())); } sqlx::query_as::<_, UserGpgKey>( "INSERT INTO user_gpg_key (id, user_id, key_id, public_key, fingerprint, primary_email, \ expires_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \ RETURNING id, user_id, key_id, public_key, fingerprint, primary_email, expires_at, \ verified_at, revoked_at, created_at, updated_at", ) .bind(Uuid::now_v7()) .bind(user_uid) .bind(key_id) .bind(&public_key) .bind(&fingerprint) .bind(primary_email) .bind(params.expires_at) .bind(now) .fetch_one(self.ctx.db.writer()) .await .map_err(AppError::Database) } pub async fn user_delete_gpg_key(&self, ctx: &Session, key_uid: Uuid) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let result = sqlx::query( "UPDATE user_gpg_key SET revoked_at = $1, updated_at = $1 \ WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL", ) .bind(chrono::Utc::now()) .bind(key_uid) .bind(user_uid) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "key not found") } } fn parse_key_type(value: &str) -> Result { use crate::models::common::KeyType; let key_type = value .trim() .parse::() .map_err(|_| AppError::BadRequest("invalid key_type".into()))?; if key_type == KeyType::Unknown { return Err(AppError::BadRequest("invalid key_type".into())); } Ok(key_type) } fn ssh_fingerprint(public_key: &str, expected_type: KeyType) -> Result { use base64::Engine; let parts: Vec<&str> = public_key.split_whitespace().collect(); if parts.len() < 2 { return Err(AppError::BadRequest("invalid SSH public key format".into())); } let actual_type = ssh_key_type(parts[0])?; if actual_type != expected_type { return Err(AppError::BadRequest( "key_type does not match SSH public key".into(), )); } let decoded = base64::engine::general_purpose::STANDARD .decode(parts[1]) .map_err(|_| AppError::BadRequest("invalid SSH public key data".into()))?; if decoded.is_empty() { return Err(AppError::BadRequest("invalid SSH public key data".into())); } Ok(super::util::sha256_hex(&decoded)) } fn ssh_key_type(value: &str) -> Result { match value { "ssh-rsa" => Ok(KeyType::Rsa), "ssh-ed25519" => Ok(KeyType::Ed25519), v if v.starts_with("ecdsa-sha2-") => Ok(KeyType::Ecdsa), "ssh-dss" => Ok(KeyType::Dsa), _ => Err(AppError::BadRequest("unsupported SSH key type".into())), } } fn gpg_fingerprint(public_key: &str) -> Result { let key = public_key.trim(); if !key.starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----") || !key.contains("-----END PGP PUBLIC KEY BLOCK-----") { return Err(AppError::BadRequest("invalid GPG public key format".into())); } Ok(super::util::sha256_hex(key.as_bytes())) }