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}