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