Skip to content
Clone

fix(ssh): match offered keys by SHA-256 fingerprint and pass git-dir to receive-pack

OpenSSH offers RSA keys under rsa-sha2-* algorithm names, so comparing the
stored ssh-rsa line verbatim rejected valid logins. Also pass the bare path
explicitly because receive-pack rejects a missing <git-dir>.
ZhenYi committed
parent 4557313 commit eca6c76 Browse files

2 files changed

+81 −11

File tree 2
Modified lib/git/src/ssh/handler/auth.rs View lib/git/src/ssh/handler/auth.rs
@@ -3,6 +3,7 @@
33use models::users::user;
44use models::users::user_ssh_keys;
55use russh::keys::PublicKey;
6use russh::keys::ssh_key::HashAlg;
67use russh::server::Auth;
78use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set};
89use time::OffsetDateTime;
@@ -16,13 +17,55 @@
1617 key_id: Uuid,
1718}
1819
20fn key_content_matches(content: &str, public_key: &PublicKey) -> bool {
21 match PublicKey::from_str(content.trim()) {
22 // Compare fingerprints: OpenSSH may offer RSA keys under rsa-sha2-*
23 // algorithm names, so direct equality against a stored ssh-rsa line
24 // can fail even for the same key material.
25 Ok(p) => p.fingerprint(HashAlg::Sha256) == public_key.fingerprint(HashAlg::Sha256),
26 Err(_) => content.trim() == public_key.to_string().trim(),
27 }
28}
29
1930impl GitSshHandler {
31 /// Resolve the account owning `public_key`. The SSH username is tried
32 /// first for exact-name accounts, then authentication falls back to a
33 /// global key match so virtual accounts (e.g. `git@host`, matching the
34 /// `ssh_clone_user` in clone URLs) identify users by key like GitHub.
2035 async fn find_user_by_public_key(
2136 &self,
2237 username: &str,
2338 public_key: &PublicKey,
2439 ) -> Result<Option<SshKeyUser>, sea_orm::DbErr> {
2540 let db = self.service.smart.git.db_read();
41 if let Some(found) = self.find_user_key_by_name(db, username, public_key).await? {
42 return Ok(Some(found));
43 }
44 let keys = user_ssh_keys::Entity::find().all(db).await?;
45 for key in keys {
46 if !key_content_matches(key.content.as_str(), public_key) {
47 continue;
48 }
49 let Some(user) = user::Entity::find_by_id(key.user_id).one(db).await? else {
50 continue;
51 };
52 if !user.is_active {
53 continue;
54 }
55 return Ok(Some(SshKeyUser {
56 user,
57 key_id: key.id,
58 }));
59 }
60 Ok(None)
61 }
62
63 async fn find_user_key_by_name(
64 &self,
65 db: &sea_orm::DatabaseConnection,
66 username: &str,
67 public_key: &PublicKey,
68 ) -> Result<Option<SshKeyUser>, sea_orm::DbErr> {
2669 let Some(user) = user::Entity::find()
2770 .filter(user::Column::LowerName.eq(username.to_lowercase()))
2871 .one(db)
@@ -37,13 +80,8 @@
3780 .filter(user_ssh_keys::Column::UserId.eq(user.uid))
3881 .all(db)
3982 .await?;
40 let presented = public_key.to_string();
4183 for key in keys {
42 let matched = PublicKey::from_str(key.content.trim()).ok().map_or_else(
43 || key.content.trim() == presented.trim(),
44 |p| p == *public_key,
45 );
46 if matched {
84 if key_content_matches(key.content.as_str(), public_key) {
4785 return Ok(Some(SshKeyUser {
4886 user,
4987 key_id: key.id,
@@ -74,17 +112,26 @@
74112 user: &str,
75113 public_key: &PublicKey,
76114 ) -> Result<Auth, russh::Error> {
115 let fingerprint = public_key.fingerprint(HashAlg::Sha256).to_string();
77116 if self.over_capacity {
117 tracing::debug!(user, %fingerprint, "publickey offer rejected: server over capacity");
78118 return Ok(Auth::reject());
79119 }
80120 if user.is_empty() || public_key.to_string().len() < 32 {
121 tracing::debug!(user, %fingerprint, "publickey offer rejected: malformed request");
81122 return Ok(Auth::reject());
82123 }
83124 match self.find_user_by_public_key(user, public_key).await {
84 Ok(Some(_)) => Ok(Auth::Accept),
85 Ok(None) => Ok(Auth::reject()),
125 Ok(Some(_)) => {
126 tracing::debug!(user, %fingerprint, "publickey offer matched a known key");
127 Ok(Auth::Accept)
128 }
129 Ok(None) => {
130 tracing::info!(user, %fingerprint, client = ?self.client_addr, "publickey offer rejected: key not registered");
131 Ok(Auth::reject())
132 }
86133 Err(e) => {
87 tracing::warn!("ssh auth lookup failed: {e}");
134 tracing::warn!(user, %fingerprint, "ssh auth lookup failed: {e}");
88135 Err(russh::Error::NotAuthenticated)
89136 }
90137 }
@@ -95,25 +142,45 @@
95142 user: &str,
96143 public_key: &PublicKey,
97144 ) -> Result<Auth, russh::Error> {
145 let fingerprint = public_key.fingerprint(HashAlg::Sha256).to_string();
98146 if self.over_capacity {
147 tracing::debug!(user, %fingerprint, "publickey auth rejected: server over capacity");
99148 return Ok(Auth::reject());
100149 }
101150 if user.is_empty() || public_key.to_string().len() < 32 {
151 tracing::debug!(user, %fingerprint, "publickey auth rejected: malformed request");
102152 return Ok(Auth::reject());
103153 }
104154 match self.find_user_by_public_key(user, public_key).await {
105155 Ok(Some(found)) => {
106156 if found.user.is_restricted || found.user.prohibit_login {
157 tracing::warn!(
158 user,
159 user_id = %found.user.uid,
160 %fingerprint,
161 "publickey auth rejected: user restricted or login prohibited"
162 );
107163 return Ok(Auth::reject());
108164 }
109165 let key_id = found.key_id;
166 tracing::info!(
167 user,
168 user_id = %found.user.uid,
169 key_id = %key_id,
170 %fingerprint,
171 client = ?self.client_addr,
172 "ssh publickey auth accepted"
173 );
110174 self.operator = Some(found.user);
111175 self.update_key_last_used(key_id);
112176 Ok(Auth::Accept)
113177 }
114 Ok(None) => Ok(Auth::reject()),
178 Ok(None) => {
179 tracing::info!(user, %fingerprint, client = ?self.client_addr, "publickey auth rejected: key not registered");
180 Ok(Auth::reject())
181 }
115182 Err(e) => {
116 tracing::warn!("ssh auth lookup failed: {e}");
183 tracing::warn!(user, %fingerprint, "ssh auth lookup failed: {e}");
117184 Err(russh::Error::NotAuthenticated)
118185 }
119186 }
Modified lib/git/src/ssh/git_service.rs View lib/git/src/ssh/git_service.rs
@@ -96,6 +96,9 @@
9696 cmd.current_dir(bare)
9797 .arg(service.as_subcommand())
9898 .arg("--advertise-refs")
99 // receive-pack rejects a missing <git-dir> even when GIT_DIR is set,
100 // so the path must be passed explicitly.
101 .arg(bare)
99102 .env("GIT_DIR", bare)
100103 .env("GIT_TERMINAL_PROMPT", "0");
101104 apply_hardened_env(&mut cmd);