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}