signstar_config/
admin_credentials.rs

1//! Administrative credentials handling for a NetHSM backend.
2
3use std::{
4    fs::{File, Permissions, read_to_string, set_permissions},
5    io::Write,
6    os::unix::fs::{PermissionsExt, chown},
7    path::{Path, PathBuf},
8    process::{Command, Stdio},
9};
10
11#[cfg(doc)]
12use nethsm::NetHsm;
13use serde::{de::DeserializeOwned, ser::Serialize};
14use signstar_common::{
15    admin_credentials::{
16        create_credentials_dir,
17        get_plaintext_credentials_file,
18        get_systemd_creds_credentials_file,
19    },
20    common::SECRET_FILE_MODE,
21};
22
23use crate::{
24    AdministrativeSecretHandling,
25    utils::{fail_if_not_root, get_command, get_current_system_user},
26};
27
28/// An error that may occur when handling administrative credentials for a NetHSM backend.
29#[derive(Debug, thiserror::Error)]
30pub enum Error {
31    /// There is no top-level administrator.
32    #[error("There is no top-level administrator but at least one is required")]
33    AdministratorMissing,
34
35    /// There is no top-level administrator with the name "admin".
36    #[error("The default top-level administrator \"admin\" is missing")]
37    AdministratorNoDefault,
38
39    /// A credentials file can not be created.
40    #[error("The credentials file {path} can not be created:\n{source}")]
41    CredsFileCreate {
42        /// The path to a credentials file administrative secrets can not be stored.
43        path: PathBuf,
44        /// The source error.
45        source: std::io::Error,
46    },
47
48    /// A credentials file does not exist.
49    #[error("The credentials file {path} does not exist")]
50    CredsFileMissing {
51        /// The path to a missing credentials file.
52        path: PathBuf,
53    },
54
55    /// A credentials file is not a file.
56    #[error("The credentials file {path} is not a file")]
57    CredsFileNotAFile {
58        /// The path to a credentials file that is not a file.
59        path: PathBuf,
60    },
61
62    /// A credentials file can not be written to.
63    #[error("The credentials file {path} can not be written to:\n{source}")]
64    CredsFileWrite {
65        /// The path to a credentials file that can not be written to.
66        path: PathBuf,
67        /// The source error
68        source: std::io::Error,
69    },
70
71    /// A passphrase is too short.
72    #[error(
73        "The passphrase for {context} is too short (should be at least {minimum_length} characters)"
74    )]
75    PassphraseTooShort {
76        /// The context in which the passphrase is used.
77        ///
78        /// This is inserted into the sentence "The _context_ passphrase is not long enough"
79        context: String,
80
81        /// The minimum length of a passphrase.
82        minimum_length: usize,
83    },
84}
85
86/// Administrative credentials.
87///
88/// Requires implementations to also derive [`DeserializeOwned`] and [`Serialize`].
89///
90/// Provides blanket implementations for loading of administrative credentials from default system
91/// locations ([`AdminCredentials::load`]) and specific paths
92/// ([`AdminCredentials::load_from_file`]), as well as storing of administrative credentials in the
93/// default system location ([`AdminCredentials::store`]).
94/// Technically, only the implementation of [`AdminCredentials::validate`] is required.
95pub trait AdminCredentials: DeserializeOwned + Serialize {
96    /// Loads an [`AdminCredentials`] from the default file location.
97    ///
98    /// # Errors
99    ///
100    /// Returns an error if [`AdminCredentials::load_from_file`] fails.
101    ///
102    /// # Panics
103    ///
104    /// This method panics when providing [`AdministrativeSecretHandling::ShamirsSecretSharing`]
105    /// as `secrets_handling`.
106    fn load(secrets_handling: AdministrativeSecretHandling) -> Result<Self, crate::Error> {
107        // fail if not running as root
108        fail_if_not_root(&get_current_system_user()?)?;
109
110        Self::load_from_file(
111            match secrets_handling {
112                AdministrativeSecretHandling::Plaintext => get_plaintext_credentials_file(),
113                AdministrativeSecretHandling::SystemdCreds => get_systemd_creds_credentials_file(),
114                AdministrativeSecretHandling::ShamirsSecretSharing => {
115                    unimplemented!("Shamir's Secret Sharing is not yet supported")
116                }
117            },
118            secrets_handling,
119        )
120    }
121
122    /// Loads an [`AdminCredentials`] from file.
123    /// # Errors
124    ///
125    /// Returns an error if
126    ///
127    /// - the method is called by a system user that is not root,
128    /// - the file at `path` does not exist,
129    /// - the file at `path` is not a file,
130    /// - the file at `path` is considered as plaintext but can not be loaded,
131    /// - the file at `path` is considered as [systemd-creds] encrypted but can not be decrypted,
132    /// - or the file at `path` is considered as [systemd-creds] encrypted but can not be loaded
133    ///   after decryption.
134    ///
135    /// # Panics
136    ///
137    /// This method panics when providing [`AdministrativeSecretHandling::ShamirsSecretSharing`]
138    /// as `secrets_handling`.
139    ///
140    /// [systemd-creds]: https://man.archlinux.org/man/systemd-creds.1
141    fn load_from_file(
142        path: impl AsRef<Path>,
143        secrets_handling: AdministrativeSecretHandling,
144    ) -> Result<Self, crate::Error> {
145        let path = path.as_ref();
146        if !path.exists() {
147            return Err(crate::Error::AdminSecretHandling(Error::CredsFileMissing {
148                path: path.to_path_buf(),
149            }));
150        }
151        if !path.is_file() {
152            return Err(crate::Error::AdminSecretHandling(
153                Error::CredsFileNotAFile {
154                    path: path.to_path_buf(),
155                },
156            ));
157        }
158
159        let config: Self = match secrets_handling {
160            AdministrativeSecretHandling::Plaintext => toml::from_str(
161                &read_to_string(path).map_err(|source| crate::Error::IoPath {
162                    path: path.to_path_buf(),
163                    context: "reading administrative credentials",
164                    source,
165                })?,
166            )
167            .map_err(|source| crate::Error::TomlRead {
168                path: path.to_path_buf(),
169                context: "deserializing a TOML string as administrative credentials",
170                source: Box::new(source),
171            })?,
172            AdministrativeSecretHandling::SystemdCreds => {
173                // Decrypt the credentials using systemd-creds.
174                let creds_command = get_command("systemd-creds")?;
175                let mut command = Command::new(creds_command);
176                let command = command.arg("decrypt").arg(path).arg("-");
177                let command_output =
178                    command
179                        .output()
180                        .map_err(|source| crate::Error::CommandExec {
181                            command: format!("{command:?}"),
182                            source,
183                        })?;
184                if !command_output.status.success() {
185                    return Err(crate::Error::CommandNonZero {
186                        command: format!("{command:?}"),
187                        exit_status: command_output.status,
188                        stderr: String::from_utf8_lossy(&command_output.stderr).into_owned(),
189                    });
190                }
191
192                // Read the resulting TOML string from stdout and construct an AdminCredentials
193                // from it.
194                let config_str = String::from_utf8(command_output.stdout).map_err(|source| {
195                    crate::Error::Utf8String {
196                        path: path.to_path_buf(),
197                        context: "after decrypting".to_string(),
198                        source,
199                    }
200                })?;
201                toml::from_str(&config_str).map_err(|source| crate::Error::TomlRead {
202                    path: path.to_path_buf(),
203                    context: "deserializing a TOML string as administrative credentials",
204                    source: Box::new(source),
205                })?
206            }
207            AdministrativeSecretHandling::ShamirsSecretSharing => {
208                unimplemented!("Shamir's Secret Sharing is not yet supported")
209            }
210        };
211        config.validate()?;
212        Ok(config)
213    }
214
215    /// Stores the [`AdminCredentials`] as a file in the default location.
216    ///
217    /// Depending on `secrets_handling`, the file path and contents differ:
218    ///
219    /// - [`AdministrativeSecretHandling::Plaintext`]: the file path is defined by
220    ///   [`get_plaintext_credentials_file`] and the contents are plaintext,
221    /// - [`AdministrativeSecretHandling::SystemdCreds`]: the file path is defined by
222    ///   [`get_systemd_creds_credentials_file`] and the contents are [systemd-creds] encrypted.
223    ///
224    /// Automatically creates the directory in which the administrative credentials are created.
225    /// After storing the [`AdminCredentials`] as file, its file permissions and ownership are
226    /// adjusted so that it is only accessible by root.
227    ///
228    /// # Errors
229    ///
230    /// Returns an error if
231    ///
232    /// - the method is called by a system user that is not root,
233    /// - the directory for administrative credentials cannot be created,
234    /// - `self` cannot be turned into its TOML representation,
235    /// - the [systemd-creds] command is not found,
236    /// - [systemd-creds] fails to encrypt the TOML representation of `self`,
237    /// - the target file can not be created,
238    /// - the plaintext or [systemd-creds] encrypted data can not be written to file,
239    /// - or the ownership or permissions of the target file can not be adjusted.
240    ///
241    /// # Panics
242    ///
243    /// This method panics when providing [`AdministrativeSecretHandling::ShamirsSecretSharing`]
244    /// as `secrets_handling`.
245    ///
246    /// [systemd-creds]: https://man.archlinux.org/man/systemd-creds.1
247    fn store(&self, secrets_handling: AdministrativeSecretHandling) -> Result<(), crate::Error> {
248        // fail if not running as root
249        fail_if_not_root(&get_current_system_user()?)?;
250
251        create_credentials_dir()?;
252
253        let (config_data, path) = {
254            // Get the TOML string representation of self.
255            let config_data =
256                toml::to_string_pretty(self).map_err(|source| crate::Error::TomlWrite {
257                    path: PathBuf::new(),
258                    context: "serializing administrative credentials",
259                    source,
260                })?;
261            match secrets_handling {
262                AdministrativeSecretHandling::Plaintext => (
263                    config_data.as_bytes().to_vec(),
264                    get_plaintext_credentials_file(),
265                ),
266                AdministrativeSecretHandling::SystemdCreds => {
267                    // Encrypt self as systemd-creds encrypted TOML file.
268                    let creds_command = get_command("systemd-creds")?;
269                    let mut command = Command::new(creds_command);
270                    let command = command.args(["encrypt", "-", "-"]);
271
272                    let mut command_child = command
273                        .stdin(Stdio::piped())
274                        .stdout(Stdio::piped())
275                        .spawn()
276                        .map_err(|source| crate::Error::CommandBackground {
277                            command: format!("{command:?}"),
278                            source,
279                        })?;
280                    let Some(mut stdin) = command_child.stdin.take() else {
281                        return Err(crate::Error::CommandAttachToStdin {
282                            command: format!("{command:?}"),
283                        })?;
284                    };
285
286                    let handle = std::thread::spawn(move || {
287                        stdin.write_all(config_data.as_bytes()).map_err(|source| {
288                            crate::Error::CommandWriteToStdin {
289                                command: "systemd-creds encrypt - -".to_string(),
290                                source,
291                            }
292                        })
293                    });
294
295                    let _handle_result = handle.join().map_err(|source| crate::Error::Thread {
296                        context: format!(
297                            "storing systemd-creds encrypted administrative credentials: {source:?}"
298                        ),
299                    })?;
300
301                    let command_output = command_child.wait_with_output().map_err(|source| {
302                        crate::Error::CommandExec {
303                            command: format!("{command:?}"),
304                            source,
305                        }
306                    })?;
307                    if !command_output.status.success() {
308                        return Err(crate::Error::CommandNonZero {
309                            command: format!("{command:?}"),
310                            exit_status: command_output.status,
311                            stderr: String::from_utf8_lossy(&command_output.stderr).into_owned(),
312                        });
313                    }
314                    (command_output.stdout, get_systemd_creds_credentials_file())
315                }
316                AdministrativeSecretHandling::ShamirsSecretSharing => {
317                    unimplemented!("Shamir's Secret Sharing is not yet supported")
318                }
319            }
320        };
321
322        // Write administrative credentials to file and adjust permission and ownership
323        // of file
324        {
325            let mut file = File::create(path.as_path()).map_err(|source| {
326                crate::Error::AdminSecretHandling(Error::CredsFileCreate {
327                    path: path.clone(),
328                    source,
329                })
330            })?;
331            file.write_all(&config_data).map_err(|source| {
332                crate::Error::AdminSecretHandling(Error::CredsFileWrite {
333                    path: path.to_path_buf(),
334                    source,
335                })
336            })?;
337        }
338        chown(&path, Some(0), Some(0)).map_err(|source| crate::Error::Chown {
339            path: path.clone(),
340            user: "root".to_string(),
341            source,
342        })?;
343        set_permissions(path.as_path(), Permissions::from_mode(SECRET_FILE_MODE)).map_err(
344            |source| crate::Error::ApplyPermissions {
345                path: path.clone(),
346                mode: SECRET_FILE_MODE,
347                source,
348            },
349        )?;
350
351        Ok(())
352    }
353
354    /// Validates the [`AdminCredentials`].
355    ///
356    /// # Errors
357    ///
358    /// This method is supposed to return an error if an assumption about the integrity of the
359    /// administrative credentials cannot be met.
360    /// It is called in the blanket implementation of [`AdminCredentials::load_from_file`].
361    fn validate(&self) -> Result<(), crate::Error>;
362}