kanidm_unix_common/
unix_passwd.rs

1use serde::{Deserialize, Serialize};
2use serde_with::formats::CommaSeparator;
3use serde_with::{serde_as, DefaultOnNull, StringWithSeparator};
4use std::fmt;
5use std::fs::File;
6use std::io::{BufRead, Read};
7use std::path::Path;
8use std::str::FromStr;
9
10#[derive(Serialize, Deserialize, Debug, Default, Clone)]
11pub struct EtcDb {
12    pub users: Vec<EtcUser>,
13    pub shadow: Vec<EtcShadow>,
14    pub groups: Vec<EtcGroup>,
15}
16
17#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
18pub struct EtcUser {
19    pub name: String,
20    pub password: String,
21    pub uid: u32,
22    pub gid: u32,
23    pub gecos: String,
24    pub homedir: String,
25    pub shell: String,
26}
27
28pub fn parse_etc_passwd(bytes: &[u8]) -> Result<Vec<EtcUser>, UnixIntegrationError> {
29    use csv::ReaderBuilder;
30
31    let filecontents = strip_comments(bytes);
32
33    let mut rdr = ReaderBuilder::new()
34        .has_headers(false)
35        .delimiter(b':')
36        .from_reader(filecontents.as_bytes());
37
38    rdr.deserialize()
39        .map(|result| result.map_err(|_e| UnixIntegrationError))
40        .collect::<Result<Vec<EtcUser>, UnixIntegrationError>>()
41}
42
43pub fn read_etc_passwd_file<P: AsRef<Path>>(path: P) -> Result<Vec<EtcUser>, UnixIntegrationError> {
44    let mut file = File::open(path.as_ref()).map_err(|_| UnixIntegrationError)?;
45
46    let mut contents = vec![];
47    file.read_to_end(&mut contents)
48        .map_err(|_| UnixIntegrationError)?;
49
50    parse_etc_passwd(contents.as_slice()).map_err(|_| UnixIntegrationError)
51}
52
53#[derive(PartialEq, Default, Clone)]
54pub enum CryptPw {
55    Sha256(String),
56    Sha512(String),
57    YesCrypt(String),
58    #[default]
59    Invalid,
60}
61
62impl fmt::Display for CryptPw {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        match self {
65            CryptPw::Invalid => write!(f, "x"),
66            CryptPw::Sha256(s) | CryptPw::Sha512(s) | CryptPw::YesCrypt(s) => write!(f, "{s}"),
67        }
68    }
69}
70
71impl fmt::Debug for CryptPw {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            CryptPw::Invalid => write!(f, "x"),
75            CryptPw::Sha256(_s) => write!(f, "crypt sha256"),
76            CryptPw::Sha512(_s) => write!(f, "crypt sha512"),
77            CryptPw::YesCrypt(_s) => write!(f, "crypt yescrypt"),
78        }
79    }
80}
81
82impl FromStr for CryptPw {
83    type Err = &'static str;
84
85    fn from_str(value: &str) -> Result<Self, Self::Err> {
86        if value.starts_with("$6$") {
87            Ok(CryptPw::Sha512(value.to_string()))
88        } else if value.starts_with("$5$") {
89            Ok(CryptPw::Sha256(value.to_string()))
90        } else if value.starts_with("$y$") {
91            Ok(CryptPw::YesCrypt(value.to_string()))
92        } else {
93            Ok(CryptPw::Invalid)
94        }
95    }
96}
97
98impl CryptPw {
99    pub fn is_valid(&self) -> bool {
100        !matches!(self, CryptPw::Invalid)
101    }
102
103    pub fn check_pw(&self, cred: &str) -> bool {
104        match &self {
105            CryptPw::Sha256(crypt) => sha_crypt::sha256_check(cred, crypt.as_str()).is_ok(),
106            CryptPw::Sha512(crypt) => sha_crypt::sha512_check(cred, crypt.as_str()).is_ok(),
107            CryptPw::YesCrypt(crypt) => {
108                use yescrypt::{PasswordHash, PasswordVerifier, Yescrypt};
109                let password_hash = match PasswordHash::new(crypt.as_str()) {
110                    Ok(ph) => ph,
111                    Err(err) => {
112                        debug!("Failed to parse yescrypt password hash: {err:?}");
113                        return false;
114                    }
115                };
116                Yescrypt::default()
117                    .verify_password(cred.as_bytes(), &password_hash)
118                    .inspect_err(|err| debug!("Failed to verify password: {err:?}"))
119                    .is_ok()
120            }
121            CryptPw::Invalid => false,
122        }
123    }
124}
125
126mod timestamp_days {
127    use serde::{Deserialize, Deserializer, Serialize, Serializer};
128    use time::OffsetDateTime;
129
130    /// Serialize an `Option<OffsetDateTime>` as the days from epoch.
131    pub fn serialize<S: Serializer>(
132        option: &Option<OffsetDateTime>,
133        serializer: S,
134    ) -> Result<S::Ok, S::Error> {
135        option
136            .map(|odt| {
137                let difference = odt - OffsetDateTime::UNIX_EPOCH;
138                difference.whole_days()
139            })
140            .serialize(serializer)
141    }
142
143    /// Deserialize an `Option<OffsetDateTime>` from the days since epoch
144    pub fn deserialize<'a, D: Deserializer<'a>>(
145        deserializer: D,
146    ) -> Result<Option<OffsetDateTime>, D::Error> {
147        Option::deserialize(deserializer)?
148            .map(|value| {
149                let difference = time::Duration::days(value);
150                Ok(OffsetDateTime::UNIX_EPOCH + difference)
151            })
152            .transpose()
153    }
154}
155
156#[serde_as]
157#[derive(Serialize, Deserialize, Debug, PartialEq, Default, Clone)]
158pub struct EtcShadow {
159    pub name: String,
160    #[serde_as(as = "serde_with::DisplayFromStr")]
161    pub password: CryptPw,
162    // 0 means must change next login.
163    // None means all other aging features are disabled
164    #[serde(with = "timestamp_days")]
165    pub epoch_change_seconds: Option<time::OffsetDateTime>,
166    // 0 means no age
167    #[serde_as(deserialize_as = "DefaultOnNull")]
168    pub days_min_password_age: i64,
169    pub days_max_password_age: Option<i64>,
170    // 0 means no warning
171    #[serde_as(deserialize_as = "DefaultOnNull")]
172    pub days_warning_period: i64,
173    // Number of days after max_password_age passes where the password can
174    // still be accepted such that the user can update their password
175    pub days_inactivity_period: Option<i64>,
176    #[serde(with = "timestamp_days")]
177    pub epoch_expire_seconds: Option<time::OffsetDateTime>,
178    pub flag_reserved: Option<u32>,
179}
180
181#[cfg(any(all(target_family = "unix", not(target_os = "freebsd")), test))]
182fn parse_linux_etc_shadow(bytes: &[u8]) -> Result<Vec<EtcShadow>, UnixIntegrationError> {
183    use csv::ReaderBuilder;
184
185    let filecontents = strip_comments(bytes);
186
187    let mut rdr = ReaderBuilder::new()
188        .has_headers(false)
189        .delimiter(b':')
190        .from_reader(filecontents.as_bytes());
191    rdr.deserialize()
192        .map(|result| {
193            result.map_err(|err| {
194                eprintln!("{err:?}");
195                UnixIntegrationError
196            })
197        })
198        .collect::<Result<Vec<EtcShadow>, UnixIntegrationError>>()
199}
200
201pub fn parse_etc_shadow(bytes: &[u8]) -> Result<Vec<EtcShadow>, UnixIntegrationError> {
202    #[cfg(all(target_family = "unix", not(target_os = "freebsd")))]
203    return parse_linux_etc_shadow(bytes);
204
205    #[cfg(all(target_family = "unix", target_os = "freebsd"))]
206    return parse_etc_master_passwd(bytes);
207}
208
209pub fn read_etc_shadow_file<P: AsRef<Path>>(
210    path: P,
211) -> Result<Vec<EtcShadow>, UnixIntegrationError> {
212    let mut file = File::open(path.as_ref()).map_err(|_| UnixIntegrationError)?;
213
214    let mut contents = vec![];
215    file.read_to_end(&mut contents)
216        .map_err(|_| UnixIntegrationError)?;
217
218    parse_etc_shadow(contents.as_slice()).map_err(|_| UnixIntegrationError)
219}
220
221#[serde_as]
222#[derive(Serialize, Deserialize, Debug, PartialEq, Default, Clone)]
223pub struct EtcMasterPasswd {
224    pub name: String,
225    #[serde_as(as = "serde_with::DisplayFromStr")]
226    pub password: CryptPw,
227    pub uid: u32,
228    pub gid: u32,
229    pub class: String,
230    #[serde(with = "time::serde::timestamp::option")]
231    pub epoch_change_seconds: Option<time::OffsetDateTime>,
232    #[serde(with = "time::serde::timestamp::option")]
233    pub epoch_expire_seconds: Option<time::OffsetDateTime>,
234    pub gecos: String,
235    pub homedir: String,
236    pub shell: String,
237}
238
239impl From<EtcMasterPasswd> for EtcShadow {
240    fn from(etc_master_passwd: EtcMasterPasswd) -> Self {
241        let EtcMasterPasswd {
242            name,
243            password,
244            epoch_change_seconds,
245            epoch_expire_seconds,
246            ..
247        } = etc_master_passwd;
248
249        let epoch_change_seconds = if epoch_change_seconds == Some(time::OffsetDateTime::UNIX_EPOCH)
250        {
251            None
252        } else {
253            epoch_change_seconds
254        };
255
256        let epoch_expire_seconds = if epoch_expire_seconds == Some(time::OffsetDateTime::UNIX_EPOCH)
257        {
258            None
259        } else {
260            epoch_expire_seconds
261        };
262
263        Self {
264            name,
265            password,
266            epoch_change_seconds,
267            epoch_expire_seconds,
268            ..Default::default()
269        }
270    }
271}
272
273#[cfg(any(all(target_family = "unix", target_os = "freebsd"), test))]
274fn parse_etc_master_passwd(bytes: &[u8]) -> Result<Vec<EtcShadow>, UnixIntegrationError> {
275    use csv::ReaderBuilder;
276
277    let filecontents = strip_comments(bytes);
278
279    let mut rdr = ReaderBuilder::new()
280        .has_headers(false)
281        .delimiter(b':')
282        .from_reader(filecontents.as_bytes());
283    let records = rdr
284        .deserialize()
285        .map(|result| {
286            result.map_err(|err| {
287                eprintln!("{err:?}");
288                UnixIntegrationError
289            })
290        })
291        .collect::<Result<Vec<EtcMasterPasswd>, UnixIntegrationError>>()?;
292
293    Ok(records.into_iter().map(EtcShadow::from).collect())
294}
295
296#[serde_as]
297#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
298pub struct EtcGroup {
299    pub name: String,
300    pub password: String,
301    pub gid: u32,
302    #[serde_as(as = "StringWithSeparator::<CommaSeparator, String>")]
303    pub members: Vec<String>,
304}
305
306#[derive(Debug)]
307pub struct UnixIntegrationError;
308
309pub fn parse_etc_group(bytes: &[u8]) -> Result<Vec<EtcGroup>, UnixIntegrationError> {
310    use csv::ReaderBuilder;
311
312    let filecontents = strip_comments(bytes);
313
314    let mut rdr = ReaderBuilder::new()
315        .has_headers(false)
316        .delimiter(b':')
317        .from_reader(filecontents.as_bytes());
318    rdr.deserialize()
319        .map(|result| result.map_err(|_e| UnixIntegrationError))
320        .collect::<Result<Vec<EtcGroup>, UnixIntegrationError>>()
321}
322
323pub fn read_etc_group_file<P: AsRef<Path>>(path: P) -> Result<Vec<EtcGroup>, UnixIntegrationError> {
324    let mut file = File::open(path.as_ref()).map_err(|_| UnixIntegrationError)?;
325
326    let mut contents = vec![];
327    file.read_to_end(&mut contents)
328        .map_err(|_| UnixIntegrationError)?;
329
330    parse_etc_group(contents.as_slice()).map_err(|_| UnixIntegrationError)
331}
332
333fn strip_comments(bytes: &[u8]) -> String {
334    bytes
335        .lines()
336        .filter_map(|line| {
337            let line = line.ok()?;
338            let trimmed = line.trim();
339            if !trimmed.is_empty() && !trimmed.starts_with('#') {
340                Some(line)
341            } else {
342                None
343            }
344        })
345        .collect::<Vec<_>>()
346        .join("\n")
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    const EXAMPLE_PASSWD: &str = r#"root:x:0:0:root:/root:/bin/bash
354systemd-timesync:x:498:498:systemd Time Synchronization:/:/usr/sbin/nologin
355nobody:x:65534:65534:nobody:/var/lib/nobody:/bin/bash
356"#;
357
358    #[test]
359    fn test_parse_passwd() {
360        let users =
361            parse_etc_passwd(EXAMPLE_PASSWD.as_bytes()).expect("Failed to parse passwd data");
362
363        assert_eq!(
364            users[0],
365            EtcUser {
366                name: "root".to_string(),
367                password: "x".to_string(),
368                uid: 0,
369                gid: 0,
370                gecos: "root".to_string(),
371                homedir: "/root".to_string(),
372                shell: "/bin/bash".to_string(),
373            }
374        );
375
376        assert_eq!(
377            users[1],
378            EtcUser {
379                name: "systemd-timesync".to_string(),
380                password: "x".to_string(),
381                uid: 498,
382                gid: 498,
383                gecos: "systemd Time Synchronization".to_string(),
384                homedir: "/".to_string(),
385                shell: "/usr/sbin/nologin".to_string(),
386            }
387        );
388
389        assert_eq!(
390            users[2],
391            EtcUser {
392                name: "nobody".to_string(),
393                password: "x".to_string(),
394                uid: 65534,
395                gid: 65534,
396                gecos: "nobody".to_string(),
397                homedir: "/var/lib/nobody".to_string(),
398                shell: "/bin/bash".to_string(),
399            }
400        );
401    }
402
403    // IMPORTANT this is the password "a". Very secure, totes secret.
404    const EXAMPLE_SHADOW: &str = r#"sshd:!:19978::::::
405tss:!:19980::::::
406admin:$6$5.bXZTIXuVv.xI3.$sAubscCJPwnBWwaLt2JR33lo539UyiDku.aH5WVSX0Tct9nGL2ePMEmrqT3POEdBlgNQ12HJBwskewGu2dpF//:19980:0:99999:7:::
407admin_yescrypt:$y$j9T$LdJMENpBABJJ3hIHjB1Bi.$GFxnbKnR8WaEdBMGMctf6JGMs56hU5dYcy6UrKGWr62:19980:0:99999:7:::
408"#;
409
410    #[test]
411    fn test_parse_shadow() {
412        let shadow =
413            parse_linux_etc_shadow(EXAMPLE_SHADOW.as_bytes()).expect("Failed to parse passwd data");
414
415        assert_eq!(
416            shadow[0],
417            EtcShadow {
418                name: "sshd".to_string(),
419                password: CryptPw::Invalid,
420                epoch_change_seconds: Some(
421                    time::OffsetDateTime::UNIX_EPOCH + time::Duration::days(19978)
422                ),
423                days_min_password_age: 0,
424                days_max_password_age: None,
425                days_warning_period: 0,
426                days_inactivity_period: None,
427                epoch_expire_seconds: None,
428                flag_reserved: None
429            }
430        );
431
432        assert_eq!(
433            shadow[1],
434            EtcShadow {
435                name: "tss".to_string(),
436                password: CryptPw::Invalid,
437                epoch_change_seconds: Some(
438                    time::OffsetDateTime::UNIX_EPOCH + time::Duration::days(19980)
439                ),
440                days_min_password_age: 0,
441                days_max_password_age: None,
442                days_warning_period: 0,
443                days_inactivity_period: None,
444                epoch_expire_seconds: None,
445                flag_reserved: None
446            }
447        );
448
449        assert_eq!(shadow[2], EtcShadow {
450            name: "admin".to_string(),
451            password: CryptPw::Sha512("$6$5.bXZTIXuVv.xI3.$sAubscCJPwnBWwaLt2JR33lo539UyiDku.aH5WVSX0Tct9nGL2ePMEmrqT3POEdBlgNQ12HJBwskewGu2dpF//".to_string()),
452            epoch_change_seconds: Some(time::OffsetDateTime::UNIX_EPOCH + time::Duration::days(19980)),
453            days_min_password_age: 0,
454            days_max_password_age: Some(99999),
455            days_warning_period: 7,
456            days_inactivity_period: None,
457            epoch_expire_seconds: None,
458            flag_reserved: None
459        });
460
461        assert_eq!(
462            shadow[3],
463            EtcShadow {
464                name: "admin_yescrypt".to_string(),
465                password: CryptPw::YesCrypt(
466                    "$y$j9T$LdJMENpBABJJ3hIHjB1Bi.$GFxnbKnR8WaEdBMGMctf6JGMs56hU5dYcy6UrKGWr62"
467                        .to_string()
468                ),
469                epoch_change_seconds: Some(
470                    time::OffsetDateTime::UNIX_EPOCH + time::Duration::days(19980)
471                ),
472                days_min_password_age: 0,
473                days_max_password_age: Some(99999),
474                days_warning_period: 7,
475                days_inactivity_period: None,
476                epoch_expire_seconds: None,
477                flag_reserved: None
478            }
479        );
480    }
481
482    const EXAMPLE_GROUP: &str = r#"root:x:0:
483wheel:x:481:admin,testuser
484"#;
485
486    #[test]
487    fn test_parse_group() {
488        let groups = parse_etc_group(EXAMPLE_GROUP.as_bytes()).expect("Failed to parse groups");
489
490        assert_eq!(
491            groups[0],
492            EtcGroup {
493                name: "root".to_string(),
494                password: "x".to_string(),
495                gid: 0,
496                members: vec![]
497            }
498        );
499
500        assert_eq!(
501            groups[1],
502            EtcGroup {
503                name: "wheel".to_string(),
504                password: "x".to_string(),
505                gid: 481,
506                members: vec!["admin".to_string(), "testuser".to_string(),]
507            }
508        );
509    }
510
511    #[test]
512    fn test_parse_group_freebsd() {
513        let group_data = r#"wheel:*:0:root,testuser,kanidm"#;
514        let groups = parse_etc_group(group_data.as_bytes()).expect("Failed to parse groups");
515        assert_eq!(
516            groups[0],
517            EtcGroup {
518                name: "wheel".to_string(),
519                password: "*".to_string(),
520                gid: 0,
521                members: vec![
522                    "root".to_string(),
523                    "testuser".to_string(),
524                    "kanidm".to_string()
525                ]
526            }
527        );
528        // empty group
529        let group_data = r#"
530        # $FreeBSD$
531# 
532wheel:*:0:"#;
533        let groups = parse_etc_group(group_data.as_bytes()).expect("Failed to parse groups");
534        assert_eq!(
535            groups[0],
536            EtcGroup {
537                name: "wheel".to_string(),
538                password: "*".to_string(),
539                gid: 0,
540                members: vec![]
541            }
542        );
543    }
544
545    #[test]
546    fn test_parse_passwd_freebsd() {
547        let passwd_data = r#" # Comment
548root:*:0:0:Charlie &:/root:/bin/sh
549toor:*:0:0:Bourne-again Superuser:/root:
550daemon:*:1:1:Owner of many system processes:/root:/usr/sbin/nologin
551"#;
552        let users = parse_etc_passwd(passwd_data.as_bytes()).expect("Failed to parse passwd data");
553
554        assert_eq!(
555            users[0],
556            EtcUser {
557                name: "root".to_string(),
558                password: "*".to_string(),
559                uid: 0,
560                gid: 0,
561                gecos: "Charlie &".to_string(),
562                homedir: "/root".to_string(),
563                shell: "/bin/sh".to_string(),
564            }
565        );
566
567        assert_eq!(
568            users[1],
569            EtcUser {
570                name: "toor".to_string(),
571                password: "*".to_string(),
572                uid: 0,
573                gid: 0,
574                gecos: "Bourne-again Superuser".to_string(),
575                homedir: "/root".to_string(),
576                shell: "".to_string(),
577            }
578        );
579
580        assert_eq!(
581            users[2],
582            EtcUser {
583                name: "daemon".to_string(),
584                password: "*".to_string(),
585                uid: 1,
586                gid: 1,
587                gecos: "Owner of many system processes".to_string(),
588                homedir: "/root".to_string(),
589                shell: "/usr/sbin/nologin".to_string(),
590            }
591        );
592    }
593
594    #[test]
595    fn test_parse_masterpasswd_freebsd() {
596        let master_passwd_data = r#"# $FreeBSD$
597root:$6$U7ePyqmS.jKiqDWG$EFhw5zmkjK1h02QJvefu5RuTryxIhqzUmcFjnofafd2abgHzYuvWdqpyCw/ZfNOSTUAMNiJUcwtCW8SOFwq/i/:0:0::0:0:Charlie &:/root:/bin/sh
598toor:*:0:0::0:0:Bourne-again Superuser:/root:
599daemon:*:1:1::0:0:Owner of many system processes:/root:/usr/sbin/nologin
600operator:*:2:5::0:0:System &:/:/usr/sbin/nologin
601bin:*:3:7::0:0:Binaries Commands and Source:/:/usr/sbin/nologin
602tty:*:4:65533::0:0:Tty Sandbox:/:/usr/sbin/nologin
603"#;
604
605        let shadow = parse_etc_master_passwd(master_passwd_data.as_bytes())
606            .expect("Failed to parse freebsd shadow");
607
608        assert_eq!(
609            shadow[0],
610            EtcShadow {
611                name: "root".to_string(),
612                password: CryptPw::Sha512("$6$U7ePyqmS.jKiqDWG$EFhw5zmkjK1h02QJvefu5RuTryxIhqzUmcFjnofafd2abgHzYuvWdqpyCw/ZfNOSTUAMNiJUcwtCW8SOFwq/i/".to_string()),
613                epoch_change_seconds: None,
614                days_min_password_age: 0,
615                days_max_password_age: None,
616                days_warning_period: 0,
617                days_inactivity_period: None,
618                epoch_expire_seconds: None,
619                flag_reserved: None
620            }
621        );
622
623        assert_eq!(
624            shadow[1],
625            EtcShadow {
626                name: "toor".to_string(),
627                password: CryptPw::Invalid,
628                epoch_change_seconds: None,
629                days_min_password_age: 0,
630                days_max_password_age: None,
631                days_warning_period: 0,
632                days_inactivity_period: None,
633                epoch_expire_seconds: None,
634                flag_reserved: None
635            }
636        );
637
638        assert_eq!(
639            shadow[2],
640            EtcShadow {
641                name: "daemon".to_string(),
642                password: CryptPw::Invalid,
643                epoch_change_seconds: None,
644                days_min_password_age: 0,
645                days_max_password_age: None,
646                days_warning_period: 0,
647                days_inactivity_period: None,
648                epoch_expire_seconds: None,
649                flag_reserved: None
650            }
651        );
652    }
653}