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