signstar_config/
admin_credentials.rs

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