1use crate::{AuthBackend, ScramCredentials};
41use anyhow::{anyhow, Context, Result};
42use argon2::{
43 password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
44 Argon2,
45};
46use async_trait::async_trait;
47use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
48use rusmes_proto::Username;
49use std::collections::HashMap;
50use std::path::{Path, PathBuf};
51use std::sync::Arc;
52use tokio::fs;
53use tokio::io::{AsyncReadExt, AsyncWriteExt};
54use tokio::sync::RwLock;
55
56#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
66pub enum HashAlgorithm {
67 #[default]
69 Bcrypt,
70 Argon2,
73}
74
75impl HashAlgorithm {
76 pub fn from_config_str(s: &str) -> Result<Self> {
81 match s.to_ascii_lowercase().as_str() {
82 "bcrypt" => Ok(HashAlgorithm::Bcrypt),
83 "argon2" | "argon2id" => Ok(HashAlgorithm::Argon2),
84 other => Err(anyhow!(
85 "unknown hash_algorithm '{}': expected 'bcrypt' or 'argon2'",
86 other
87 )),
88 }
89 }
90}
91
92fn is_bcrypt_hash(hash: &str) -> bool {
94 hash.starts_with("$2a$") || hash.starts_with("$2b$") || hash.starts_with("$2y$")
95}
96
97fn is_argon2_hash(hash: &str) -> bool {
99 hash.starts_with("$argon2id$") || hash.starts_with("$argon2i$") || hash.starts_with("$argon2d$")
100}
101
102#[derive(Debug, Clone)]
116struct UserRecord {
117 hash_field: String,
119}
120
121impl UserRecord {
122 fn password_hash(&self) -> &str {
127 self.hash_field
128 .split('\t')
129 .next()
130 .unwrap_or(&self.hash_field)
131 }
132
133 fn scram_credentials(&self) -> Option<ScramCredentials> {
137 let mut parts = self.hash_field.splitn(5, '\t');
138 let _ = parts.next()?;
140
141 let salt_b64 = parts.next()?;
142 let iter_str = parts.next()?;
143 let sk_b64 = parts.next()?;
144 let svk_b64 = parts.next()?;
145
146 let salt = BASE64.decode(salt_b64).ok()?;
147 let iteration_count = iter_str.parse::<u32>().ok()?;
148 let stored_key = BASE64.decode(sk_b64).ok()?;
149 let server_key = BASE64.decode(svk_b64).ok()?;
150
151 if stored_key.len() != 32 || server_key.len() != 32 || salt.is_empty() {
153 return None;
154 }
155
156 Some(ScramCredentials {
157 salt,
158 iteration_count,
159 stored_key,
160 server_key,
161 })
162 }
163
164 fn with_scram(password_hash: &str, creds: &ScramCredentials) -> Self {
168 let salt_b64 = BASE64.encode(&creds.salt);
169 let sk_b64 = BASE64.encode(&creds.stored_key);
170 let svk_b64 = BASE64.encode(&creds.server_key);
171 let hash_field = format!(
172 "{}\t{}\t{}\t{}\t{}",
173 password_hash, salt_b64, creds.iteration_count, sk_b64, svk_b64
174 );
175 Self { hash_field }
176 }
177}
178
179pub struct FileAuthBackend {
194 file_path: PathBuf,
195 users: Arc<RwLock<HashMap<String, UserRecord>>>,
196 algorithm: HashAlgorithm,
199}
200
201impl FileAuthBackend {
202 pub async fn new(file_path: impl AsRef<Path>) -> Result<Self> {
208 Self::with_algorithm(file_path, HashAlgorithm::default()).await
209 }
210
211 pub async fn with_algorithm(
218 file_path: impl AsRef<Path>,
219 algorithm: HashAlgorithm,
220 ) -> Result<Self> {
221 let file_path = file_path.as_ref().to_path_buf();
222 let users = Self::load_users(&file_path).await?;
223
224 Ok(Self {
225 file_path,
226 users: Arc::new(RwLock::new(users)),
227 algorithm,
228 })
229 }
230
231 pub fn algorithm(&self) -> HashAlgorithm {
233 self.algorithm
234 }
235
236 async fn load_users(file_path: &Path) -> Result<HashMap<String, UserRecord>> {
242 if !file_path.exists() {
244 if let Some(parent) = file_path.parent() {
245 fs::create_dir_all(parent)
246 .await
247 .context("Failed to create parent directory")?;
248 }
249 fs::File::create(file_path)
250 .await
251 .context("Failed to create password file")?;
252 return Ok(HashMap::new());
253 }
254
255 let mut file = fs::File::open(file_path)
256 .await
257 .context("Failed to open password file")?;
258 let mut contents = String::new();
259 file.read_to_string(&mut contents)
260 .await
261 .context("Failed to read password file")?;
262
263 let mut users = HashMap::new();
264 for (line_num, line) in contents.lines().enumerate() {
265 let line = line.trim();
266 if line.is_empty() || line.starts_with('#') {
267 continue;
268 }
269
270 let colon_pos = line.find(':').ok_or_else(|| {
273 anyhow!(
274 "Invalid format on line {}: expected 'username:hash'",
275 line_num + 1
276 )
277 })?;
278
279 let username = &line[..colon_pos];
280 let hash_field = &line[colon_pos + 1..];
281
282 if username.is_empty() {
283 return Err(anyhow!("Empty username on line {}", line_num + 1));
284 }
285
286 let password_hash = hash_field.split('\t').next().unwrap_or(hash_field);
289
290 if !is_bcrypt_hash(password_hash) && !is_argon2_hash(password_hash) {
291 return Err(anyhow!(
292 "Invalid password hash on line {}: expected bcrypt ($2a$/$2b$/$2y$) or argon2 ($argon2id$/$argon2i$/$argon2d$) prefix",
293 line_num + 1
294 ));
295 }
296
297 users.insert(
298 username.to_string(),
299 UserRecord {
300 hash_field: hash_field.to_string(),
301 },
302 );
303 }
304
305 Ok(users)
306 }
307
308 async fn save_users(&self, users: &HashMap<String, UserRecord>) -> Result<()> {
310 let mut contents = String::new();
311 let mut usernames: Vec<&String> = users.keys().collect();
312 usernames.sort();
313
314 for username in usernames {
315 let record = &users[username];
316 contents.push_str(&format!("{}:{}\n", username, record.hash_field));
317 }
318
319 let temp_path = self.file_path.with_extension("tmp");
321 let mut file = fs::File::create(&temp_path)
322 .await
323 .context("Failed to create temporary file")?;
324 file.write_all(contents.as_bytes())
325 .await
326 .context("Failed to write to temporary file")?;
327 file.sync_all()
328 .await
329 .context("Failed to sync temporary file")?;
330 drop(file);
331
332 fs::rename(&temp_path, &self.file_path)
333 .await
334 .context("Failed to rename temporary file")?;
335
336 Ok(())
337 }
338
339 fn hash_password(&self, password: &str) -> Result<String> {
348 match self.algorithm {
349 HashAlgorithm::Bcrypt => bcrypt::hash(password, bcrypt::DEFAULT_COST)
350 .context("Failed to hash password (bcrypt)"),
351 HashAlgorithm::Argon2 => {
352 let salt = SaltString::generate(&mut OsRng);
353 let argon2 = Argon2::default();
354 argon2
355 .hash_password(password.as_bytes(), &salt)
356 .map_err(|e| anyhow!("Failed to hash password (argon2id): {}", e))
357 .map(|h| h.to_string())
358 }
359 }
360 }
361
362 fn verify_password(password: &str, hash: &str) -> Result<bool> {
369 if is_bcrypt_hash(hash) {
370 bcrypt::verify(password, hash).context("Failed to verify password (bcrypt)")
371 } else if is_argon2_hash(hash) {
372 let parsed = PasswordHash::new(hash)
373 .map_err(|e| anyhow!("Failed to parse argon2 PHC string: {}", e))?;
374 match Argon2::default().verify_password(password.as_bytes(), &parsed) {
375 Ok(()) => Ok(true),
376 Err(argon2::password_hash::Error::Password) => Ok(false),
377 Err(e) => Err(anyhow!("Failed to verify password (argon2id): {}", e)),
378 }
379 } else {
380 Err(anyhow!(
381 "Unrecognized password hash format (no bcrypt or argon2 prefix)"
382 ))
383 }
384 }
385
386 pub async fn set_scram_credentials(
400 &self,
401 user: &str,
402 credentials: ScramCredentials,
403 ) -> Result<()> {
404 let mut users = self.users.write().await;
405
406 let record = users
407 .get(user)
408 .ok_or_else(|| anyhow!("User '{}' does not exist", user))?;
409
410 let password_hash = record.password_hash().to_string();
411 let new_record = UserRecord::with_scram(&password_hash, &credentials);
412 users.insert(user.to_string(), new_record);
413
414 self.save_users(&users).await
415 }
416}
417
418#[async_trait]
423impl AuthBackend for FileAuthBackend {
424 async fn authenticate(&self, username: &Username, password: &str) -> Result<bool> {
425 let users = self.users.read().await;
426
427 if let Some(record) = users.get(username.as_str()) {
428 Self::verify_password(password, record.password_hash())
429 } else {
430 let _ = bcrypt::verify(
436 password,
437 "$2b$12$dummy_hash_to_prevent_timing_attack_00000000000000000000000000000",
438 );
439 Ok(false)
440 }
441 }
442
443 async fn verify_identity(&self, username: &Username) -> Result<bool> {
444 let users = self.users.read().await;
445 Ok(users.contains_key(username.as_str()))
446 }
447
448 async fn list_users(&self) -> Result<Vec<Username>> {
449 let users = self.users.read().await;
450 let mut usernames = Vec::new();
451
452 for username_str in users.keys() {
453 let username = Username::new(username_str.clone()).context(format!(
454 "Invalid username in password file: {}",
455 username_str
456 ))?;
457 usernames.push(username);
458 }
459
460 usernames.sort_by(|a, b| a.as_str().cmp(b.as_str()));
461 Ok(usernames)
462 }
463
464 async fn create_user(&self, username: &Username, password: &str) -> Result<()> {
465 let mut users = self.users.write().await;
466
467 if users.contains_key(username.as_str()) {
468 return Err(anyhow!("User '{}' already exists", username.as_str()));
469 }
470
471 let hash = self.hash_password(password)?;
472 users.insert(
473 username.as_str().to_string(),
474 UserRecord { hash_field: hash },
475 );
476
477 self.save_users(&users).await
478 }
479
480 async fn delete_user(&self, username: &Username) -> Result<()> {
481 let mut users = self.users.write().await;
482
483 if !users.contains_key(username.as_str()) {
484 return Err(anyhow!("User '{}' does not exist", username.as_str()));
485 }
486
487 users.remove(username.as_str());
488 self.save_users(&users).await
489 }
490
491 async fn change_password(&self, username: &Username, new_password: &str) -> Result<()> {
492 let mut users = self.users.write().await;
493
494 let record = users
495 .get(username.as_str())
496 .ok_or_else(|| anyhow!("User '{}' does not exist", username.as_str()))?;
497
498 let existing_scram = record.scram_credentials();
500 let new_hash = self.hash_password(new_password)?;
501
502 let new_record = match existing_scram {
503 Some(creds) => UserRecord::with_scram(&new_hash, &creds),
504 None => UserRecord {
505 hash_field: new_hash,
506 },
507 };
508 users.insert(username.as_str().to_string(), new_record);
509
510 self.save_users(&users).await
511 }
512
513 async fn fetch_scram_credentials(&self, user: &str) -> Result<Option<ScramCredentials>> {
522 let users = self.users.read().await;
523 let record = match users.get(user) {
524 Some(r) => r,
525 None => return Ok(None),
526 };
527 Ok(record.scram_credentials())
528 }
529
530 async fn get_scram_params(&self, username: &str) -> Result<(Vec<u8>, u32)> {
535 let creds = self
536 .fetch_scram_credentials(username)
537 .await?
538 .ok_or_else(|| anyhow!("No SCRAM credentials stored for user '{}'", username))?;
539 Ok((creds.salt, creds.iteration_count))
540 }
541
542 async fn get_scram_stored_key(&self, username: &str) -> Result<Vec<u8>> {
543 let creds = self
544 .fetch_scram_credentials(username)
545 .await?
546 .ok_or_else(|| anyhow!("No SCRAM credentials stored for user '{}'", username))?;
547 Ok(creds.stored_key)
548 }
549
550 async fn get_scram_server_key(&self, username: &str) -> Result<Vec<u8>> {
551 let creds = self
552 .fetch_scram_credentials(username)
553 .await?
554 .ok_or_else(|| anyhow!("No SCRAM credentials stored for user '{}'", username))?;
555 Ok(creds.server_key)
556 }
557
558 async fn store_scram_credentials(
559 &self,
560 username: &Username,
561 salt: Vec<u8>,
562 iterations: u32,
563 stored_key: Vec<u8>,
564 server_key: Vec<u8>,
565 ) -> Result<()> {
566 let creds = ScramCredentials {
567 salt,
568 iteration_count: iterations,
569 stored_key,
570 server_key,
571 };
572 self.set_scram_credentials(username.as_str(), creds).await
573 }
574}
575
576#[cfg(test)]
581mod tests {
582 use super::*;
583 use crate::backends::{
584 ldap::LdapBackend, ldap::LdapConfig, oauth2::OAuth2Backend, oauth2::OAuth2Config,
585 };
586 use crate::{AuthBackendKind, FileBackendConfig};
587 use hmac::{Hmac, Mac};
588 use sha2::Sha256;
589 use std::env;
590 use std::fs as std_fs;
591
592 type HmacSha256 = Hmac<Sha256>;
593
594 fn derive_scram_creds(password: &str, salt: &[u8], iterations: u32) -> ScramCredentials {
597 let mut salted_password = vec![0u8; 32];
599 pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), salt, iterations, &mut salted_password);
600
601 let mut mac =
603 HmacSha256::new_from_slice(&salted_password).expect("HMAC accepts any key length");
604 mac.update(b"Client Key");
605 let client_key = mac.finalize().into_bytes();
606
607 use sha2::Digest;
609 let mut hasher = sha2::Sha256::new();
610 hasher.update(client_key);
611 let stored_key = hasher.finalize().to_vec();
612
613 let mut mac2 =
615 HmacSha256::new_from_slice(&salted_password).expect("HMAC accepts any key length");
616 mac2.update(b"Server Key");
617 let server_key = mac2.finalize().into_bytes().to_vec();
618
619 ScramCredentials {
620 salt: salt.to_vec(),
621 iteration_count: iterations,
622 stored_key,
623 server_key,
624 }
625 }
626
627 #[tokio::test]
631 async fn auth_backend_kind_build_file() {
632 let dir = env::temp_dir().join(format!("rusmes_auth_kind_build_{}", std::process::id()));
633 std_fs::create_dir_all(&dir).expect("create temp dir");
634 let passwd_path = dir.join("passwd");
635
636 let kind = AuthBackendKind::File(FileBackendConfig {
638 path: passwd_path.to_string_lossy().to_string(),
639 hash_algorithm: HashAlgorithm::default(),
640 });
641 let backend = kind.build().await.expect("build file backend");
642
643 let username = Username::new("testuser".to_string()).expect("valid username");
645 backend
646 .create_user(&username, "s3cr3t!")
647 .await
648 .expect("create user");
649
650 let ok = backend
652 .authenticate(&username, "s3cr3t!")
653 .await
654 .expect("authenticate");
655 assert!(ok, "correct password must authenticate");
656
657 let bad = backend
659 .authenticate(&username, "wrong")
660 .await
661 .expect("authenticate with wrong pw");
662 assert!(!bad, "wrong password must not authenticate");
663
664 std_fs::remove_dir_all(&dir).ok();
666 }
667
668 #[tokio::test]
672 async fn file_backend_scram_credentials_roundtrip() {
673 let dir = env::temp_dir().join(format!(
674 "rusmes_auth_scram_roundtrip_{}",
675 std::process::id()
676 ));
677 std_fs::create_dir_all(&dir).expect("create temp dir");
678 let passwd_path = dir.join("passwd");
679
680 let hash = bcrypt::hash("hunter2", 4).expect("bcrypt hash");
683 std_fs::write(&passwd_path, format!("olduser:{}\n", hash))
684 .expect("write old-format passwd");
685
686 let backend = FileAuthBackend::new(&passwd_path)
687 .await
688 .expect("load old-format passwd");
689
690 let user = Username::new("olduser".to_string()).expect("username");
692 assert!(
693 backend.authenticate(&user, "hunter2").await.expect("auth"),
694 "old-format user must authenticate"
695 );
696
697 let none = backend
699 .fetch_scram_credentials("olduser")
700 .await
701 .expect("fetch scram");
702 assert!(none.is_none(), "old-format user has no SCRAM credentials");
703
704 let salt = b"naCl_and_pepper!!"; let creds = derive_scram_creds("hunter2", salt, 4096);
707
708 backend
709 .set_scram_credentials("olduser", creds.clone())
710 .await
711 .expect("set_scram_credentials");
712
713 let fetched = backend
714 .fetch_scram_credentials("olduser")
715 .await
716 .expect("fetch after set")
717 .expect("credentials must be present");
718
719 assert_eq!(fetched.salt, creds.salt, "salt round-trip");
720 assert_eq!(
721 fetched.iteration_count, creds.iteration_count,
722 "iteration_count round-trip"
723 );
724 assert_eq!(
725 fetched.stored_key, creds.stored_key,
726 "stored_key round-trip"
727 );
728 assert_eq!(
729 fetched.server_key, creds.server_key,
730 "server_key round-trip"
731 );
732
733 let backend2 = FileAuthBackend::new(&passwd_path)
735 .await
736 .expect("reload backend");
737 let reloaded = backend2
738 .fetch_scram_credentials("olduser")
739 .await
740 .expect("fetch after reload")
741 .expect("credentials survive disk round-trip");
742 assert_eq!(
743 reloaded.stored_key, creds.stored_key,
744 "persisted stored_key"
745 );
746 assert_eq!(
747 reloaded.server_key, creds.server_key,
748 "persisted server_key"
749 );
750
751 assert!(
753 backend2
754 .authenticate(&user, "hunter2")
755 .await
756 .expect("re-auth"),
757 "bcrypt auth must still work after SCRAM credential write"
758 );
759
760 std_fs::remove_dir_all(&dir).ok();
762 }
763
764 #[tokio::test]
768 async fn default_fetch_scram_credentials_returns_none() {
769 let ldap = LdapBackend::new(LdapConfig::default());
771 let result = ldap
772 .fetch_scram_credentials("anyuser")
773 .await
774 .expect("LDAP default must not error");
775 assert!(
776 result.is_none(),
777 "LdapBackend must return Ok(None) for fetch_scram_credentials"
778 );
779
780 let oauth2 = OAuth2Backend::new(OAuth2Config::default());
782 let result2 = oauth2
783 .fetch_scram_credentials("anyuser")
784 .await
785 .expect("OAuth2 default must not error");
786 assert!(
787 result2.is_none(),
788 "OAuth2Backend must return Ok(None) for fetch_scram_credentials"
789 );
790
791 }
794
795 #[tokio::test]
806 async fn argon2_roundtrip_and_bcrypt_compat() {
807 let dir = env::temp_dir().join(format!(
808 "rusmes_auth_argon2_roundtrip_{}",
809 std::process::id()
810 ));
811 std_fs::create_dir_all(&dir).expect("create temp dir");
812 let passwd_path = dir.join("passwd");
813
814 let bcrypt_hash = bcrypt::hash("legacy-pass", 4).expect("bcrypt hash");
818 std_fs::write(&passwd_path, format!("legacyuser:{}\n", bcrypt_hash))
819 .expect("seed bcrypt-only file");
820
821 let backend = FileAuthBackend::with_algorithm(&passwd_path, HashAlgorithm::Argon2)
823 .await
824 .expect("load passwd file");
825 assert_eq!(backend.algorithm(), HashAlgorithm::Argon2);
826
827 let legacy = Username::new("legacyuser".to_string()).expect("legacy username");
830 assert!(
831 backend
832 .authenticate(&legacy, "legacy-pass")
833 .await
834 .expect("auth legacy"),
835 "pre-existing bcrypt hash must still verify under argon2 config"
836 );
837 assert!(
838 !backend
839 .authenticate(&legacy, "wrong")
840 .await
841 .expect("auth legacy bad"),
842 "wrong bcrypt password must fail under argon2 config"
843 );
844
845 let new_user = Username::new("alice".to_string()).expect("alice username");
847 backend
848 .create_user(&new_user, "Sup3rSecret!")
849 .await
850 .expect("create alice");
851
852 let on_disk = std_fs::read_to_string(&passwd_path).expect("read passwd file");
855 let alice_line = on_disk
856 .lines()
857 .find(|l| l.starts_with("alice:"))
858 .expect("alice line");
859 let alice_hash_field = &alice_line["alice:".len()..];
860 let alice_hash = alice_hash_field
861 .split('\t')
862 .next()
863 .expect("alice hash field non-empty");
864 assert!(
865 alice_hash.starts_with("$argon2id$"),
866 "new password under argon2 config must produce $argon2id$ hash, got: {}",
867 alice_hash
868 );
869
870 assert!(
872 backend
873 .authenticate(&new_user, "Sup3rSecret!")
874 .await
875 .expect("auth alice"),
876 "argon2 hash must verify with correct password"
877 );
878 assert!(
879 !backend
880 .authenticate(&new_user, "wrong-pass")
881 .await
882 .expect("auth alice bad"),
883 "argon2 hash must reject wrong password"
884 );
885
886 assert!(
888 backend
889 .authenticate(&legacy, "legacy-pass")
890 .await
891 .expect("re-auth legacy"),
892 "bcrypt user still verifies after argon2 user is created"
893 );
894
895 let scram = derive_scram_creds("Sup3rSecret!", b"some-salt-bytes!", 4096);
899 backend
900 .set_scram_credentials("alice", scram.clone())
901 .await
902 .expect("set scram on alice");
903 backend
904 .change_password(&new_user, "NewArgon2Pass!")
905 .await
906 .expect("change_password to argon2");
907 let after = backend
908 .fetch_scram_credentials("alice")
909 .await
910 .expect("fetch scram after change_password")
911 .expect("scram preserved");
912 assert_eq!(
913 after.salt, scram.salt,
914 "SCRAM salt preserved across argon2 password change"
915 );
916 assert!(
917 backend
918 .authenticate(&new_user, "NewArgon2Pass!")
919 .await
920 .expect("auth new password"),
921 "argon2 hash from change_password must verify"
922 );
923
924 let backend2 = FileAuthBackend::with_algorithm(&passwd_path, HashAlgorithm::Argon2)
926 .await
927 .expect("reload backend");
928 assert!(
929 backend2
930 .authenticate(&legacy, "legacy-pass")
931 .await
932 .expect("reload bcrypt legacy"),
933 "bcrypt legacy verifies after disk reload"
934 );
935 assert!(
936 backend2
937 .authenticate(&new_user, "NewArgon2Pass!")
938 .await
939 .expect("reload argon2 alice"),
940 "argon2 alice verifies after disk reload"
941 );
942
943 std_fs::remove_dir_all(&dir).ok();
945 }
946
947 #[test]
948 fn hash_algorithm_from_config_str_accepts_known_values() {
949 assert_eq!(
950 HashAlgorithm::from_config_str("bcrypt").expect("bcrypt"),
951 HashAlgorithm::Bcrypt
952 );
953 assert_eq!(
954 HashAlgorithm::from_config_str("BCRYPT").expect("BCRYPT"),
955 HashAlgorithm::Bcrypt
956 );
957 assert_eq!(
958 HashAlgorithm::from_config_str("argon2").expect("argon2"),
959 HashAlgorithm::Argon2
960 );
961 assert_eq!(
962 HashAlgorithm::from_config_str("argon2id").expect("argon2id"),
963 HashAlgorithm::Argon2
964 );
965 assert_eq!(
966 HashAlgorithm::from_config_str("Argon2ID").expect("Argon2ID"),
967 HashAlgorithm::Argon2
968 );
969 assert!(HashAlgorithm::from_config_str("scrypt").is_err());
970 assert!(HashAlgorithm::from_config_str("").is_err());
971 }
972}