fix(gitauth): bind git credentials to the account; auth via access keys (PAT)
C2 (CWE-269): Basic-auth git HTTP/LFS accepted any valid access JWT as the password and returned claims.user_id() without comparing it to the username row, so an attacker could present an admin username with their own token and get is_site_admin=true. Replace JWT-as-password with two bound credential types: - access key (PAT) from user_access_key: matched only against keys owned by the username's account, so a token can never authenticate as another user. - account password (existing path, unchanged binding). The key's AccessKeyScope now caps the actor (credential_max = Read/Write/Admin) and is enforced in authorize() even past the is_site_admin short-circuit, so a read-scoped PAT cannot push. Token hash (sha256(token || salt)) is factored into lib/auth/features/access_token and shared with create_access_key.
zhenyi committed
Modified dvcs/gitauth/service.rs dvcs/gitauth/service.rs +70 −21 View dvcs/gitauth/service.rs
@@ -3,8 +3,8 @@
33use cache::RedisCache;
44use db::conn::Db;
55use db::conn::sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
6use db::user::users;
7use jwt::{AccessClaims, HasUserExt, JwtState, RotationContext};
6use db::user::{user_access_keys, users};
7use jwt::JwtState;
88use repo::actor::{ActorContext, RepoAccess};
99use uuid::Uuid;
1010
@@ -69,11 +69,12 @@
6969 return Err(GitAuthError::Unauthorized("account is not active".into()));
7070 }
7171
72 let user_id = self.verify_credentials(&user, password).await?;
72 let (user_id, credential_max) = self.verify_credentials(&user, password).await?;
7373 let actor = ActorContext {
7474 user_id: Some(user_id),
7575 scopes: Default::default(),
7676 is_site_admin: user.is_admin,
77 credential_max,
7778 channel_role: None,
7879 space_role: None,
7980 };
@@ -92,6 +93,15 @@
9293 required: RepoAccess,
9394 ) -> GitAuthResult<ResolvedRepo> {
9495 let result: GitAuthResult<ResolvedRepo> = async {
96 // A scoped machine credential (access key) caps what this actor may
97 // do even when the account itself would be entitled to more.
98 if let Some(max) = actor.credential_max
99 && !max.at_least(required)
100 {
101 return Err(GitAuthError::Forbidden(
102 "access key scope is too narrow for this operation".into(),
103 ));
104 }
95105 let db = self.db.reader();
96106 let resolved = resolve::resolve_repo(&db, space_name, repo_name).await?;
97107 permission::check_access(
@@ -109,9 +119,18 @@
109119 result
110120 }
111121
112 async fn verify_credentials(&self, user: &users::Model, password: &str) -> GitAuthResult<Uuid> {
113 if let Ok(claims) = self.validate_jwt_access(password).await {
114 return Ok(claims.user_id());
122 /// Verify a credential for the account looked up by username.
123 ///
124 /// Returns the effective `user_id` (always the account's own uid — a
125 /// credential can never authenticate as a different account) plus, when the
126 /// credential is a scoped access key, the access ceiling it grants.
127 async fn verify_credentials(
128 &self,
129 user: &users::Model,
130 password: &str,
131 ) -> GitAuthResult<(Uuid, Option<RepoAccess>)> {
132 if let Some(key) = self.verify_access_key(user, password).await? {
133 return Ok((user.uid, Some(access_key_max(key.scope))));
115134 }
116135 let Some(hash) = user.passwd_hash.as_ref() else {
117136 return Err(GitAuthError::Unauthorized("invalid credentials".into()));
@@ -121,26 +140,56 @@
121140 &auth::features::password_hash::PasswordHashString(hash.clone()),
122141 )?;
123142 if ok {
124 Ok(user.uid)
143 Ok((user.uid, None))
125144 } else {
126145 Err(GitAuthError::Unauthorized("invalid credentials".into()))
127146 }
128147 }
129148
130 async fn validate_jwt_access(&self, token: &str) -> GitAuthResult<AccessClaims> {
131 let cache = {
132 let mut cache = self.jwt.cache.write().await;
133 if cache.current_kid().is_none() {
134 cache.load_from_db(&self.jwt.db).await?;
135 }
136 cache
137 };
138 let ctx = RotationContext {
139 db: &self.jwt.db,
140 settings: &self.jwt.settings,
141 cache: &cache,
142 };
143 Ok(jwt::validate_access(&ctx, token).await?)
149 /// Match `presented` against one of `user`'s access keys.
150 ///
151 /// Only keys owned by `user` are considered, so a key minted for another
152 /// account can never satisfy this username. The key row is returned so its
153 /// scope can cap the actor's access.
154 async fn verify_access_key(
155 &self,
156 user: &users::Model,
157 presented: &str,
158 ) -> GitAuthResult<Option<user_access_keys::Model>> {
159 let db = self.db.reader();
160 let keys = user_access_keys::Entity::find()
161 .filter(user_access_keys::Column::UserId.eq(user.uid))
162 .all(&db)
163 .await?;
164 Ok(keys.into_iter().find(|key| {
165 auth::features::access_token::hash(presented, &key.token_salt) == key.token_hash
166 }))
167 }
168}
169
170/// Map an access-key scope to the maximum git access it may grant.
171fn access_key_max(scope: user_access_keys::AccessKeyScope) -> RepoAccess {
172 use user_access_keys::AccessKeyScope as S;
173 match scope {
174 S::Read => RepoAccess::Read,
175 S::Write | S::Repo => RepoAccess::Write,
176 S::Admin | S::All => RepoAccess::Admin,
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::access_key_max;
183 use db::user::user_access_keys::AccessKeyScope;
184 use repo::actor::RepoAccess;
185
186 #[test]
187 fn access_key_scope_maps_to_git_access() {
188 assert_eq!(access_key_max(AccessKeyScope::Read), RepoAccess::Read);
189 assert_eq!(access_key_max(AccessKeyScope::Write), RepoAccess::Write);
190 assert_eq!(access_key_max(AccessKeyScope::Repo), RepoAccess::Write);
191 assert_eq!(access_key_max(AccessKeyScope::Admin), RepoAccess::Admin);
192 assert_eq!(access_key_max(AccessKeyScope::All), RepoAccess::Admin);
144193 }
145194}
146195
Added lib/auth/features/access_token.rs lib/auth/features/access_token.rs +40 −0 View lib/auth/features/access_token.rs
@@ -0,0 +1,40 @@
1use sha2::{Digest, Sha256};
2
3/// Hash an access-key token with its per-key salt.
4///
5/// Format is `sha256(token ∥ salt)` hex, matching the value stored in
6/// `user_access_key.token_hash` when a key is created. Kept here (rather than
7/// duplicated in the `user` and `gitauth` crates) so creation and verification
8/// can never drift apart.
9pub fn hash(token: &str, salt: &str) -> String {
10 let mut h = Sha256::new();
11 h.update(token.as_bytes());
12 h.update(salt.as_bytes());
13 h.finalize().iter().map(|b| format!("{:02x}", b)).collect()
14}
15
16#[cfg(test)]
17mod tests {
18 use super::*;
19
20 #[test]
21 fn roundtrip_matches_and_rejects() {
22 let token = "vLYtRpmOf7rG-hYTO5m90g";
23 let salt = "zwvT4aD8qWfK0xH1";
24 let h1 = hash(token, salt);
25 let h2 = hash(token, salt);
26 assert_eq!(h1, h2);
27 assert!(hash(token, salt) == h1);
28 assert_ne!(hash("wrong-token", salt), h1, "different token must differ");
29 assert_ne!(hash(token, "other-salt"), h1, "different salt must differ");
30 assert_eq!(h1.len(), 64, "sha256 hex is 64 chars");
31 }
32
33 #[test]
34 fn salt_isolation_across_keys() {
35 let token = "same-token";
36 let a = hash(token, "salt-one");
37 let b = hash(token, "salt-two");
38 assert_ne!(a, b);
39 }
40}
Modified lib/auth/features/mod.rs lib/auth/features/mod.rs +1 −0 View lib/auth/features/mod.rs
@@ -10,6 +10,7 @@
1010use crate::pow::PowService;
1111use config::auth::{AuthConfig, SharedAuthConfig};
1212
13pub mod access_token;
1314pub mod agreement;
1415pub mod email_code;
1516pub mod forgot_password;
Modified lib/repo/actor.rs lib/repo/actor.rs +6 −0 View lib/repo/actor.rs
@@ -46,6 +46,10 @@
4646 pub scopes: HashSet<TokenScope>,
4747 pub is_site_admin: bool,
4848
49 /// Ceiling this credential may grant (e.g. a `read`-scoped access key).
50 /// `None` means the credential itself imposes no limit.
51 pub credential_max: Option<RepoAccess>,
52
4953 pub channel_role: Option<ChannelRole>,
5054
5155 pub space_role: Option<SpaceRole>,
@@ -63,6 +67,7 @@
6367 user_id: None,
6468 scopes: HashSet::new(),
6569 is_site_admin: false,
70 credential_max: None,
6671 channel_role: None,
6772 space_role: None,
6873 }
@@ -75,6 +80,7 @@
7580 user_id: Some(user_id),
7681 scopes,
7782 is_site_admin: false,
83 credential_max: None,
7884 channel_role: None,
7985 space_role: None,
8086 }
Modified lib/user/features/security/usecase.rs lib/user/features/security/usecase.rs +1 −1 View lib/user/features/security/usecase.rs
@@ -117,7 +117,7 @@
117117 let bytes = svc.config.access_key_token_bytes;
118118 let raw = random_token(bytes);
119119 let salt = random_token(16);
120 let hash = sha256_hex(format!("{}{}", raw, salt).as_bytes());
120 let hash = auth::features::access_token::hash(&raw, &salt);
121121 let last_eight: String = raw
122122 .chars()
123123 .rev()