signstar_config/admin_credentials.rs
1//! Administrative credentials handling for a NetHSM 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
11#[cfg(doc)]
12use nethsm::UserId;
13use nethsm::{FullCredentials, Passphrase};
14use serde::{Deserialize, Serialize};
15use signstar_common::{
16 admin_credentials::{
17 create_credentials_dir,
18 get_plaintext_credentials_file,
19 get_systemd_creds_credentials_file,
20 },
21 common::SECRET_FILE_MODE,
22};
23
24use crate::{
25 AdministrativeSecretHandling,
26 utils::{fail_if_not_root, get_command, get_current_system_user},
27};
28
29/// An error that may occur when handling administrative credentials for a NetHSM backend.
30#[derive(Debug, thiserror::Error)]
31pub enum Error {
32 /// There is no top-level administrator.
33 #[error("There is no top-level administrator but at least one is required")]
34 AdministratorMissing,
35
36 /// There is no top-level administrator with the name "admin".
37 #[error("The default top-level administrator \"admin\" is missing")]
38 AdministratorNoDefault,
39
40 /// A credentials file can not be created.
41 #[error("The credentials file {path} can not be created:\n{source}")]
42 CredsFileCreate {
43 /// The path to a credentials file administrative secrets can not be stored.
44 path: PathBuf,
45 /// The source error.
46 source: std::io::Error,
47 },
48
49 /// A credentials file does not exist.
50 #[error("The credentials file {path} does not exist")]
51 CredsFileMissing {
52 /// The path to a missing credentials file.
53 path: PathBuf,
54 },
55
56 /// A credentials file is not a file.
57 #[error("The credentials file {path} is not a file")]
58 CredsFileNotAFile {
59 /// The path to a credentials file that is not a file.
60 path: PathBuf,
61 },
62
63 /// A credentials file can not be written to.
64 #[error("The credentials file {path} can not be written to:\n{source}")]
65 CredsFileWrite {
66 /// The path to a credentials file that can not be written to.
67 path: PathBuf,
68 /// The source error
69 source: std::io::Error,
70 },
71
72 /// A passphrase is too short.
73 #[error(
74 "The passphrase for {context} is too short (should be at least {minimum_length} characters)"
75 )]
76 PassphraseTooShort {
77 /// The context in which the passphrase is used.
78 ///
79 /// This is inserted into the sentence "The _context_ passphrase is not long enough"
80 context: String,
81
82 /// The minimum length of a passphrase.
83 minimum_length: usize,
84 },
85}
86
87/// Administrative credentials.
88///
89/// Tracks the following credentials and passphrases:
90/// - the backup passphrase of the backend,
91/// - the unlock passphrase of the backend,
92/// - the top-level administrator credentials of the backend,
93/// - the namespace administrator credentials of the backend.
94///
95/// # Note
96///
97/// The unlock and backup passphrase must be at least 10 characters long.
98/// The passphrases of top-level and namespace administrator accounts must be at least 10 characters
99/// long.
100/// The list of top-level administrator credentials must include an account with the username
101/// "admin".
102#[derive(Clone, Debug, Default, Deserialize, Serialize)]
103pub struct AdminCredentials {
104 iteration: u32,
105 backup_passphrase: Passphrase,
106 unlock_passphrase: Passphrase,
107 administrators: Vec<FullCredentials>,
108 namespace_administrators: Vec<FullCredentials>,
109}
110
111impl AdminCredentials {
112 /// Creates a new [`AdminCredentials`] instance.
113 ///
114 /// # Examples
115 ///
116 /// ```
117 /// use nethsm::FullCredentials;
118 /// use signstar_config::admin_credentials::AdminCredentials;
119 ///
120 /// # fn main() -> testresult::TestResult {
121 /// let creds = AdminCredentials::new(
122 /// 1,
123 /// "backup-passphrase".parse()?,
124 /// "unlock-passphrase".parse()?,
125 /// vec![FullCredentials::new(
126 /// "admin".parse()?,
127 /// "admin-passphrase".parse()?,
128 /// )],
129 /// vec![FullCredentials::new(
130 /// "ns1~admin".parse()?,
131 /// "ns1-admin-passphrase".parse()?,
132 /// )],
133 /// )?;
134 /// # // the backup passphrase is too short
135 /// # assert!(AdminCredentials::new(
136 /// # 1,
137 /// # "short".parse()?,
138 /// # "unlock-passphrase".parse()?,
139 /// # vec![FullCredentials::new("admin".parse()?, "admin-passphrase".parse()?)],
140 /// # vec![FullCredentials::new(
141 /// # "ns1~admin".parse()?,
142 /// # "ns1-admin-passphrase".parse()?,
143 /// # )],
144 /// # ).is_err());
145 /// #
146 /// # // the unlock passphrase is too short
147 /// # assert!(AdminCredentials::new(
148 /// # 1,
149 /// # "backup-passphrase".parse()?,
150 /// # "short".parse()?,
151 /// # vec![FullCredentials::new("admin".parse()?, "admin-passphrase".parse()?)],
152 /// # vec![FullCredentials::new(
153 /// # "ns1~admin".parse()?,
154 /// # "ns1-admin-passphrase".parse()?,
155 /// # )],
156 /// # ).is_err());
157 /// #
158 /// # // there is no top-level administrator
159 /// # assert!(AdminCredentials::new(
160 /// # 1,
161 /// # "backup-passphrase".parse()?,
162 /// # "unlock-passphrase".parse()?,
163 /// # Vec::new(),
164 /// # vec![FullCredentials::new(
165 /// # "ns1~admin".parse()?,
166 /// # "ns1-admin-passphrase".parse()?,
167 /// # )],
168 /// # ).is_err());
169 /// #
170 /// # // there is no default top-level administrator
171 /// # assert!(AdminCredentials::new(
172 /// # 1,
173 /// # "backup-passphrase".parse()?,
174 /// # "unlock-passphrase".parse()?,
175 /// # vec![FullCredentials::new("some".parse()?, "admin-passphrase".parse()?)],
176 /// # vec![FullCredentials::new(
177 /// # "ns1~admin".parse()?,
178 /// # "ns1-admin-passphrase".parse()?,
179 /// # )],
180 /// # ).is_err());
181 /// #
182 /// # // a top-level administrator passphrase is too short
183 /// # assert!(AdminCredentials::new(
184 /// # 1,
185 /// # "backup-passphrase".parse()?,
186 /// # "unlock-passphrase".parse()?,
187 /// # vec![FullCredentials::new("admin".parse()?, "short".parse()?)],
188 /// # vec![FullCredentials::new(
189 /// # "ns1~admin".parse()?,
190 /// # "ns1-admin-passphrase".parse()?,
191 /// # )],
192 /// # ).is_err());
193 /// #
194 /// # // a namespace administrator passphrase is too short
195 /// # assert!(AdminCredentials::new(
196 /// # 1,
197 /// # "backup-passphrase".parse()?,
198 /// # "unlock-passphrase".parse()?,
199 /// # vec![FullCredentials::new("some".parse()?, "admin-passphrase".parse()?)],
200 /// # vec![FullCredentials::new(
201 /// # "ns1~admin".parse()?,
202 /// # "short".parse()?,
203 /// # )],
204 /// # ).is_err());
205 /// # Ok(())
206 /// # }
207 /// ```
208 pub fn new(
209 iteration: u32,
210 backup_passphrase: Passphrase,
211 unlock_passphrase: Passphrase,
212 administrators: Vec<FullCredentials>,
213 namespace_administrators: Vec<FullCredentials>,
214 ) -> Result<Self, crate::Error> {
215 let admin_credentials = Self {
216 iteration,
217 backup_passphrase,
218 unlock_passphrase,
219 administrators,
220 namespace_administrators,
221 };
222 admin_credentials.validate()?;
223
224 Ok(admin_credentials)
225 }
226
227 /// Loads an [`AdminCredentials`] from the default file location.
228 ///
229 /// Depending on `secrets_handling`, the file path and contents differ:
230 ///
231 /// - [`AdministrativeSecretHandling::Plaintext`]: the file path is defined by
232 /// [`get_plaintext_credentials_file`] and the contents are plaintext,
233 /// - [`AdministrativeSecretHandling::SystemdCreds`]: the file path is defined by
234 /// [`get_systemd_creds_credentials_file`] and the contents are [systemd-creds] encrypted.
235 ///
236 /// Delegates to [`AdminCredentials::load_from_file`], providing the specific file path and the
237 /// selected `secrets_handling`.
238 ///
239 /// # Examples
240 ///
241 /// ```no_run
242 /// use signstar_config::{AdminCredentials, AdministrativeSecretHandling};
243 ///
244 /// # fn main() -> testresult::TestResult {
245 /// // load plaintext credentials from default location
246 /// let plaintext_admin_creds = AdminCredentials::load(AdministrativeSecretHandling::Plaintext)?;
247 ///
248 /// // load systemd-creds encrypted credentials from default location
249 /// let systemd_creds_admin_creds =
250 /// AdminCredentials::load(AdministrativeSecretHandling::SystemdCreds)?;
251 ///
252 /// # Ok(())
253 /// # }
254 /// ```
255 ///
256 /// # Errors
257 ///
258 /// Returns an error if [`AdminCredentials::load_from_file`] fails.
259 ///
260 /// # Panics
261 ///
262 /// This function panics when providing [`AdministrativeSecretHandling::ShamirsSecretSharing`]
263 /// as `secrets_handling`.
264 ///
265 /// [systemd-creds]: https://man.archlinux.org/man/systemd-creds.1
266 pub fn load(secrets_handling: AdministrativeSecretHandling) -> Result<Self, crate::Error> {
267 // fail if not running as root
268 fail_if_not_root(&get_current_system_user()?)?;
269
270 Self::load_from_file(
271 match secrets_handling {
272 AdministrativeSecretHandling::Plaintext => get_plaintext_credentials_file(),
273 AdministrativeSecretHandling::SystemdCreds => get_systemd_creds_credentials_file(),
274 AdministrativeSecretHandling::ShamirsSecretSharing => {
275 unimplemented!("Shamir's Secret Sharing is not yet supported")
276 }
277 },
278 secrets_handling,
279 )
280 }
281
282 /// Loads an [`AdminCredentials`] instance from file.
283 ///
284 /// Depending on `path` and `secrets_handling`, the behavior of this function differs:
285 ///
286 /// - If `secrets_handling` is set to [`AdministrativeSecretHandling::Plaintext`] the contents
287 /// at `path` are considered to be plaintext.
288 /// - If `secrets_handling` is set to [`AdministrativeSecretHandling::SystemdCreds`] the
289 /// contents at `path` are considered to be [systemd-creds] encrypted.
290 ///
291 /// # Examples
292 ///
293 /// ```no_run
294 /// use std::io::Write;
295 ///
296 /// use signstar_config::{AdminCredentials, AdministrativeSecretHandling};
297 ///
298 /// # fn main() -> testresult::TestResult {
299 /// let admin_creds = r#"iteration = 1
300 /// backup_passphrase = "backup-passphrase"
301 /// unlock_passphrase = "unlock-passphrase"
302 ///
303 /// [[administrators]]
304 /// name = "admin"
305 /// passphrase = "admin-passphrase"
306 ///
307 /// [[namespace_administrators]]
308 /// name = "ns1~admin"
309 /// passphrase = "ns1-admin-passphrase"
310 /// "#;
311 /// let mut tempfile = tempfile::NamedTempFile::new()?;
312 /// write!(tempfile.as_file_mut(), "{admin_creds}");
313 ///
314 /// assert!(
315 /// AdminCredentials::load_from_file(tempfile.path(), AdministrativeSecretHandling::Plaintext)
316 /// .is_ok()
317 /// );
318 /// # Ok(())
319 /// # }
320 /// ```
321 ///
322 /// # Errors
323 ///
324 /// Returns an error if
325 /// - the function is called by a system user that is not root,
326 /// - the file at `path` does not exist,
327 /// - the file at `path` is not a file,
328 /// - the file at `path` is considered as plaintext but can not be loaded,
329 /// - the file at `path` is considered as [systemd-creds] encrypted but can not be decrypted,
330 /// - or the file at `path` is considered as [systemd-creds] encrypted but can not be loaded
331 /// after decryption.
332 ///
333 /// # Panics
334 ///
335 /// This function panics when providing [`AdministrativeSecretHandling::ShamirsSecretSharing`]
336 /// as `secrets_handling`.
337 ///
338 /// [systemd-creds]: https://man.archlinux.org/man/systemd-creds.1
339 pub fn load_from_file(
340 path: impl AsRef<Path>,
341 secrets_handling: AdministrativeSecretHandling,
342 ) -> Result<Self, crate::Error> {
343 let path = path.as_ref();
344 if !path.exists() {
345 return Err(crate::Error::AdminSecretHandling(Error::CredsFileMissing {
346 path: path.to_path_buf(),
347 }));
348 }
349 if !path.is_file() {
350 return Err(crate::Error::AdminSecretHandling(
351 Error::CredsFileNotAFile {
352 path: path.to_path_buf(),
353 },
354 ));
355 }
356
357 let config: Self = match secrets_handling {
358 AdministrativeSecretHandling::Plaintext => toml::from_str(
359 &read_to_string(path).map_err(|source| crate::Error::IoPath {
360 path: path.to_path_buf(),
361 context: "reading administrative credentials",
362 source,
363 })?,
364 )
365 .map_err(|source| crate::Error::TomlRead {
366 path: path.to_path_buf(),
367 context: "deserializing a TOML string as administrative credentials",
368 source: Box::new(source),
369 })?,
370 AdministrativeSecretHandling::SystemdCreds => {
371 // Decrypt the credentials using systemd-creds.
372 let creds_command = get_command("systemd-creds")?;
373 let mut command = Command::new(creds_command);
374 let command = command.arg("decrypt").arg(path).arg("-");
375 let command_output =
376 command
377 .output()
378 .map_err(|source| crate::Error::CommandExec {
379 command: format!("{command:?}"),
380 source,
381 })?;
382 if !command_output.status.success() {
383 return Err(crate::Error::CommandNonZero {
384 command: format!("{command:?}"),
385 exit_status: command_output.status,
386 stderr: String::from_utf8_lossy(&command_output.stderr).into_owned(),
387 });
388 }
389
390 // Read the resulting TOML string from stdout and construct an AdminCredentials from
391 // it.
392 let config_str = String::from_utf8(command_output.stdout).map_err(|source| {
393 crate::Error::Utf8String {
394 path: path.to_path_buf(),
395 context: "after decrypting".to_string(),
396 source,
397 }
398 })?;
399 toml::from_str(&config_str).map_err(|source| crate::Error::TomlRead {
400 path: path.to_path_buf(),
401 context: "deserializing a TOML string as administrative credentials",
402 source: Box::new(source),
403 })?
404 }
405 AdministrativeSecretHandling::ShamirsSecretSharing => {
406 unimplemented!("Shamir's Secret Sharing is not yet supported")
407 }
408 };
409 config.validate()?;
410 Ok(config)
411 }
412
413 /// Stores the [`AdminCredentials`] as a file in the default location.
414 ///
415 /// Depending on `secrets_handling`, the file path and contents differ:
416 ///
417 /// - [`AdministrativeSecretHandling::Plaintext`]: the file path is defined by
418 /// [`get_plaintext_credentials_file`] and the contents are plaintext,
419 /// - [`AdministrativeSecretHandling::SystemdCreds`]: the file path is defined by
420 /// [`get_systemd_creds_credentials_file`] and the contents are [systemd-creds] encrypted.
421 ///
422 /// Automatically creates the directory in which the administrative credentials are created.
423 /// After storing the [`AdminCredentials`] as file, its file permissions and ownership are
424 /// adjusted so that it is only accessible by root.
425 ///
426 /// # Examples
427 ///
428 /// ```no_run
429 /// use nethsm::FullCredentials;
430 /// use signstar_config::{AdminCredentials, AdministrativeSecretHandling};
431 ///
432 /// # fn main() -> testresult::TestResult {
433 /// let creds = AdminCredentials::new(
434 /// 1,
435 /// "backup-passphrase".parse()?,
436 /// "unlock-passphrase".parse()?,
437 /// vec![FullCredentials::new(
438 /// "admin".parse()?,
439 /// "admin-passphrase".parse()?,
440 /// )],
441 /// vec![FullCredentials::new(
442 /// "ns1~admin".parse()?,
443 /// "ns1-admin-passphrase".parse()?,
444 /// )],
445 /// )?;
446 ///
447 /// // store as plaintext file
448 /// creds.store(AdministrativeSecretHandling::Plaintext)?;
449 ///
450 /// // store as systemd-creds encrypted file
451 /// creds.store(AdministrativeSecretHandling::SystemdCreds)?;
452 /// # Ok(())
453 /// # }
454 /// ```
455 ///
456 /// # Errors
457 ///
458 /// Returns an error if
459 /// - the function is called by a system user that is not root,
460 /// - the directory for administrative credentials cannot be created,
461 /// - `self` cannot be turned into its TOML representation,
462 /// - the [systemd-creds] command is not found,
463 /// - [systemd-creds] fails to encrypt the TOML representation of `self`,
464 /// - the target file can not be created,
465 /// - the plaintext or [systemd-creds] encrypted data can not be written to file,
466 /// - or the ownership or permissions of the target file can not be adjusted.
467 ///
468 /// # Panics
469 ///
470 /// This function panics when providing [`AdministrativeSecretHandling::ShamirsSecretSharing`]
471 /// as `secrets_handling`.
472 ///
473 /// [systemd-creds]: https://man.archlinux.org/man/systemd-creds.1
474 pub fn store(
475 &self,
476 secrets_handling: AdministrativeSecretHandling,
477 ) -> Result<(), crate::Error> {
478 // fail if not running as root
479 fail_if_not_root(&get_current_system_user()?)?;
480
481 create_credentials_dir()?;
482
483 let (config_data, path) = {
484 // Get the TOML string representation of self.
485 let config_data =
486 toml::to_string_pretty(self).map_err(|source| crate::Error::TomlWrite {
487 path: PathBuf::new(),
488 context: "serializing administrative credentials",
489 source,
490 })?;
491 match secrets_handling {
492 AdministrativeSecretHandling::Plaintext => (
493 config_data.as_bytes().to_vec(),
494 get_plaintext_credentials_file(),
495 ),
496 AdministrativeSecretHandling::SystemdCreds => {
497 // Encrypt self as systemd-creds encrypted TOML file.
498 let creds_command = get_command("systemd-creds")?;
499 let mut command = Command::new(creds_command);
500 let command = command.args(["encrypt", "-", "-"]);
501
502 let mut command_child = command
503 .stdin(Stdio::piped())
504 .stdout(Stdio::piped())
505 .spawn()
506 .map_err(|source| crate::Error::CommandBackground {
507 command: format!("{command:?}"),
508 source,
509 })?;
510 let Some(mut stdin) = command_child.stdin.take() else {
511 return Err(crate::Error::CommandAttachToStdin {
512 command: format!("{command:?}"),
513 })?;
514 };
515
516 let handle = std::thread::spawn(move || {
517 stdin.write_all(config_data.as_bytes()).map_err(|source| {
518 crate::Error::CommandWriteToStdin {
519 command: "systemd-creds encrypt - -".to_string(),
520 source,
521 }
522 })
523 });
524
525 let _handle_result = handle.join().map_err(|source| crate::Error::Thread {
526 context: format!(
527 "storing systemd-creds encrypted administrative credentials: {source:?}"
528 ),
529 })?;
530
531 let command_output = command_child.wait_with_output().map_err(|source| {
532 crate::Error::CommandExec {
533 command: format!("{command:?}"),
534 source,
535 }
536 })?;
537 if !command_output.status.success() {
538 return Err(crate::Error::CommandNonZero {
539 command: format!("{command:?}"),
540 exit_status: command_output.status,
541 stderr: String::from_utf8_lossy(&command_output.stderr).into_owned(),
542 });
543 }
544 (command_output.stdout, get_systemd_creds_credentials_file())
545 }
546 AdministrativeSecretHandling::ShamirsSecretSharing => {
547 unimplemented!("Shamir's Secret Sharing is not yet supported")
548 }
549 }
550 };
551
552 // Write administrative credentials to file and adjust permission and ownership
553 // of file
554 {
555 let mut file = File::create(path.as_path()).map_err(|source| {
556 crate::Error::AdminSecretHandling(Error::CredsFileCreate {
557 path: path.clone(),
558 source,
559 })
560 })?;
561 file.write_all(&config_data).map_err(|source| {
562 crate::Error::AdminSecretHandling(Error::CredsFileWrite {
563 path: path.to_path_buf(),
564 source,
565 })
566 })?;
567 }
568 chown(&path, Some(0), Some(0)).map_err(|source| crate::Error::Chown {
569 path: path.clone(),
570 user: "root".to_string(),
571 source,
572 })?;
573 set_permissions(path.as_path(), Permissions::from_mode(SECRET_FILE_MODE)).map_err(
574 |source| crate::Error::ApplyPermissions {
575 path: path.clone(),
576 mode: SECRET_FILE_MODE,
577 source,
578 },
579 )?;
580
581 Ok(())
582 }
583
584 /// Returns the iteration.
585 pub fn get_iteration(&self) -> u32 {
586 self.iteration
587 }
588
589 /// Returns the backup passphrase.
590 pub fn get_backup_passphrase(&self) -> &str {
591 self.backup_passphrase.expose_borrowed()
592 }
593
594 /// Returns the unlock passphrase.
595 pub fn get_unlock_passphrase(&self) -> &str {
596 self.unlock_passphrase.expose_borrowed()
597 }
598
599 /// Returns the list of administrators.
600 pub fn get_administrators(&self) -> &[FullCredentials] {
601 &self.administrators
602 }
603
604 /// Returns the default system-wide administrator "admin".
605 ///
606 /// # Errors
607 ///
608 /// Returns an error if no administrative account with the system-wide [`UserId`] "admin" is
609 /// found.
610 pub fn get_default_administrator(&self) -> Result<&FullCredentials, crate::Error> {
611 let Some(first_admin) = self
612 .administrators
613 .iter()
614 .find(|user| user.name.to_string() == "admin")
615 else {
616 return Err(Error::AdministratorNoDefault.into());
617 };
618 Ok(first_admin)
619 }
620
621 /// Returns the list of namespace administrators.
622 pub fn get_namespace_administrators(&self) -> &[FullCredentials] {
623 &self.namespace_administrators
624 }
625
626 /// Validates the [`AdminCredentials`].
627 ///
628 /// # Errors
629 ///
630 /// Returns an error if
631 /// - there is no top-level administrator user,
632 /// - the default top-level administrator user (with the name "admin") is missing,
633 /// - a user passphrase is too short,
634 /// - the backup passphrase is too short,
635 /// - or the unlock passphrase is too short.
636 fn validate(&self) -> Result<(), crate::Error> {
637 // there is no top-level administrator user
638 if self.get_administrators().is_empty() {
639 return Err(crate::Error::AdminSecretHandling(
640 Error::AdministratorMissing,
641 ));
642 }
643
644 // there is no top-level administrator user with the name "admin"
645 if !self
646 .get_administrators()
647 .iter()
648 .any(|user| user.name.to_string() == "admin")
649 {
650 return Err(crate::Error::AdminSecretHandling(
651 Error::AdministratorNoDefault,
652 ));
653 }
654
655 let minimum_length: usize = 10;
656
657 // a top-level administrator user passphrase is too short
658 for user in self.get_administrators().iter() {
659 if user.passphrase.expose_borrowed().len() < minimum_length {
660 return Err(crate::Error::AdminSecretHandling(
661 Error::PassphraseTooShort {
662 context: format!("user {}", user.name),
663 minimum_length,
664 },
665 ));
666 }
667 }
668
669 // a namespace administrator user passphrase is too short
670 for user in self.get_namespace_administrators().iter() {
671 if user.passphrase.expose_borrowed().len() < minimum_length {
672 return Err(crate::Error::AdminSecretHandling(
673 Error::PassphraseTooShort {
674 context: format!("user {}", user.name),
675 minimum_length,
676 },
677 ));
678 }
679 }
680
681 // the backup passphrase is too short
682 if self.get_backup_passphrase().len() < minimum_length {
683 return Err(crate::Error::AdminSecretHandling(
684 Error::PassphraseTooShort {
685 context: "backups".to_string(),
686 minimum_length,
687 },
688 ));
689 }
690
691 // the unlock passphrase is too short
692 if self.get_unlock_passphrase().len() < minimum_length {
693 return Err(crate::Error::AdminSecretHandling(
694 Error::PassphraseTooShort {
695 context: "unlocking".to_string(),
696 minimum_length,
697 },
698 ));
699 }
700
701 Ok(())
702 }
703}