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