Skip to main content

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