use std::str::FromStr; use models::users::user; use models::users::user_ssh_keys; use russh::keys::PublicKey; use russh::keys::ssh_key::HashAlg; use russh::server::Auth; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set}; use time::OffsetDateTime; use tokio::task; use uuid::Uuid; use crate::ssh::handler::GitSshHandler; struct SshKeyUser { user: user::Model, key_id: Uuid, } fn key_content_matches(content: &str, public_key: &PublicKey) -> bool { match PublicKey::from_str(content.trim()) { // Compare fingerprints: OpenSSH may offer RSA keys under rsa-sha2-* // algorithm names, so direct equality against a stored ssh-rsa line // can fail even for the same key material. Ok(p) => p.fingerprint(HashAlg::Sha256) == public_key.fingerprint(HashAlg::Sha256), Err(_) => content.trim() == public_key.to_string().trim(), } } impl GitSshHandler { /// Resolve the account owning `public_key`. The SSH username is tried /// first for exact-name accounts, then authentication falls back to a /// global key match so virtual accounts (e.g. `git@host`, matching the /// `ssh_clone_user` in clone URLs) identify users by key like GitHub. async fn find_user_by_public_key( &self, username: &str, public_key: &PublicKey, ) -> Result, sea_orm::DbErr> { let db = self.service.smart.git.db_read(); if let Some(found) = self.find_user_key_by_name(db, username, public_key).await? { return Ok(Some(found)); } let keys = user_ssh_keys::Entity::find().all(db).await?; for key in keys { if !key_content_matches(key.content.as_str(), public_key) { continue; } let Some(user) = user::Entity::find_by_id(key.user_id).one(db).await? else { continue; }; if !user.is_active { continue; } return Ok(Some(SshKeyUser { user, key_id: key.id, })); } Ok(None) } async fn find_user_key_by_name( &self, db: &sea_orm::DatabaseConnection, username: &str, public_key: &PublicKey, ) -> Result, sea_orm::DbErr> { let Some(user) = user::Entity::find() .filter(user::Column::LowerName.eq(username.to_lowercase())) .one(db) .await? else { return Ok(None); }; if !user.is_active { return Ok(None); } let keys = user_ssh_keys::Entity::find() .filter(user_ssh_keys::Column::UserId.eq(user.uid)) .all(db) .await?; for key in keys { if key_content_matches(key.content.as_str(), public_key) { return Ok(Some(SshKeyUser { user, key_id: key.id, })); } } Ok(None) } fn update_key_last_used(&self, key_id: Uuid) { let db = self.service.smart.git.state.database.clone(); let now = OffsetDateTime::now_utc(); task::spawn(async move { let _ = user_ssh_keys::Entity::update_many() .set(user_ssh_keys::ActiveModel { id: Set(key_id), last_used_at: Set(Some(now)), ..Default::default() }) .filter(user_ssh_keys::Column::Id.eq(key_id)) .exec(db.write()) .await; }); } pub async fn auth_publickey_offered_impl( &mut self, user: &str, public_key: &PublicKey, ) -> Result { let fingerprint = public_key.fingerprint(HashAlg::Sha256).to_string(); if self.over_capacity { tracing::debug!(user, %fingerprint, "publickey offer rejected: server over capacity"); return Ok(Auth::reject()); } if user.is_empty() || public_key.to_string().len() < 32 { tracing::debug!(user, %fingerprint, "publickey offer rejected: malformed request"); return Ok(Auth::reject()); } match self.find_user_by_public_key(user, public_key).await { Ok(Some(_)) => { tracing::debug!(user, %fingerprint, "publickey offer matched a known key"); Ok(Auth::Accept) } Ok(None) => { tracing::info!(user, %fingerprint, client = ?self.client_addr, "publickey offer rejected: key not registered"); Ok(Auth::reject()) } Err(e) => { tracing::warn!(user, %fingerprint, "ssh auth lookup failed: {e}"); Err(russh::Error::NotAuthenticated) } } } pub async fn auth_publickey_impl( &mut self, user: &str, public_key: &PublicKey, ) -> Result { let fingerprint = public_key.fingerprint(HashAlg::Sha256).to_string(); if self.over_capacity { tracing::debug!(user, %fingerprint, "publickey auth rejected: server over capacity"); return Ok(Auth::reject()); } if user.is_empty() || public_key.to_string().len() < 32 { tracing::debug!(user, %fingerprint, "publickey auth rejected: malformed request"); return Ok(Auth::reject()); } match self.find_user_by_public_key(user, public_key).await { Ok(Some(found)) => { if found.user.is_restricted || found.user.prohibit_login { tracing::warn!( user, user_id = %found.user.uid, %fingerprint, "publickey auth rejected: user restricted or login prohibited" ); return Ok(Auth::reject()); } let key_id = found.key_id; tracing::info!( user, user_id = %found.user.uid, key_id = %key_id, %fingerprint, client = ?self.client_addr, "ssh publickey auth accepted" ); self.operator = Some(found.user); self.update_key_last_used(key_id); Ok(Auth::Accept) } Ok(None) => { tracing::info!(user, %fingerprint, client = ?self.client_addr, "publickey auth rejected: key not registered"); Ok(Auth::reject()) } Err(e) => { tracing::warn!(user, %fingerprint, "ssh auth lookup failed: {e}"); Err(russh::Error::NotAuthenticated) } } } }