Skip to main content

rusmes_auth/backends/
system.rs

1//! Pure Rust system authentication backend
2//!
3//! Reads `/etc/passwd` and `/etc/shadow` directly, replacing the C-based PAM backend.
4//! Supports SHA-512 (`$6$`), SHA-256 (`$5$`), bcrypt (`$2b$`/`$2a$`/`$2y$`),
5//! and MD5-crypt (`$1$`) password hash verification.
6
7use crate::AuthBackend;
8use anyhow::{anyhow, Context, Result};
9use async_trait::async_trait;
10use md5::Md5;
11use rusmes_proto::Username;
12use sha2::{Digest, Sha256, Sha512};
13use std::path::{Path, PathBuf};
14
15// ── Custom base64 alphabet (Drepper / crypt(3)) ────────────────────────────
16
17const CRYPT_B64: &[u8; 64] = b"./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
18
19/// Encode `n` least-significant bits from `value` into the crypt base64 alphabet.
20fn b64_encode_bits(value: u32, n: usize) -> String {
21    let mut out = String::with_capacity(n);
22    let mut v = value;
23    for _ in 0..n {
24        out.push(CRYPT_B64[(v & 0x3f) as usize] as char);
25        v >>= 6;
26    }
27    out
28}
29
30// ── SHA-512 crypt ($6$) ─────────────────────────────────────────────────────
31
32/// SHA-512 byte-reordering table derived from the crypt(3) transposition table.
33/// Each triple (b2, b1, b0) produces 4 base64 chars via
34/// `value = (b2 << 16) | (b1 << 8) | b0`, with characters extracted LSB-first.
35/// The final singleton (63) produces 2 chars.
36const SHA512_REORDER: [(usize, usize, usize); 21] = [
37    (0, 21, 42),
38    (22, 43, 1),
39    (44, 2, 23),
40    (3, 24, 45),
41    (25, 46, 4),
42    (47, 5, 26),
43    (6, 27, 48),
44    (28, 49, 7),
45    (50, 8, 29),
46    (9, 30, 51),
47    (31, 52, 10),
48    (53, 11, 32),
49    (12, 33, 54),
50    (34, 55, 13),
51    (56, 14, 35),
52    (15, 36, 57),
53    (37, 58, 16),
54    (59, 17, 38),
55    (18, 39, 60),
56    (40, 61, 19),
57    (62, 20, 41),
58];
59
60/// Implement the Drepper SHA-512 crypt algorithm (`$6$`).
61///
62/// Reference: <https://www.akkadia.org/drepper/SHA-crypt.txt>
63fn sha512_crypt(password: &[u8], salt: &[u8], rounds: u32) -> String {
64    let p_len = password.len();
65    let s_len = salt.len();
66
67    // ── Step 1-3: Compute digest B ──────────────────────────────────────
68    let digest_b = {
69        let mut ctx = Sha512::new();
70        ctx.update(password);
71        ctx.update(salt);
72        ctx.update(password);
73        ctx.finalize()
74    };
75
76    // ── Step 4-8: Compute digest A ──────────────────────────────────────
77    let digest_a = {
78        let mut ctx = Sha512::new();
79        ctx.update(password); // step 4
80        ctx.update(salt); // step 5
81
82        // step 6: add bytes from B, repeating the 64-byte digest as needed
83        let mut remaining = p_len;
84        while remaining >= 64 {
85            ctx.update(&digest_b[..]);
86            remaining -= 64;
87        }
88        if remaining > 0 {
89            ctx.update(&digest_b[..remaining]);
90        }
91
92        // step 7: for each bit of password length (LSB first)
93        let mut n = p_len;
94        while n > 0 {
95            if n & 1 != 0 {
96                ctx.update(&digest_b[..]);
97            } else {
98                ctx.update(password);
99            }
100            n >>= 1;
101        }
102
103        ctx.finalize()
104    };
105
106    // ── Step 9-10: Compute DP (P-bytes) ─────────────────────────────────
107    let p_bytes = {
108        let mut ctx = Sha512::new();
109        for _ in 0..p_len {
110            ctx.update(password);
111        }
112        let dp = ctx.finalize();
113
114        let mut buf = vec![0u8; p_len];
115        let mut off = 0;
116        while off + 64 <= p_len {
117            buf[off..off + 64].copy_from_slice(&dp[..]);
118            off += 64;
119        }
120        if off < p_len {
121            buf[off..].copy_from_slice(&dp[..p_len - off]);
122        }
123        buf
124    };
125
126    // ── Step 11-12: Compute DS (S-bytes) ────────────────────────────────
127    let s_bytes = {
128        let repeat_count = 16 + (digest_a[0] as usize);
129        let mut ctx = Sha512::new();
130        for _ in 0..repeat_count {
131            ctx.update(salt);
132        }
133        let ds = ctx.finalize();
134
135        let mut buf = vec![0u8; s_len];
136        let mut off = 0;
137        while off + 64 <= s_len {
138            buf[off..off + 64].copy_from_slice(&ds[..]);
139            off += 64;
140        }
141        if off < s_len {
142            buf[off..].copy_from_slice(&ds[..s_len - off]);
143        }
144        buf
145    };
146
147    // ── Step 13: Rounds ─────────────────────────────────────────────────
148    let mut prev: [u8; 64] = digest_a.into();
149    for i in 0..rounds {
150        let mut ctx = Sha512::new();
151        if i & 1 != 0 {
152            ctx.update(&p_bytes);
153        } else {
154            ctx.update(prev);
155        }
156        if i % 3 != 0 {
157            ctx.update(&s_bytes);
158        }
159        if i % 7 != 0 {
160            ctx.update(&p_bytes);
161        }
162        if i & 1 != 0 {
163            ctx.update(prev);
164        } else {
165            ctx.update(&p_bytes);
166        }
167        let out = ctx.finalize();
168        prev.copy_from_slice(&out);
169    }
170
171    // ── Step 14: Encode final digest ────────────────────────────────────
172    let mut encoded = String::with_capacity(86);
173    for &(a, b, c) in &SHA512_REORDER {
174        let v = (prev[a] as u32) << 16 | (prev[b] as u32) << 8 | (prev[c] as u32);
175        encoded.push_str(&b64_encode_bits(v, 4));
176    }
177    // Final single byte (index 63) → 2 chars
178    encoded.push_str(&b64_encode_bits(prev[63] as u32, 2));
179
180    encoded
181}
182
183/// Produce the full `$6$...` hash string for the given password and salt.
184fn sha512_crypt_full(password: &[u8], raw_salt: &str) -> String {
185    let (rounds, salt) = parse_rounds_and_salt(raw_salt);
186    // Salt is truncated to 16 characters per the spec.
187    let salt = if salt.len() > 16 { &salt[..16] } else { salt };
188    let hash = sha512_crypt(password, salt.as_bytes(), rounds);
189    if rounds == 5000 {
190        format!("$6${salt}${hash}")
191    } else {
192        format!("$6$rounds={rounds}${salt}${hash}")
193    }
194}
195
196// ── SHA-256 crypt ($5$) ─────────────────────────────────────────────────────
197
198/// SHA-256 byte-reordering table.  Each triple (a,b,c) → 4 chars;
199/// the final pair (30,31) → 3 chars.
200const SHA256_REORDER: [(usize, usize, usize); 10] = [
201    (0, 10, 20),
202    (21, 1, 11),
203    (12, 22, 2),
204    (3, 13, 23),
205    (24, 4, 14),
206    (15, 25, 5),
207    (6, 16, 26),
208    (27, 7, 17),
209    (18, 28, 8),
210    (9, 19, 29),
211];
212
213/// Implement the Drepper SHA-256 crypt algorithm (`$5$`).
214fn sha256_crypt(password: &[u8], salt: &[u8], rounds: u32) -> String {
215    let p_len = password.len();
216    let s_len = salt.len();
217
218    // Digest B
219    let digest_b = {
220        let mut ctx = Sha256::new();
221        ctx.update(password);
222        ctx.update(salt);
223        ctx.update(password);
224        ctx.finalize()
225    };
226
227    // Digest A
228    let digest_a = {
229        let mut ctx = Sha256::new();
230        ctx.update(password);
231        ctx.update(salt);
232
233        let mut remaining = p_len;
234        while remaining >= 32 {
235            ctx.update(&digest_b[..]);
236            remaining -= 32;
237        }
238        if remaining > 0 {
239            ctx.update(&digest_b[..remaining]);
240        }
241
242        let mut n = p_len;
243        while n > 0 {
244            if n & 1 != 0 {
245                ctx.update(&digest_b[..]);
246            } else {
247                ctx.update(password);
248            }
249            n >>= 1;
250        }
251
252        ctx.finalize()
253    };
254
255    // P-bytes
256    let p_bytes = {
257        let mut ctx = Sha256::new();
258        for _ in 0..p_len {
259            ctx.update(password);
260        }
261        let dp = ctx.finalize();
262
263        let mut buf = vec![0u8; p_len];
264        let mut off = 0;
265        while off + 32 <= p_len {
266            buf[off..off + 32].copy_from_slice(&dp[..]);
267            off += 32;
268        }
269        if off < p_len {
270            buf[off..].copy_from_slice(&dp[..p_len - off]);
271        }
272        buf
273    };
274
275    // S-bytes
276    let s_bytes = {
277        let repeat_count = 16 + (digest_a[0] as usize);
278        let mut ctx = Sha256::new();
279        for _ in 0..repeat_count {
280            ctx.update(salt);
281        }
282        let ds = ctx.finalize();
283
284        let mut buf = vec![0u8; s_len];
285        let mut off = 0;
286        while off + 32 <= s_len {
287            buf[off..off + 32].copy_from_slice(&ds[..]);
288            off += 32;
289        }
290        if off < s_len {
291            buf[off..].copy_from_slice(&ds[..s_len - off]);
292        }
293        buf
294    };
295
296    // Rounds
297    let mut prev: [u8; 32] = digest_a.into();
298    for i in 0..rounds {
299        let mut ctx = Sha256::new();
300        if i & 1 != 0 {
301            ctx.update(&p_bytes);
302        } else {
303            ctx.update(prev);
304        }
305        if i % 3 != 0 {
306            ctx.update(&s_bytes);
307        }
308        if i % 7 != 0 {
309            ctx.update(&p_bytes);
310        }
311        if i & 1 != 0 {
312            ctx.update(prev);
313        } else {
314            ctx.update(&p_bytes);
315        }
316        let out = ctx.finalize();
317        prev.copy_from_slice(&out);
318    }
319
320    // Encode
321    let mut encoded = String::with_capacity(43);
322    for &(a, b, c) in &SHA256_REORDER {
323        let v = (prev[a] as u32) << 16 | (prev[b] as u32) << 8 | (prev[c] as u32);
324        encoded.push_str(&b64_encode_bits(v, 4));
325    }
326    // Final pair (31, 30) → 3 chars  (note: glibc order is b64_from_24bit(0, final[31], final[30]))
327    let v = (prev[31] as u32) << 8 | (prev[30] as u32);
328    encoded.push_str(&b64_encode_bits(v, 3));
329
330    encoded
331}
332
333/// Produce the full `$5$...` hash string.
334fn sha256_crypt_full(password: &[u8], raw_salt: &str) -> String {
335    let (rounds, salt) = parse_rounds_and_salt(raw_salt);
336    let salt = if salt.len() > 16 { &salt[..16] } else { salt };
337    let hash = sha256_crypt(password, salt.as_bytes(), rounds);
338    if rounds == 5000 {
339        format!("$5${salt}${hash}")
340    } else {
341        format!("$5$rounds={rounds}${salt}${hash}")
342    }
343}
344
345// ── MD5-crypt ($1$) ─────────────────────────────────────────────────────────
346
347/// MD5-crypt (`$1$`) implementation per the original Poul-Henning Kamp algorithm.
348fn md5_crypt(password: &[u8], salt: &[u8]) -> String {
349    let p_len = password.len();
350
351    // Step 1: alternate sum
352    let digest_b = {
353        let mut ctx = Md5::new();
354        ctx.update(password);
355        ctx.update(salt);
356        ctx.update(password);
357        ctx.finalize()
358    };
359
360    // Step 2: main sum
361    let mut ctx = Md5::new();
362    ctx.update(password);
363    ctx.update(b"$1$");
364    ctx.update(salt);
365
366    // Add bytes from alternate sum
367    let mut remaining = p_len;
368    while remaining >= 16 {
369        ctx.update(&digest_b[..]);
370        remaining -= 16;
371    }
372    if remaining > 0 {
373        ctx.update(&digest_b[..remaining]);
374    }
375
376    // Bit-pattern of password length
377    let mut n = p_len;
378    while n > 0 {
379        if n & 1 != 0 {
380            ctx.update([0u8]);
381        } else {
382            ctx.update(&password[..1]);
383        }
384        n >>= 1;
385    }
386
387    let mut result: [u8; 16] = ctx.finalize().into();
388
389    // 1000 rounds
390    for i in 0..1000u32 {
391        let mut ctx2 = Md5::new();
392        if i & 1 != 0 {
393            ctx2.update(password);
394        } else {
395            ctx2.update(result);
396        }
397        if i % 3 != 0 {
398            ctx2.update(salt);
399        }
400        if i % 7 != 0 {
401            ctx2.update(password);
402        }
403        if i & 1 != 0 {
404            ctx2.update(result);
405        } else {
406            ctx2.update(password);
407        }
408        result = ctx2.finalize().into();
409    }
410
411    // MD5-crypt byte reordering and encoding
412    let mut encoded = String::with_capacity(22);
413    let groups: [(usize, usize, usize); 5] =
414        [(0, 6, 12), (1, 7, 13), (2, 8, 14), (3, 9, 15), (4, 10, 5)];
415    for (a, b, c) in groups {
416        let v = (result[a] as u32) << 16 | (result[b] as u32) << 8 | (result[c] as u32);
417        encoded.push_str(&b64_encode_bits(v, 4));
418    }
419    // Final byte (index 11) → 2 chars
420    encoded.push_str(&b64_encode_bits(result[11] as u32, 2));
421
422    encoded
423}
424
425fn md5_crypt_full(password: &[u8], salt: &str) -> String {
426    let hash = md5_crypt(password, salt.as_bytes());
427    format!("$1${salt}${hash}")
428}
429
430// ── Utilities ───────────────────────────────────────────────────────────────
431
432/// Parse an optional `rounds=N$` prefix and the salt from the combined string.
433///
434/// Input examples:
435///   - `"saltstring"` → (5000, `"saltstring"`)
436///   - `"rounds=10000$saltstring"` → (10000, `"saltstring"`)
437fn parse_rounds_and_salt(raw: &str) -> (u32, &str) {
438    if let Some(rest) = raw.strip_prefix("rounds=") {
439        if let Some(dollar_pos) = rest.find('$') {
440            let rounds_str = &rest[..dollar_pos];
441            let salt = &rest[dollar_pos + 1..];
442            let rounds = rounds_str
443                .parse::<u32>()
444                .unwrap_or(5000)
445                .clamp(1000, 999_999_999);
446            return (rounds, salt);
447        }
448    }
449    (5000, raw)
450}
451
452/// Verify a password against a crypt-style hash string.
453///
454/// Supported prefixes:
455///   - `$6$` — SHA-512 crypt
456///   - `$5$` — SHA-256 crypt
457///   - `$1$` — MD5 crypt (legacy)
458///   - `$2b$` / `$2a$` / `$2y$` — bcrypt (delegated to the `bcrypt` crate)
459fn verify_password(password: &str, hash: &str) -> Result<bool> {
460    if hash.starts_with("$6$") {
461        let inner = hash
462            .strip_prefix("$6$")
463            .ok_or_else(|| anyhow!("invalid $6$ hash"))?;
464        // inner = [rounds=N$]salt$hash
465        let last_dollar = inner
466            .rfind('$')
467            .ok_or_else(|| anyhow!("malformed $6$ hash: missing final delimiter"))?;
468        let raw_salt = &inner[..last_dollar];
469        let computed = sha512_crypt_full(password.as_bytes(), raw_salt);
470        Ok(computed == hash)
471    } else if hash.starts_with("$5$") {
472        let inner = hash
473            .strip_prefix("$5$")
474            .ok_or_else(|| anyhow!("invalid $5$ hash"))?;
475        let last_dollar = inner
476            .rfind('$')
477            .ok_or_else(|| anyhow!("malformed $5$ hash: missing final delimiter"))?;
478        let raw_salt = &inner[..last_dollar];
479        let computed = sha256_crypt_full(password.as_bytes(), raw_salt);
480        Ok(computed == hash)
481    } else if hash.starts_with("$1$") {
482        let inner = hash
483            .strip_prefix("$1$")
484            .ok_or_else(|| anyhow!("invalid $1$ hash"))?;
485        let last_dollar = inner
486            .rfind('$')
487            .ok_or_else(|| anyhow!("malformed $1$ hash: missing final delimiter"))?;
488        let salt = &inner[..last_dollar];
489        let computed = md5_crypt_full(password.as_bytes(), salt);
490        Ok(computed == hash)
491    } else if hash.starts_with("$2b$") || hash.starts_with("$2a$") || hash.starts_with("$2y$") {
492        bcrypt::verify(password, hash).map_err(|e| anyhow!("bcrypt verification error: {e}"))
493    } else if hash == "*" || hash == "!" || hash == "!!" || hash.starts_with("!") {
494        // Locked / disabled account
495        Ok(false)
496    } else {
497        Err(anyhow!(
498            "unsupported password hash scheme: {}",
499            &hash[..hash.len().min(10)]
500        ))
501    }
502}
503
504// ── /etc/passwd parsing ─────────────────────────────────────────────────────
505
506/// A parsed entry from `/etc/passwd`.
507#[derive(Debug, Clone)]
508struct PasswdEntry {
509    username: String,
510    uid: u32,
511    #[allow(dead_code)]
512    gid: u32,
513    #[allow(dead_code)]
514    gecos: String,
515    #[allow(dead_code)]
516    home: String,
517    #[allow(dead_code)]
518    shell: String,
519}
520
521/// Parse a single `/etc/passwd` line.  Returns `None` for comments, blank lines,
522/// or malformed entries.
523fn parse_passwd_line(line: &str) -> Option<PasswdEntry> {
524    let line = line.trim();
525    if line.is_empty() || line.starts_with('#') {
526        return None;
527    }
528    let parts: Vec<&str> = line.split(':').collect();
529    if parts.len() < 7 {
530        return None;
531    }
532    let uid = parts[2].parse::<u32>().ok()?;
533    let gid = parts[3].parse::<u32>().ok()?;
534    Some(PasswdEntry {
535        username: parts[0].to_owned(),
536        uid,
537        gid,
538        gecos: parts[4].to_owned(),
539        home: parts[5].to_owned(),
540        shell: parts[6].to_owned(),
541    })
542}
543
544/// Read and parse all entries from a passwd-format file.
545async fn read_passwd_file(path: &Path) -> Result<Vec<PasswdEntry>> {
546    let content = tokio::fs::read_to_string(path)
547        .await
548        .with_context(|| format!("failed to read {}", path.display()))?;
549    Ok(content.lines().filter_map(parse_passwd_line).collect())
550}
551
552// ── /etc/shadow parsing ─────────────────────────────────────────────────────
553
554/// A parsed entry from `/etc/shadow`.
555#[derive(Debug, Clone)]
556struct ShadowEntry {
557    username: String,
558    hash: String,
559    /// `true` when the password field starts with `!` or `*` (locked).
560    locked: bool,
561}
562
563/// Parse a single `/etc/shadow` line.
564fn parse_shadow_line(line: &str) -> Option<ShadowEntry> {
565    let line = line.trim();
566    if line.is_empty() || line.starts_with('#') {
567        return None;
568    }
569    let parts: Vec<&str> = line.splitn(9, ':').collect();
570    if parts.len() < 2 {
571        return None;
572    }
573    let username = parts[0].to_owned();
574    let raw_hash = parts[1];
575    let locked = raw_hash.starts_with('!') || raw_hash.starts_with('*');
576
577    // If locked with "!" prefix the real hash follows after the "!"
578    let hash = if raw_hash.starts_with('!') && raw_hash.len() > 1 && raw_hash != "!!" {
579        raw_hash[1..].to_owned()
580    } else {
581        raw_hash.to_owned()
582    };
583
584    Some(ShadowEntry {
585        username,
586        hash,
587        locked,
588    })
589}
590
591/// Read and parse all entries from a shadow-format file.
592async fn read_shadow_file(path: &Path) -> Result<Vec<ShadowEntry>> {
593    let content = tokio::fs::read_to_string(path)
594        .await
595        .with_context(|| format!("failed to read {}", path.display()))?;
596    Ok(content.lines().filter_map(parse_shadow_line).collect())
597}
598
599// ── SystemAuthBackend ───────────────────────────────────────────────────────
600
601/// Configuration for the system (passwd/shadow) authentication backend.
602#[derive(Debug, Clone)]
603pub struct SystemConfig {
604    /// Path to the passwd file (default: `/etc/passwd`).
605    pub passwd_path: PathBuf,
606    /// Path to the shadow file (default: `/etc/shadow`).
607    pub shadow_path: PathBuf,
608    /// Minimum UID considered a "real" (non-system) user.
609    pub min_uid: u32,
610    /// When `false`, users with UID < `min_uid` are excluded from
611    /// `list_users` and `verify_identity`.
612    pub allow_system_users: bool,
613}
614
615impl Default for SystemConfig {
616    fn default() -> Self {
617        Self {
618            passwd_path: PathBuf::from("/etc/passwd"),
619            shadow_path: PathBuf::from("/etc/shadow"),
620            min_uid: 1000,
621            allow_system_users: false,
622        }
623    }
624}
625
626/// Pure Rust system authentication backend.
627///
628/// Reads `/etc/passwd` and `/etc/shadow` directly — no C libraries required.
629pub struct SystemAuthBackend {
630    config: SystemConfig,
631}
632
633impl SystemAuthBackend {
634    /// Create a new system authentication backend with the given configuration.
635    pub fn new(config: SystemConfig) -> Self {
636        Self { config }
637    }
638
639    /// Helper: is a UID considered a "regular" user?
640    fn is_regular_uid(&self, uid: u32) -> bool {
641        self.config.allow_system_users || uid >= self.config.min_uid
642    }
643}
644
645#[async_trait]
646impl AuthBackend for SystemAuthBackend {
647    async fn authenticate(&self, username: &Username, password: &str) -> Result<bool> {
648        let entries = read_shadow_file(&self.config.shadow_path).await?;
649        let entry = entries.iter().find(|e| e.username == username.as_str());
650
651        let entry = match entry {
652            Some(e) => e,
653            None => return Ok(false),
654        };
655
656        if entry.locked {
657            return Ok(false);
658        }
659
660        let hash = entry.hash.clone();
661        let pw = password.to_owned();
662
663        // Hash verification can be CPU-intensive; run in a blocking thread.
664        tokio::task::spawn_blocking(move || verify_password(&pw, &hash))
665            .await
666            .map_err(|e| anyhow!("join error: {e}"))?
667    }
668
669    async fn verify_identity(&self, username: &Username) -> Result<bool> {
670        let entries = read_passwd_file(&self.config.passwd_path).await?;
671        Ok(entries
672            .iter()
673            .any(|e| e.username == username.as_str() && self.is_regular_uid(e.uid)))
674    }
675
676    async fn list_users(&self) -> Result<Vec<Username>> {
677        let entries = read_passwd_file(&self.config.passwd_path).await?;
678        let mut users = Vec::new();
679        for entry in &entries {
680            if !self.is_regular_uid(entry.uid) {
681                continue;
682            }
683            if let Ok(u) = Username::new(entry.username.clone()) {
684                users.push(u);
685            }
686        }
687        Ok(users)
688    }
689
690    async fn create_user(&self, _username: &Username, _password: &str) -> Result<()> {
691        Err(anyhow!(
692            "system backend is read-only; use useradd(8) to create system users"
693        ))
694    }
695
696    async fn delete_user(&self, _username: &Username) -> Result<()> {
697        Err(anyhow!(
698            "system backend is read-only; use userdel(8) to delete system users"
699        ))
700    }
701
702    async fn change_password(&self, _username: &Username, _new_password: &str) -> Result<()> {
703        Err(anyhow!(
704            "system backend is read-only; use passwd(1) to change passwords"
705        ))
706    }
707}
708
709// ═══════════════════════════════════════════════════════════════════════════
710// Tests
711// ═══════════════════════════════════════════════════════════════════════════
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716    use std::io::Write;
717
718    // ── SHA-512 crypt test vectors (from Drepper spec) ──────────────────
719
720    #[test]
721    fn test_sha512_crypt_vector_1() {
722        let hash = sha512_crypt_full(b"Hello world!", "saltstring");
723        assert_eq!(
724            hash,
725            "$6$saltstring$svn8UoSVapNtMuq1ukKS4tPQd8iKwSMHWjl/O817G3uBnIFNjnQJuesI68u4OTLiBFdcbYEdFCoEOfaS35inz1"
726        );
727    }
728
729    #[test]
730    fn test_sha512_crypt_vector_2() {
731        let hash = sha512_crypt_full(b"Hello world!", "rounds=10000$saltstringsaltstring");
732        assert_eq!(
733            hash,
734            "$6$rounds=10000$saltstringsaltst$OW1/O6BYHV6BcXZu8QVeXbDWra3Oeqh0sbHbbMCVNSnCM/UrjmM0Dp8vOuZeHBy/YTBmSK6H9qs/y3RnOaw5v."
735        );
736    }
737
738    #[test]
739    fn test_sha512_crypt_vector_3_rounds_5000() {
740        // rounds=5000 is the default; the hash string must NOT contain
741        // "rounds=5000" (it is implied).
742        let hash = sha512_crypt_full(b"Hello world!", "rounds=5000$saltstring");
743        assert_eq!(
744            hash,
745            "$6$saltstring$svn8UoSVapNtMuq1ukKS4tPQd8iKwSMHWjl/O817G3uBnIFNjnQJuesI68u4OTLiBFdcbYEdFCoEOfaS35inz1"
746        );
747    }
748
749    #[test]
750    fn test_sha512_crypt_vector_4_long_salt() {
751        let hash = sha512_crypt_full(
752            b"a]very.]long.]password",
753            "rounds=1400$anotherlongsaltstring",
754        );
755        assert_eq!(
756            hash,
757            "$6$rounds=1400$anotherlongsalts$Qfvpda9/GjV7Wb8GUTT6zacXCbXD87betTdwA7oey1xJInUU7wpEJ4J2WJ0UIrAePuYGKy86Do7Cdj.JxTpiN."
758        );
759    }
760
761    #[test]
762    fn test_sha512_crypt_vector_5_very_long_salt() {
763        let hash = sha512_crypt_full(
764            b"we have a short salt string but not a short password",
765            "rounds=77777$short",
766        );
767        assert_eq!(
768            hash,
769            "$6$rounds=77777$short$WuQyW2YR.hBNpjjRhpYD/ifIw05xdfeEyQoMxIXbkvr0gge1a1x3yRULJ5CCaUeOxFmtlcGZelFl5CxtgfiAc0"
770        );
771    }
772
773    #[test]
774    fn test_sha512_crypt_vector_6_low_rounds() {
775        // rounds=1000 is the minimum
776        let hash = sha512_crypt_full(
777            b"the minimum number is still observed",
778            "rounds=1000$roundstoolow",
779        );
780        assert_eq!(
781            hash,
782            "$6$rounds=1000$roundstoolow$kUMsbe306n21p9R.FRkW3IGn.S9NPN0x50YhH1xhLsPuWGsUSklZt58jaTfF4ZEQpyUNGc0dqbpBYYBaHHrsX."
783        );
784    }
785
786    // ── SHA-256 crypt test vectors ──────────────────────────────────────
787
788    #[test]
789    fn test_sha256_crypt_vector_1() {
790        let hash = sha256_crypt_full(b"Hello world!", "saltstring");
791        assert_eq!(
792            hash,
793            "$5$saltstring$5B8vYYiY.CVt1RlTTf8KbXBH3hsxY/GNooZaBBGWEc5"
794        );
795    }
796
797    #[test]
798    fn test_sha256_crypt_vector_2() {
799        let hash = sha256_crypt_full(b"Hello world!", "rounds=10000$saltstringsaltstring");
800        assert_eq!(
801            hash,
802            "$5$rounds=10000$saltstringsaltst$3xv.VbSHBb41AL9AvLeujZkZRBAwqFMz2.opqey6IcA"
803        );
804    }
805
806    #[test]
807    fn test_sha256_crypt_vector_3() {
808        let hash = sha256_crypt_full(b"Hello world!", "rounds=5000$saltstring");
809        assert_eq!(
810            hash,
811            "$5$saltstring$5B8vYYiY.CVt1RlTTf8KbXBH3hsxY/GNooZaBBGWEc5"
812        );
813    }
814
815    #[test]
816    fn test_sha256_crypt_vector_4() {
817        let hash = sha256_crypt_full(
818            b"a]very.]long.]password",
819            "rounds=1400$anotherlongsaltstring",
820        );
821        assert_eq!(
822            hash,
823            "$5$rounds=1400$anotherlongsalts$8fc8RpnsAEYdbUkzdb0Tt9jps8e3xnDYAbqtN8Gmdl3"
824        );
825    }
826
827    #[test]
828    fn test_sha256_crypt_vector_5() {
829        let hash = sha256_crypt_full(
830            b"we have a short salt string but not a short password",
831            "rounds=77777$short",
832        );
833        assert_eq!(
834            hash,
835            "$5$rounds=77777$short$JiO1O3ZpDAxGJeaDIuqCoEFysAe1mZNJRs3pw0KQRd/"
836        );
837    }
838
839    #[test]
840    fn test_sha256_crypt_vector_6() {
841        let hash = sha256_crypt_full(
842            b"the minimum number is still observed",
843            "rounds=1000$roundstoolow",
844        );
845        assert_eq!(
846            hash,
847            "$5$rounds=1000$roundstoolow$yfvwcWrQ8l/K0DAWyuPMDNHpIVlTQebY9l/gL972bIC"
848        );
849    }
850
851    // ── Password verification ───────────────────────────────────────────
852
853    #[test]
854    fn test_verify_sha512() {
855        let hash = "$6$saltstring$svn8UoSVapNtMuq1ukKS4tPQd8iKwSMHWjl/O817G3uBnIFNjnQJuesI68u4OTLiBFdcbYEdFCoEOfaS35inz1";
856        assert!(verify_password("Hello world!", hash).expect("verify ok"));
857        assert!(!verify_password("wrong", hash).expect("verify ok"));
858    }
859
860    #[test]
861    fn test_verify_sha256() {
862        let hash = "$5$saltstring$5B8vYYiY.CVt1RlTTf8KbXBH3hsxY/GNooZaBBGWEc5";
863        assert!(verify_password("Hello world!", hash).expect("verify ok"));
864        assert!(!verify_password("wrong", hash).expect("verify ok"));
865    }
866
867    #[test]
868    fn test_verify_locked_account() {
869        assert!(!verify_password("anything", "!").expect("verify ok"));
870        assert!(!verify_password("anything", "*").expect("verify ok"));
871        assert!(!verify_password("anything", "!!").expect("verify ok"));
872    }
873
874    // ── Passwd parsing ──────────────────────────────────────────────────
875
876    #[test]
877    fn test_parse_passwd_line_valid() {
878        let entry = parse_passwd_line("alice:x:1001:1001:Alice:/home/alice:/bin/bash");
879        let e = entry.expect("should parse");
880        assert_eq!(e.username, "alice");
881        assert_eq!(e.uid, 1001);
882    }
883
884    #[test]
885    fn test_parse_passwd_line_system_user() {
886        let entry = parse_passwd_line("daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin");
887        let e = entry.expect("should parse");
888        assert_eq!(e.username, "daemon");
889        assert_eq!(e.uid, 1);
890    }
891
892    #[test]
893    fn test_parse_passwd_line_comment() {
894        assert!(parse_passwd_line("# a comment").is_none());
895    }
896
897    #[test]
898    fn test_parse_passwd_line_empty() {
899        assert!(parse_passwd_line("").is_none());
900        assert!(parse_passwd_line("   ").is_none());
901    }
902
903    #[test]
904    fn test_parse_passwd_line_malformed() {
905        assert!(parse_passwd_line("no-colons-at-all").is_none());
906        assert!(parse_passwd_line("only:two:fields").is_none());
907    }
908
909    // ── Shadow parsing ──────────────────────────────────────────────────
910
911    #[test]
912    fn test_parse_shadow_line_valid() {
913        let line = "alice:$6$salt$hash:19000:0:99999:7:::";
914        let e = parse_shadow_line(line).expect("should parse");
915        assert_eq!(e.username, "alice");
916        assert_eq!(e.hash, "$6$salt$hash");
917        assert!(!e.locked);
918    }
919
920    #[test]
921    fn test_parse_shadow_line_locked_bang() {
922        let line = "bob:!$6$salt$hash:19000:0:99999:7:::";
923        let e = parse_shadow_line(line).expect("should parse");
924        assert_eq!(e.username, "bob");
925        assert!(e.locked);
926        // The underlying hash (without the "!") is preserved
927        assert_eq!(e.hash, "$6$salt$hash");
928    }
929
930    #[test]
931    fn test_parse_shadow_line_locked_star() {
932        let line = "nologin:*:19000:0:99999:7:::";
933        let e = parse_shadow_line(line).expect("should parse");
934        assert!(e.locked);
935    }
936
937    #[test]
938    fn test_parse_shadow_line_locked_double_bang() {
939        let line = "newuser:!!:19000:0:99999:7:::";
940        let e = parse_shadow_line(line).expect("should parse");
941        assert!(e.locked);
942        assert_eq!(e.hash, "!!");
943    }
944
945    #[test]
946    fn test_parse_shadow_line_comment_and_empty() {
947        assert!(parse_shadow_line("# comment").is_none());
948        assert!(parse_shadow_line("").is_none());
949    }
950
951    // ── SystemAuthBackend unit tests ────────────────────────────────────
952
953    #[test]
954    fn test_system_config_default() {
955        let cfg = SystemConfig::default();
956        assert_eq!(cfg.passwd_path, PathBuf::from("/etc/passwd"));
957        assert_eq!(cfg.shadow_path, PathBuf::from("/etc/shadow"));
958        assert_eq!(cfg.min_uid, 1000);
959        assert!(!cfg.allow_system_users);
960    }
961
962    #[test]
963    fn test_system_backend_custom_paths() {
964        let cfg = SystemConfig {
965            passwd_path: PathBuf::from("/tmp/test_passwd"),
966            shadow_path: PathBuf::from("/tmp/test_shadow"),
967            min_uid: 500,
968            allow_system_users: true,
969        };
970        let backend = SystemAuthBackend::new(cfg);
971        assert_eq!(
972            backend.config.passwd_path,
973            PathBuf::from("/tmp/test_passwd")
974        );
975        assert!(backend.config.allow_system_users);
976    }
977
978    /// Helper: write content to a temporary file and return its path.
979    fn write_temp_file(prefix: &str, content: &str) -> PathBuf {
980        let dir = std::env::temp_dir();
981        let ts = std::time::SystemTime::now()
982            .duration_since(std::time::UNIX_EPOCH)
983            .unwrap_or_default()
984            .as_nanos();
985        let path = dir.join(format!("rusmes_test_{}_{}", prefix, ts));
986        let mut f = std::fs::File::create(&path).expect("create temp file");
987        f.write_all(content.as_bytes()).expect("write temp file");
988        path
989    }
990
991    #[tokio::test]
992    async fn test_authenticate_sha512() {
993        // "testpass" hashed with $6$testsalt$
994        let pw_hash = sha512_crypt_full(b"testpass", "testsalt");
995        let shadow_content = format!("alice:{}:19000:0:99999:7:::\n", pw_hash);
996        let shadow_path = write_temp_file("shadow_auth", &shadow_content);
997
998        let passwd_content = "alice:x:1001:1001:Alice:/home/alice:/bin/bash\n";
999        let passwd_path = write_temp_file("passwd_auth", passwd_content);
1000
1001        let backend = SystemAuthBackend::new(SystemConfig {
1002            passwd_path: passwd_path.clone(),
1003            shadow_path: shadow_path.clone(),
1004            ..SystemConfig::default()
1005        });
1006
1007        let user = Username::new("alice").expect("valid username");
1008        assert!(backend
1009            .authenticate(&user, "testpass")
1010            .await
1011            .expect("auth ok"));
1012        assert!(!backend.authenticate(&user, "wrong").await.expect("auth ok"));
1013
1014        // Clean up
1015        let _ = std::fs::remove_file(&shadow_path);
1016        let _ = std::fs::remove_file(&passwd_path);
1017    }
1018
1019    #[tokio::test]
1020    async fn test_authenticate_locked_account() {
1021        let shadow_content = "bob:!$6$salt$fakehash:19000:0:99999:7:::\n";
1022        let shadow_path = write_temp_file("shadow_locked", shadow_content);
1023
1024        let backend = SystemAuthBackend::new(SystemConfig {
1025            shadow_path: shadow_path.clone(),
1026            ..SystemConfig::default()
1027        });
1028
1029        let user = Username::new("bob").expect("valid username");
1030        assert!(!backend
1031            .authenticate(&user, "anything")
1032            .await
1033            .expect("auth ok"));
1034
1035        let _ = std::fs::remove_file(&shadow_path);
1036    }
1037
1038    #[tokio::test]
1039    async fn test_verify_identity() {
1040        let content = "\
1041root:x:0:0:root:/root:/bin/bash
1042alice:x:1001:1001:Alice:/home/alice:/bin/bash
1043bob:x:1002:1002:Bob:/home/bob:/bin/bash
1044";
1045        let path = write_temp_file("passwd_verify", content);
1046
1047        let backend = SystemAuthBackend::new(SystemConfig {
1048            passwd_path: path.clone(),
1049            ..SystemConfig::default()
1050        });
1051
1052        let alice = Username::new("alice").expect("valid");
1053        let root = Username::new("root").expect("valid");
1054        let ghost = Username::new("ghost").expect("valid");
1055
1056        assert!(backend.verify_identity(&alice).await.expect("ok"));
1057        // root has uid 0 < 1000 → excluded by default
1058        assert!(!backend.verify_identity(&root).await.expect("ok"));
1059        assert!(!backend.verify_identity(&ghost).await.expect("ok"));
1060
1061        let _ = std::fs::remove_file(&path);
1062    }
1063
1064    #[tokio::test]
1065    async fn test_list_users_filters_system() {
1066        let content = "\
1067root:x:0:0:root:/root:/bin/bash
1068daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
1069alice:x:1001:1001:Alice:/home/alice:/bin/bash
1070bob:x:1002:1002:Bob:/home/bob:/bin/bash
1071";
1072        let path = write_temp_file("passwd_list", content);
1073
1074        let backend = SystemAuthBackend::new(SystemConfig {
1075            passwd_path: path.clone(),
1076            ..SystemConfig::default()
1077        });
1078
1079        let users = backend.list_users().await.expect("ok");
1080        let names: Vec<String> = users.iter().map(|u| u.as_str().to_owned()).collect();
1081
1082        assert_eq!(names, vec!["alice", "bob"]);
1083
1084        let _ = std::fs::remove_file(&path);
1085    }
1086
1087    #[tokio::test]
1088    async fn test_list_users_allow_system() {
1089        let content = "\
1090root:x:0:0:root:/root:/bin/bash
1091alice:x:1001:1001:Alice:/home/alice:/bin/bash
1092";
1093        let path = write_temp_file("passwd_allow_sys", content);
1094
1095        let backend = SystemAuthBackend::new(SystemConfig {
1096            passwd_path: path.clone(),
1097            allow_system_users: true,
1098            ..SystemConfig::default()
1099        });
1100
1101        let users = backend.list_users().await.expect("ok");
1102        assert_eq!(users.len(), 2);
1103
1104        let _ = std::fs::remove_file(&path);
1105    }
1106
1107    #[tokio::test]
1108    async fn test_create_delete_change_return_errors() {
1109        let backend = SystemAuthBackend::new(SystemConfig::default());
1110        let user = Username::new("test").expect("valid");
1111
1112        assert!(backend.create_user(&user, "pw").await.is_err());
1113        assert!(backend.delete_user(&user).await.is_err());
1114        assert!(backend.change_password(&user, "new").await.is_err());
1115    }
1116
1117    // ── rounds parsing ──────────────────────────────────────────────────
1118
1119    #[test]
1120    fn test_parse_rounds_and_salt_default() {
1121        let (rounds, salt) = parse_rounds_and_salt("mysalt");
1122        assert_eq!(rounds, 5000);
1123        assert_eq!(salt, "mysalt");
1124    }
1125
1126    #[test]
1127    fn test_parse_rounds_and_salt_explicit() {
1128        let (rounds, salt) = parse_rounds_and_salt("rounds=10000$mysalt");
1129        assert_eq!(rounds, 10000);
1130        assert_eq!(salt, "mysalt");
1131    }
1132
1133    #[test]
1134    fn test_parse_rounds_and_salt_clamped_low() {
1135        let (rounds, _) = parse_rounds_and_salt("rounds=100$mysalt");
1136        assert_eq!(rounds, 1000);
1137    }
1138
1139    // ── MD5-crypt ───────────────────────────────────────────────────────
1140
1141    #[test]
1142    fn test_md5_crypt_basic() {
1143        // Reference vector: password "password", salt "3edqd5Yh"
1144        // Generated by: openssl passwd -1 -salt 3edqd5Yh password
1145        // → $1$3edqd5Yh$SE3KgrxqSR.n5oJB/Me561
1146        //
1147        // We verify round-trip: hash then verify.
1148        let hash = md5_crypt_full(b"password", "3edqd5Yh");
1149        assert!(hash.starts_with("$1$3edqd5Yh$"));
1150        assert!(verify_password("password", &hash).expect("verify ok"));
1151    }
1152
1153    // ── b64_encode_bits ─────────────────────────────────────────────────
1154
1155    #[test]
1156    fn test_b64_encode_bits_zero() {
1157        assert_eq!(b64_encode_bits(0, 4), "....");
1158    }
1159
1160    #[test]
1161    fn test_b64_encode_bits_single() {
1162        // value 1 → first char at index 1 = '/'
1163        assert_eq!(b64_encode_bits(1, 1), "/");
1164    }
1165}