From 814a1c2994297489aa9a65f09d5274717459fd4f Mon Sep 17 00:00:00 2001 From: David Runge <dvzrv@archlinux.org> Date: Tue, 18 Mar 2025 20:41:27 +0100 Subject: [PATCH 1/6] feat: Derive `Debug` for `Credentials` The `Passphrase` type redacts its contents from debug prints, so it is safe to implement `Debug` for `Credentials`. Signed-off-by: David Runge <dvzrv@archlinux.org> --- nethsm/src/user.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nethsm/src/user.rs b/nethsm/src/user.rs index 2ff8caf6..487f23a9 100644 --- a/nethsm/src/user.rs +++ b/nethsm/src/user.rs @@ -118,7 +118,7 @@ impl FromStr for NamespaceId { impl Display for NamespaceId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) + write!(f, "{}", self.0) } } @@ -357,7 +357,7 @@ impl FromStr for UserId { impl Display for UserId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - UserId::SystemWide(user_id) => user_id.fmt(f), + UserId::SystemWide(user_id) => write!(f, "{user_id}"), UserId::Namespace(namespace, name) => write!(f, "{namespace}~{name}"), } } @@ -396,6 +396,7 @@ impl TryFrom<String> for UserId { /// Credentials for a [`NetHsm`][`crate::NetHsm`] /// /// Holds a user ID and an accompanying [`Passphrase`]. +#[derive(Debug)] pub struct Credentials { pub user_id: UserId, pub passphrase: Option<Passphrase>, -- GitLab From c8b7fe335911504d0336153764f7650c0a7f0d37 Mon Sep 17 00:00:00 2001 From: David Runge <dvzrv@archlinux.org> Date: Thu, 20 Mar 2025 13:55:28 +0100 Subject: [PATCH 2/6] feat: Derive `Clone` for `Credentials` In some situations it is useful/ needed to be able to clone `Credentials` when using them in consumers. Signed-off-by: David Runge <dvzrv@archlinux.org> --- nethsm/src/user.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nethsm/src/user.rs b/nethsm/src/user.rs index 487f23a9..76e2f9df 100644 --- a/nethsm/src/user.rs +++ b/nethsm/src/user.rs @@ -396,7 +396,7 @@ impl TryFrom<String> for UserId { /// Credentials for a [`NetHsm`][`crate::NetHsm`] /// /// Holds a user ID and an accompanying [`Passphrase`]. -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct Credentials { pub user_id: UserId, pub passphrase: Option<Passphrase>, -- GitLab From 4da4a39cd5e78872feb0f04171464fb53922557c Mon Sep 17 00:00:00 2001 From: David Runge <dvzrv@archlinux.org> Date: Thu, 6 Feb 2025 20:59:02 +0100 Subject: [PATCH 3/6] feat(justfile): Add recipes for running integration tests in containers Add `containerized-integration-test` recipe to run each test made available through the `_containerized-integration-test` feature in a separate Arch Linux container. Signed-off-by: David Runge <dvzrv@archlinux.org> --- .containers/Containerfile.integration-test | 5 ++ REUSE.toml | 1 + justfile | 53 ++++++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 .containers/Containerfile.integration-test diff --git a/.containers/Containerfile.integration-test b/.containers/Containerfile.integration-test new file mode 100644 index 00000000..70b22062 --- /dev/null +++ b/.containers/Containerfile.integration-test @@ -0,0 +1,5 @@ +FROM archlinux + +WORKDIR /test + +RUN pacman-key --init && pacman -Sy --needed --noconfirm archlinux-keyring && cat .env && source /test/.env && pacman -Syu --needed --noconfirm cargo cargo-nextest diff --git a/REUSE.toml b/REUSE.toml index 7b272c7f..11a33249 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -18,6 +18,7 @@ path = [ "*.json", "Cargo.lock", ".codespellrc", + ".containers/Containerfile.integration-test", ".env", ".gitignore", ".gitlab-ci.yml", diff --git a/justfile b/justfile index 2ee731f6..1c3c9f58 100755 --- a/justfile +++ b/justfile @@ -608,3 +608,56 @@ watch-book: just ensure-command watchexec watchexec --exts md,toml,js --delay-run 5s :w just build-book + +# Returns the target directory for cargo. +get-cargo-target-dir: + just ensure-command cargo jq + cargo metadata --format-version 1 | jq -r '.target_directory' + +# Returns all names of tests by project and features (the names are cargo-nextest compatible) +get-tests-by-features project features: + just ensure-command cargo cargo-nextest jq + + cargo nextest list --package {{ project }} --no-default-features --features {{ features }} --message-format json | jq -r '."rust-suites"[] | ."binary-id" as $x | ."testcases" | to_entries[] | if (.value."filter-match"."status") == "matches" then (.key) else null end | select(. != null) as $y | "\($x) \($y)"' + +# Builds a container image that enables running dedicated integration tests of the project in containers +build-container-integration-test-image: + just ensure-command podman + + podman build --volume "$PWD:/test" --tag arch-signstar-integration-test --file .containers/Containerfile.integration-test . + +# Runs each test of a project that is made available with the "_containerized-integration-test" feature in a separate container +containerized-integration-test project: + #!/usr/bin/env bash + set -euo pipefail + + just ensure-command cargo-nextest podman + + readonly features=_containerized-integration-test + project={{ project }} + raw_tests="$(just get-tests-by-features "$project" "$features")" + target_dir="$(just get-cargo-target-dir)" + readarray -t tests <<< "$raw_tests" + + tmpdir="$(mktemp --dry-run --directory)" + readonly test_tmpdir="$tmpdir" + mkdir -p "$test_tmpdir" + + # remove temporary dir on exit + cleanup() ( + if [[ -n "${test_tmpdir:-}" ]]; then + rm -rf "${test_tmpdir}" + fi + ) + + # create an archive with the special containerized tests + cargo nextest archive --archive-file "$test_tmpdir/tests.tar.zst" --features _containerized-integration-test --package "$project" + + for test in "${tests[@]}"; do + printf "Running test %s\n" "$test" + podman run --rm --interactive --tty --volume "$test_tmpdir:/mnt" --volume "$PWD:/test" --volume "$target_dir/debug/examples:/usr/local/bin" arch-signstar-integration-test cargo nextest run --archive-file "/mnt/tests.tar.zst" --workspace-remap "/test" "${test/ */}" "${test/* /}" + done + +# Runs the tests that are made available with the "_containerized-integration-test" feature of all configured projects in a separate container +containerized-integration-tests: + just containerized-integration-test signstar-config -- GitLab From 4905633ee48f8e5b9df0b747309c02bae1fe95fa Mon Sep 17 00:00:00 2001 From: David Runge <dvzrv@archlinux.org> Date: Mon, 24 Mar 2025 12:56:12 +0100 Subject: [PATCH 4/6] feat: Deny missing docs on the workspace level when linting Signed-off-by: David Runge <dvzrv@archlinux.org> --- Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 6b970a36..a561d08c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,9 @@ ureq = "2.12.1" uuid = { version = "1.11.0", features = ["v7"] } zeroize = { version = "1.8.1", features = ["zeroize_derive", "serde"] } +[workspace.lints.rust] +missing_docs = "deny" + [workspace.package] authors = [ "David Runge <dvzrv@archlinux.org>", -- GitLab From f13a0a4113705204652497a60c2948451daa01a4 Mon Sep 17 00:00:00 2001 From: David Runge <dvzrv@archlinux.org> Date: Fri, 7 Feb 2025 18:15:41 +0100 Subject: [PATCH 5/6] feat: Add `signstar-config` crate to handle Signstar host configs Signed-off-by: David Runge <dvzrv@archlinux.org> --- .lycheeignore | 2 + Cargo.lock | 65 +- Cargo.toml | 1 + signstar-config/Cargo.toml | 35 + signstar-config/README.md | 123 +++ .../examples/get-nethsm-credentials.rs | 61 ++ signstar-config/src/admin_credentials.rs | 567 +++++++++++++ signstar-config/src/config.rs | 41 + signstar-config/src/error.rs | 396 +++++++++ signstar-config/src/lib.rs | 18 + signstar-config/src/non_admin_credentials.rs | 765 ++++++++++++++++++ signstar-config/src/utils.rs | 198 +++++ .../tests/admin_credentials/mod.rs | 182 +++++ signstar-config/tests/config/mod.rs | 64 ++ .../tests/fixtures/admin-creds-simple.toml | 11 + .../tests/fixtures/signstar-config-full.toml | 129 +++ .../fixtures/signstar-config-plaintext.toml | 129 +++ signstar-config/tests/integration.rs | 12 + .../tests/non_admin_credentials/mod.rs | 383 +++++++++ signstar-config/tests/utils/mod.rs | 424 ++++++++++ 20 files changed, 3597 insertions(+), 9 deletions(-) create mode 100644 signstar-config/Cargo.toml create mode 100644 signstar-config/README.md create mode 100644 signstar-config/examples/get-nethsm-credentials.rs create mode 100644 signstar-config/src/admin_credentials.rs create mode 100644 signstar-config/src/config.rs create mode 100644 signstar-config/src/error.rs create mode 100644 signstar-config/src/lib.rs create mode 100644 signstar-config/src/non_admin_credentials.rs create mode 100644 signstar-config/src/utils.rs create mode 100644 signstar-config/tests/admin_credentials/mod.rs create mode 100644 signstar-config/tests/config/mod.rs create mode 100644 signstar-config/tests/fixtures/admin-creds-simple.toml create mode 100644 signstar-config/tests/fixtures/signstar-config-full.toml create mode 100644 signstar-config/tests/fixtures/signstar-config-plaintext.toml create mode 100644 signstar-config/tests/integration.rs create mode 100644 signstar-config/tests/non_admin_credentials/mod.rs create mode 100644 signstar-config/tests/utils/mod.rs diff --git a/.lycheeignore b/.lycheeignore index 75cbd132..542c1f91 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -7,3 +7,5 @@ https://raw.githubusercontent.com/Nitrokey/nethsm-sdk-py/main/tests/%7B%7D # URLs that only become available after release of a component https://signstar.archlinux.page/rustdoc/signstar_common/ https://docs.rs/signstar_common/latest/signstar_common/ +https://signstar.archlinux.page/rustdoc/signstar_config/ +https://docs.rs/signstar_config/latest/signstar_config/ diff --git a/Cargo.lock b/Cargo.lock index 8a00f64c..c403318a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -247,7 +247,7 @@ dependencies = [ "rustc-hash", "shlex", "syn", - "which", + "which 4.4.2", ] [[package]] @@ -1017,6 +1017,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "equivalent" version = "1.0.1" @@ -3141,6 +3147,28 @@ dependencies = [ "thiserror 2.0.11", ] +[[package]] +name = "signstar-config" +version = "0.1.0" +dependencies = [ + "confy", + "nethsm", + "nethsm-config", + "nix", + "num_enum", + "rand", + "rstest", + "serde", + "signstar-common", + "strum 0.27.1", + "tempfile", + "testresult", + "thiserror 2.0.11", + "toml", + "which 7.0.2", + "zeroize", +] + [[package]] name = "signstar-configure-build" version = "0.1.2" @@ -3392,12 +3420,13 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.14.0" +version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" +checksum = "38c246215d7d24f48ae091a2902398798e05d978b24315d6efbc00ede9a8bb91" dependencies = [ "cfg-if", "fastrand", + "getrandom 0.3.1", "once_cell", "rustix", "windows-sys 0.59.0", @@ -3537,9 +3566,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.19" +version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" dependencies = [ "serde", "serde_spanned", @@ -3558,9 +3587,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.22" +version = "0.22.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" dependencies = [ "indexmap", "serde", @@ -3899,6 +3928,18 @@ dependencies = [ "rustix", ] +[[package]] +name = "which" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2774c861e1f072b3aadc02f8ba886c26ad6321567ecc294c935434cad06f1283" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + [[package]] name = "whoami" version = "1.5.2" @@ -4174,13 +4215,19 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.6.20" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +checksum = "0e7f4ea97f6f78012141bcdb6a216b2609f0979ada50b20ca5b52dde2eac2bb1" dependencies = [ "memchr", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen-rt" version = "0.33.0" diff --git a/Cargo.toml b/Cargo.toml index a561d08c..c26dc3c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "nethsm-tests", "signstar-configure-build", "signstar-common", + "signstar-config", "signstar-request-signature", ] diff --git a/signstar-config/Cargo.toml b/signstar-config/Cargo.toml new file mode 100644 index 00000000..eb86f7b2 --- /dev/null +++ b/signstar-config/Cargo.toml @@ -0,0 +1,35 @@ +[package] +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +name = "signstar-config" +repository.workspace = true +version = "0.1.0" + +[dependencies] +confy.workspace = true +nethsm.workspace = true +nethsm-config.workspace = true +nix = { version = "0.29.0", features = ["user"] } +num_enum = "0.7.3" +rand.workspace = true +serde.workspace = true +signstar-common.workspace = true +strum.workspace = true +thiserror.workspace = true +toml = "0.8.20" +which = "7.0.2" +zeroize.workspace = true + +[dev-dependencies] +rstest.workspace = true +tempfile = "3.16.0" +testresult.workspace = true + +[features] +default = [] +_containerized-integration-test = [] + +[lints] +workspace = true diff --git a/signstar-config/README.md b/signstar-config/README.md new file mode 100644 index 00000000..b6181abd --- /dev/null +++ b/signstar-config/README.md @@ -0,0 +1,123 @@ +# Signstar config + +Configuration file handling for Signstar hosts. + +## Documentation + +- <https://signstar.archlinux.page/rustdoc/signstar_config/> for development version of the crate +- <https://docs.rs/signstar_config/latest/signstar_config/> for released versions of the crate + +## Examples + +### Administrative credentials + +Administrative credentials on a Signstar host describe all required secrets to unlock, backup, restore and fully provision a NetHSM backend. +They can be used from plaintext and [`systemd-creds`] encrypted files. +Functions for interacting with configurations in default locations must be called by root. + +```rust no_run +use nethsm_config::AdministrativeSecretHandling; +use signstar_config::AdminCredentials; + +# fn main() -> testresult::TestResult { +// Load from plaintext file in default location +let creds = AdminCredentials::load(AdministrativeSecretHandling::Plaintext)?; + +// Load from systemd-creds encrypted file in default location +let creds = AdminCredentials::load(AdministrativeSecretHandling::SystemdCreds)?; + +// Store in plaintext file in default location +creds.store(AdministrativeSecretHandling::Plaintext)?; + +// Store in systemd-creds encrypted file in default location +creds.store(AdministrativeSecretHandling::SystemdCreds)?; +# Ok(()) +# } +``` + +### Creating secrets for non-administrative credentials + +Non-administrative credentials on a Signstar host provide access to non-administrative users on a NetHSM backend. +They can be used in plaintext and [`systemd-creds`] encrypted files. + +Assuming, that a Signstar configuration is present on the host, it is possible to create secrets for each backend user assigned to any of the configured system users. +Functions for the creation of secrets must be called by root. + +```rust no_run +use nethsm_config::{ + AdministrativeSecretHandling, + ConfigInteractivity, + ConfigSettings, + ExtendedUserMapping, + HermeticParallelConfig, +}; +use signstar_common::config::get_default_config_file_path; +use signstar_config::{AdminCredentials, SecretsWriter}; + +# fn main() -> testresult::TestResult { +// Load Signstar config from default location +let config = HermeticParallelConfig::new_from_file( + ConfigSettings::new( + "my_app".to_string(), + ConfigInteractivity::NonInteractive, + None, + ), + Some(&get_default_config_file_path()), +)?; + +// Get extended user mappings for all users +let creds_mapping: Vec<ExtendedUserMapping> = config.into(); + +// Create secrets for each system user and their backend users +for mapping in &creds_mapping { + mapping.create_secrets_dir()?; + mapping.create_non_administrative_secrets()?; +} +# Ok(()) +# } +``` + +--- + +NOTE: For the creation of system users based on a Signstar config refer to [signstar-configure-build]. + +--- + +### Loading secrets for non-administrative users + +Depending on user mapping in the Signstar config, a system user may have one or more NetHSM backend users assigned to it. +The credentials for each NetHSM backend user can be loaded by each configured system user. +Functions for the loading of secrets must be called by the system user that is assigned that particular secret. + +```rust no_run +use signstar_config::CredentialsLoading; + +# fn main() -> testresult::TestResult { +// Load all credentials for the current system user +let credentials_loading = CredentialsLoading::from_system_user()?; + +// Assuming the current system user is a signing user, get the credentials for its assigned user in the NetHSM backend +let credentials = credentials_loading.credentials_for_signing_user()?; +# Ok(()) +# } +``` + +## Features + +- `_containerized-integration-test` enables tests that require to be run in a separate, ephemeral container each. + +## Contributing + +Please refer to the [contributing guidelines] to learn how to contribute to this project. + +## License + +This project may be used under the terms of the [Apache-2.0] or [MIT] license. + +Changes to this project - unless stated otherwise - automatically fall under the terms of both of the aforementioned licenses. + +[Apache-2.0]: https://www.apache.org/licenses/LICENSE-2.0 +[MIT]: https://opensource.org/licenses/MIT +[contributing guidelines]: ../CONTRIBUTING.md +[signstar-configure-build]: https://signstar.archlinux.page/signstar-configure-build/index.html +[`systemd-creds`]: https://man.archlinux.org/man/systemd-creds.1 diff --git a/signstar-config/examples/get-nethsm-credentials.rs b/signstar-config/examples/get-nethsm-credentials.rs new file mode 100644 index 00000000..43264748 --- /dev/null +++ b/signstar-config/examples/get-nethsm-credentials.rs @@ -0,0 +1,61 @@ +//! Example for reading all backend user credentials associated with the current system user. +use core::panic; +use std::process::ExitCode; + +#[cfg(doc)] +use nethsm::NetHsm; +use signstar_config::{CredentialsLoading, Error, ErrorExitCode}; + +/// Loads the Signstar credentials associated with the current system user. +/// +/// The system must have a valid Signstar configuration file available in one of the understood +/// configuration file locations, which provides an entry for the current system user. +/// +/// # Errors +/// +/// Returns an error if +/// - no credentials could be found that are associated with the current user, +/// - or one or more [`UserId`]s associated with the current system user triggered an error while +/// trying to retrieve their credentials. +fn load_user_credentials() -> Result<(), Error> { + let credentials_loading = CredentialsLoading::from_system_user()?; + + // Generally fail if errors occurred while getting credentials (for whichever type of user) + if credentials_loading.has_userid_errors() { + return Err(Error::NonAdminSecretHandling( + signstar_config::non_admin_credentials::Error::CredentialsLoading { + system_user: credentials_loading.get_system_user_id()?.clone(), + errors: credentials_loading.get_userid_errors(), + }, + )); + } + + eprintln!("{credentials_loading:#?}"); + + // Get credentials for a signing user in the backend if the current system user is associated + // with one + if credentials_loading.has_signing_user() { + eprintln!("Credentials for signing user in the backend:"); + let credentials = credentials_loading.credentials_for_signing_user()?; + let Some(ref passphrase) = credentials.passphrase else { + panic!("There should be a passphrase"); + }; + eprintln!( + "user: {}\npassphrase: {}", + credentials.user_id, + passphrase.expose_owned() + ) + } + + Ok(()) +} + +/// Retrieves all [`NetHsm`] credentials for the current system user. +fn main() -> ExitCode { + if let Err(error) = load_user_credentials() { + eprintln!("{error}"); + ExitCode::from(ErrorExitCode::from(error)) + } else { + ExitCode::SUCCESS + } +} diff --git a/signstar-config/src/admin_credentials.rs b/signstar-config/src/admin_credentials.rs new file mode 100644 index 00000000..67becd13 --- /dev/null +++ b/signstar-config/src/admin_credentials.rs @@ -0,0 +1,567 @@ +//! Administrative credentials handling for a NetHSM backend. + +use std::{ + fmt::Display, + fs::{File, Permissions, set_permissions}, + io::Write, + os::unix::fs::{PermissionsExt, chown}, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +use nethsm::UserId; +use nethsm_config::AdministrativeSecretHandling; +use serde::{Deserialize, Serialize}; +use signstar_common::{ + admin_credentials::{ + create_credentials_dir, + get_plaintext_credentials_file, + get_systemd_creds_credentials_file, + }, + common::SECRET_FILE_MODE, +}; +use zeroize::Zeroize; + +use crate::utils::{fail_if_not_root, get_command, get_current_system_user}; + +/// An error that may occur when handling administrative credentials for a NetHSM backend. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Deserializing administrative secrets from a TOML string failed. + #[error("Deserializing administrative secrets in {path} as TOML string failed:\n{source}")] + ConfigFromToml { + /// The path to a config file that can not be deserialization as TOML string. + path: PathBuf, + /// The boxed source error. + source: Box<toml::de::Error>, + }, + + /// Administrative secrets can not be loaded. + #[error("Unable to load administrative secrets from {path}:\n{source}")] + ConfigLoad { + /// The path to a config file from which administrative secrets can not be loaded. + path: PathBuf, + /// The boxed source error. + source: Box<confy::ConfyError>, + }, + + /// Administrative secrets can not be stored to file. + #[error("Unable to store administrative secrets in {path}:\n{source}")] + ConfigStore { + /// The path to a config file in which administrative secrets can not be stored. + path: PathBuf, + /// The source error. + source: confy::ConfyError, + }, + + /// Serializing a Signstar config as TOML string failed. + #[error("Serializing administrative secrets as TOML string failed:\n{0}")] + ConfigToToml(#[source] toml::ser::Error), + + /// A credentials file can not be created. + #[error("The credentials file {path} can not be created:\n{source}")] + CredsFileCreate { + /// The path to a credentials file administrative secrets can not be stored. + path: PathBuf, + /// The source error. + source: std::io::Error, + }, + + /// A credentials file does not exist. + #[error("The credentials file {path} does not exist")] + CredsFileMissing { + /// The path to a missing credentials file. + path: PathBuf, + }, + + /// A credentials file is not a file. + #[error("The credentials file {path} is not a file")] + CredsFileNotAFile { + /// The path to a credentials file that is not a file. + path: PathBuf, + }, + + /// A credentials file can not be written to. + #[error("The credentials file {path} can not be written to:\n{source}")] + CredsFileWrite { + /// The path to a credentials file that can not be written to. + path: PathBuf, + /// The source error + source: std::io::Error, + }, +} + +/// User data for [`AdminCredentials`]. +#[derive(Clone, Debug, Deserialize, Serialize, Zeroize)] +pub struct User { + #[zeroize(skip)] + name: UserId, + passphrase: String, +} + +impl User { + /// Creates a new [`User`] instance. + /// + /// # Examples + /// + /// ``` + /// use signstar_config::admin_credentials::User; + /// + /// # fn main() -> testresult::TestResult { + /// let mut user = User::new( + /// "ns1~admin".parse()?, + /// "ns1-admin-passphrase".to_string(), + /// ); + /// + /// assert_eq!(user.to_string(), user.get_name().to_string()); + /// assert_eq!(user.get_passphrase(), "ns1-admin-passphrase"); + /// + /// user.set_passphrase("new-passphrase".to_string()); + /// assert_eq!(user.get_passphrase(), "new-passphrase"); + /// # Ok(()) + /// # } + pub fn new(name: UserId, passphrase: String) -> Self { + Self { name, passphrase } + } + + /// Returns the name of the [`User`]. + pub fn get_name(&self) -> UserId { + self.name.clone() + } + + /// Returns the passphrase of the [`User`]. + pub fn get_passphrase(&self) -> &str { + &self.passphrase + } + + /// Sets the passphrase of the [`User`]. + pub fn set_passphrase(&mut self, passphrase: String) { + self.passphrase = passphrase + } +} + +impl Display for User { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + +/// Administrative credentials. +#[derive(Clone, Debug, Default, Deserialize, Serialize, Zeroize)] +pub struct AdminCredentials { + #[zeroize(skip)] + iteration: u32, + backup_passphrase: String, + unlock_passphrase: String, + administrators: Vec<User>, + namespace_administrators: Vec<User>, +} + +impl AdminCredentials { + /// Creates a new [`AdminCredentials`] instance. + /// + /// # Examples + /// + /// ``` + /// use signstar_config::admin_credentials::{AdminCredentials, User}; + /// + /// # fn main() -> testresult::TestResult { + /// let creds = AdminCredentials::new( + /// 1, + /// "backup-passphrase".to_string(), + /// "unlock-passphrase".to_string(), + /// vec![User::new("admin".parse()?, "admin-passphrase".to_string())], + /// vec![User::new( + /// "ns1~admin".parse()?, + /// "ns1-admin-passphrase".to_string(), + /// )], + /// ); + /// # Ok(()) + /// # } + /// ``` + pub fn new( + iteration: u32, + backup_passphrase: String, + unlock_passphrase: String, + administrators: Vec<User>, + namespace_administrators: Vec<User>, + ) -> Self { + Self { + iteration, + backup_passphrase, + unlock_passphrase, + administrators, + namespace_administrators, + } + } + + /// Loads an [`AdminCredentials`] from the default file location. + /// + /// Depending on `secrets_handling`, the file path and contents differ: + /// + /// - [`AdministrativeSecretHandling::Plaintext`]: the file path is defined by + /// [`get_plaintext_credentials_file`] and the contents are plaintext, + /// - [`AdministrativeSecretHandling::SystemdCreds`]: the file path is defined by + /// [`get_systemd_creds_credentials_file`] and the contents are [systemd-creds] encrypted. + /// + /// Delegates to [`AdminCredentials::load_from_file`], providing the specific file path and the + /// selected `secrets_handling`. + /// + /// # Examples + /// + /// ```no_run + /// use nethsm_config::AdministrativeSecretHandling; + /// use signstar_config::admin_credentials::AdminCredentials; + /// + /// # fn main() -> testresult::TestResult { + /// // load plaintext credentials from default location + /// let plaintext_admin_creds = AdminCredentials::load(AdministrativeSecretHandling::Plaintext)?; + /// + /// // load systemd-creds encrypted credentials from default location + /// let systemd_creds_admin_creds = + /// AdminCredentials::load(AdministrativeSecretHandling::SystemdCreds)?; + /// + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns an error if [`AdminCredentials::load_from_file`] fails. + /// + /// # Panics + /// + /// This function panics when providing [`AdministrativeSecretHandling::ShamirsSecretSharing`] + /// as `secrets_handling`. + /// + /// [systemd-creds]: https://man.archlinux.org/man/systemd-creds.1 + pub fn load(secrets_handling: AdministrativeSecretHandling) -> Result<Self, crate::Error> { + Self::load_from_file( + match secrets_handling { + AdministrativeSecretHandling::Plaintext => get_plaintext_credentials_file(), + AdministrativeSecretHandling::SystemdCreds => get_systemd_creds_credentials_file(), + AdministrativeSecretHandling::ShamirsSecretSharing => { + unimplemented!("Shamir's Secret Sharing is not yet supported") + } + }, + secrets_handling, + ) + } + + /// Loads an [`AdminCredentials`] instance from file. + /// + /// Depending on `path` and `secrets_handling`, the behavior of this function differs: + /// + /// - If `secrets_handling` is set to [`AdministrativeSecretHandling::Plaintext`] the contents + /// at `path` are considered to be plaintext. + /// - If `secrets_handling` is set to [`AdministrativeSecretHandling::SystemdCreds`] the + /// contents at `path` are considered to be [systemd-creds] encrypted. + /// + /// # Examples + /// + /// ```no_run + /// use std::io::Write; + /// + /// use nethsm_config::AdministrativeSecretHandling; + /// use signstar_config::admin_credentials::{AdminCredentials, User}; + /// + /// # fn main() -> testresult::TestResult { + /// let admin_creds = r#"iteration = 1 + /// backup_passphrase = "backup-passphrase" + /// unlock_passphrase = "unlock-passphrase" + /// + /// [[administrators]] + /// name = "admin" + /// passphrase = "admin-passphrase" + /// + /// [[namespace_administrators]] + /// name = "ns1~admin" + /// passphrase = "ns1-admin-passphrase" + /// "#; + /// let mut tempfile = tempfile::NamedTempFile::new()?; + /// write!(tempfile.as_file_mut(), "{admin_creds}"); + /// + /// assert!( + /// AdminCredentials::load_from_file(tempfile.path(), AdministrativeSecretHandling::Plaintext) + /// .is_ok() + /// ); + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns an error if + /// - the function is called by a system user that is not root, + /// - the file at `path` does not exist, + /// - the file at `path` is not a file, + /// - the file at `path` is considered as plaintext but can not be loaded, + /// - the file at `path` is considered as [systemd-creds] encrypted but can not be decrypted, + /// - or the file at `path` is considered as [systemd-creds] encrypted but can not be loaded + /// after decryption. + /// + /// # Panics + /// + /// This function panics when providing [`AdministrativeSecretHandling::ShamirsSecretSharing`] + /// as `secrets_handling`. + /// + /// [systemd-creds]: https://man.archlinux.org/man/systemd-creds.1 + pub fn load_from_file( + path: impl AsRef<Path>, + secrets_handling: AdministrativeSecretHandling, + ) -> Result<Self, crate::Error> { + // fail if not running as root + fail_if_not_root(&get_current_system_user()?)?; + + let path = path.as_ref(); + if !path.exists() { + return Err(crate::Error::AdminSecretHandling(Error::CredsFileMissing { + path: path.to_path_buf(), + })); + } + if !path.is_file() { + return Err(crate::Error::AdminSecretHandling( + Error::CredsFileNotAFile { + path: path.to_path_buf(), + }, + )); + } + + match secrets_handling { + AdministrativeSecretHandling::Plaintext => confy::load_path(path).map_err(|source| { + crate::Error::AdminSecretHandling(Error::ConfigLoad { + path: path.to_path_buf(), + source: Box::new(source), + }) + }), + AdministrativeSecretHandling::SystemdCreds => { + // Decrypt the credentials using systemd-creds. + let creds_command = get_command("systemd-creds")?; + let mut command = Command::new(creds_command); + let command = command.arg("decrypt").arg(path).arg("-"); + let command_output = + command + .output() + .map_err(|source| crate::Error::CommandExec { + command: format!("{command:?}"), + source, + })?; + if !command_output.status.success() { + return Err(crate::Error::CommandNonZero { + command: format!("{command:?}"), + exit_status: command_output.status, + stderr: String::from_utf8_lossy(&command_output.stderr).into_owned(), + }); + } + + // Read the resulting TOML string from stdout and construct an AdminCredentials from + // it. + let config_str = String::from_utf8(command_output.stdout).map_err(|source| { + crate::Error::Utf8String { + path: path.to_path_buf(), + context: "after decrypting".to_string(), + source, + } + })?; + toml::from_str(&config_str).map_err(|source| { + crate::Error::AdminSecretHandling(Error::ConfigFromToml { + path: path.to_path_buf(), + source: Box::new(source), + }) + }) + } + AdministrativeSecretHandling::ShamirsSecretSharing => { + unimplemented!("Shamir's Secret Sharing is not yet supported") + } + } + } + + /// Stores the [`AdminCredentials`] as a file in the default location. + /// + /// Depending on `secrets_handling`, the file path and contents differ: + /// + /// - [`AdministrativeSecretHandling::Plaintext`]: the file path is defined by + /// [`get_plaintext_credentials_file`] and the contents are plaintext, + /// - [`AdministrativeSecretHandling::SystemdCreds`]: the file path is defined by + /// [`get_systemd_creds_credentials_file`] and the contents are [systemd-creds] encrypted. + /// + /// Automatically creates the directory in which the administrative credentials are created. + /// After storing the [`AdminCredentials`] as file, its file permissions and ownership are + /// adjusted so that it is only accessible by root. + /// + /// # Examples + /// + /// ```no_run + /// use nethsm_config::AdministrativeSecretHandling; + /// use signstar_config::admin_credentials::{AdminCredentials, User}; + /// + /// # fn main() -> testresult::TestResult { + /// let creds = AdminCredentials::new( + /// 1, + /// "backup-passphrase".to_string(), + /// "unlock-passphrase".to_string(), + /// vec![User::new("admin".parse()?, "admin-passphrase".to_string())], + /// vec![User::new( + /// "ns1~admin".parse()?, + /// "ns1-admin-passphrase".to_string(), + /// )], + /// ); + /// + /// // store as plaintext file + /// creds.store(AdministrativeSecretHandling::Plaintext)?; + /// + /// // store as systemd-creds encrypted file + /// creds.store(AdministrativeSecretHandling::SystemdCreds)?; + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns an error if + /// - the function is called by a system user that is not root, + /// - the directory for administrative credentials cannot be created, + /// - `self` cannot be turned into its TOML representation, + /// - the [systemd-creds] command is not found, + /// - [systemd-creds] fails to encrypt the TOML representation of `self`, + /// - the target file can not be created, + /// - the plaintext or [systemd-creds] encrypted data can not be written to file, + /// - or the ownership or permissions of the target file can not be adjusted. + /// + /// # Panics + /// + /// This function panics when providing [`AdministrativeSecretHandling::ShamirsSecretSharing`] + /// as `secrets_handling`. + /// + /// [systemd-creds]: https://man.archlinux.org/man/systemd-creds.1 + pub fn store( + &self, + secrets_handling: AdministrativeSecretHandling, + ) -> Result<(), crate::Error> { + // fail if not running as root + fail_if_not_root(&get_current_system_user()?)?; + + create_credentials_dir()?; + + let (config_data, path) = { + // Get the TOML string representation of self. + let config_data = toml::to_string_pretty(self) + .map_err(|source| crate::Error::AdminSecretHandling(Error::ConfigToToml(source)))?; + match secrets_handling { + AdministrativeSecretHandling::Plaintext => ( + config_data.as_bytes().to_vec(), + get_plaintext_credentials_file(), + ), + AdministrativeSecretHandling::SystemdCreds => { + // Encrypt self as systemd-creds encrypted TOML file. + let creds_command = get_command("systemd-creds")?; + let mut command = Command::new(creds_command); + let command = command.args(["encrypt", "-", "-"]); + + let mut command_child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .map_err(|source| crate::Error::CommandBackground { + command: format!("{command:?}"), + source, + })?; + let Some(mut stdin) = command_child.stdin.take() else { + return Err(crate::Error::CommandAttachToStdin { + command: format!("{command:?}"), + })?; + }; + + let handle = std::thread::spawn(move || { + stdin.write_all(config_data.as_bytes()).map_err(|source| { + crate::Error::CommandWriteToStdin { + command: "systemd-creds encrypt - -".to_string(), + source, + } + }) + }); + + let _handle_result = handle.join().map_err(|source| crate::Error::Thread { + context: format!( + "storing systemd-creds encrypted administrative credentials: {source:?}" + ), + })?; + + let command_output = command_child.wait_with_output().map_err(|source| { + crate::Error::CommandExec { + command: format!("{command:?}"), + source, + } + })?; + if !command_output.status.success() { + return Err(crate::Error::CommandNonZero { + command: format!("{command:?}"), + exit_status: command_output.status, + stderr: String::from_utf8_lossy(&command_output.stderr).into_owned(), + }); + } + (command_output.stdout, get_systemd_creds_credentials_file()) + } + AdministrativeSecretHandling::ShamirsSecretSharing => { + unimplemented!("Shamir's Secret Sharing is not yet supported") + } + } + }; + + // Write administrative credentials to file and adjust permission and ownership + // of file + { + let mut file = File::create(path.as_path()).map_err(|source| { + crate::Error::AdminSecretHandling(Error::CredsFileCreate { + path: path.clone(), + source, + }) + })?; + file.write_all(&config_data).map_err(|source| { + crate::Error::AdminSecretHandling(Error::CredsFileWrite { + path: path.to_path_buf(), + source, + }) + })?; + } + chown(&path, Some(0), Some(0)).map_err(|source| crate::Error::Chown { + path: path.clone(), + user: "root".to_string(), + source, + })?; + set_permissions(path.as_path(), Permissions::from_mode(SECRET_FILE_MODE)).map_err( + |source| crate::Error::ApplyPermissions { + path: path.clone(), + mode: SECRET_FILE_MODE, + source, + }, + )?; + + Ok(()) + } + + /// Returns the iteration. + pub fn get_iteration(&self) -> u32 { + self.iteration + } + + /// Returns the backup passphrase. + pub fn get_backup_passphrase(&self) -> &str { + &self.backup_passphrase + } + + /// Returns the unlock passphrase. + pub fn get_unlock_passphrase(&self) -> &str { + &self.unlock_passphrase + } + + /// Returns the list of administrators. + pub fn get_administrators(&self) -> &[User] { + &self.administrators + } + + /// Returns the list of namespace administrators. + pub fn get_namespace_administrators(&self) -> &[User] { + &self.namespace_administrators + } +} diff --git a/signstar-config/src/config.rs b/signstar-config/src/config.rs new file mode 100644 index 00000000..648bd8c6 --- /dev/null +++ b/signstar-config/src/config.rs @@ -0,0 +1,41 @@ +//! Configuration file handling for a NetHSM backend. + +use nethsm_config::{ConfigSettings, HermeticParallelConfig}; +use signstar_common::config::{get_config_file, get_config_file_paths}; + +/// The error that may occur when handling a configuration file for a NetHSM backend. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The Signstar configuration is missing. + #[error("No configuration file found in {}.", get_config_file_paths().iter().map(|path| path.display().to_string()).collect::<Vec<String>>().join(", "))] + ConfigMissing, + + /// An error specific to NetHsm config handling. + #[error("NetHSM config error:\n{0}")] + NetHsmConfig(#[from] nethsm_config::Error), +} + +/// Loads a [`HermeticParallelConfig`]. +/// +/// Gets a configuration file from the default locations using [`get_config_file`] and returns it as +/// [`HermeticParallelConfig`]. +/// +/// # Errors +/// +/// Returns an error if no config file is found or if the [`HermeticParallelConfig`] can not be +/// created. +pub fn load_config() -> Result<HermeticParallelConfig, crate::Error> { + let Some(config_path) = get_config_file() else { + return Err(crate::Error::Config(Error::ConfigMissing)); + }; + + HermeticParallelConfig::new_from_file( + ConfigSettings::new( + "signstar".to_string(), + nethsm_config::ConfigInteractivity::NonInteractive, + None, + ), + Some(&config_path), + ) + .map_err(|source| crate::Error::Config(Error::NetHsmConfig(source))) +} diff --git a/signstar-config/src/error.rs b/signstar-config/src/error.rs new file mode 100644 index 00000000..a03506c4 --- /dev/null +++ b/signstar-config/src/error.rs @@ -0,0 +1,396 @@ +//! Common, top-level error type for all components of signstar-config. + +use std::{ + path::PathBuf, + process::{ExitCode, ExitStatus}, + string::FromUtf8Error, +}; + +/// An error that may occur when using Signstar config. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// An error specific to administrative secret handling. + #[error("Error with administrative secret handling:\n{0}")] + AdminSecretHandling(#[from] crate::admin_credentials::Error), + + /// An error specific to Signstar config handling. + /// Applying permissions to a file or directory failed. + #[error("Unable to apply permissions from mode {mode} to {path}:\n{source}")] + ApplyPermissions { + /// The path to a file for which permissions can not be applied. + path: PathBuf, + /// The file mode that should be applied for `path`. + mode: u32, + /// The source error. + source: std::io::Error, + }, + + /// The ownership of a path can not be changed. + #[error("Changing ownership of {path} to user {user} failed:\n{source}")] + Chown { + /// The path to a file for which ownership can not be changed. + path: PathBuf, + /// The system user that should be the new owner of `path`. + user: String, + /// The source error. + source: std::io::Error, + }, + + /// Unable to attach to stdin of a command. + #[error("Unable to attach to stdin of command \"{command}\"")] + CommandAttachToStdin { + /// The command for which attaching to stdin failed. + command: String, + }, + + /// A command exited unsuccessfully. + #[error("The command \"{command}\" could not be started in the background:\n{source}")] + CommandBackground { + /// The command that could not be started in the background. + command: String, + /// The source error. + source: std::io::Error, + }, + + /// A command could not be executed. + #[error("The command \"{command}\" could not be executed:\n{source}")] + CommandExec { + /// The command that could not be executed. + command: String, + /// The source error. + source: std::io::Error, + }, + + /// A command exited unsuccessfully. + #[error( + "The command \"{command}\" exited with non-zero status code \"{exit_status}\":\nstderr:\n{stderr}" + )] + CommandNonZero { + /// The command that exited with a non-zero exit code. + command: String, + /// The exit status of `command`. + exit_status: ExitStatus, + /// The stderr of `command`. + stderr: String, + }, + + /// Unable to write to stdin of a command. + #[error("Unable to write to stdin of command \"{command}\"")] + CommandWriteToStdin { + /// The command for which writing to stdin failed. + command: String, + /// The source error. + source: std::io::Error, + }, + + /// Configuration errors. + #[error("Signstar config error:\n{0}")] + Config(#[from] crate::config::Error), + + /// An error specific to non-administrative secret handling. + #[error("Error with non-administrative secret handling:\n{0}")] + NonAdminSecretHandling(#[from] crate::non_admin_credentials::Error), + + /// Low-level administrative credentials handling in signstar-common failed. + #[error("Handling of administrative credentials failed:\n{0}")] + SignstarCommonAdminCreds(#[from] signstar_common::admin_credentials::Error), + + /// Joining a thread returned an error. + #[error("Thread error while {context}")] + Thread { + /// The context in which the failed thread ran. + /// + /// Should complete the sentence "Thread error while ". + context: String, + }, + + /// A UTF-8 error occurred when trying to convert a byte vector to a string. + #[error("Converting contents of {path} to string failed while {context}:\n{source}")] + Utf8String { + /// The path to a file for which conversion to UTF-8 string failed. + path: PathBuf, + /// The context in which the error occurred. + /// + /// Should complete the sentence "Converting contents of `path` to string failed while " + context: String, + /// The source error. + source: FromUtf8Error, + }, + + /// A utility function returned an error. + #[error("Utility function error: {0}")] + Utils(#[from] crate::utils::Error), +} + +/// Mapping for relevant [`Error`] variants to an [`ExitCode`]. +#[derive(Clone, Copy, Debug, num_enum::IntoPrimitive, Eq, Ord, PartialEq, PartialOrd)] +#[repr(u8)] +pub enum ErrorExitCode { + /// Mapping for [`crate::admin_credentials::Error::ConfigFromToml`] wrapped in + /// [`Error::AdminSecretHandling`]. + AdminCredentialsConfigFromToml = 100, + + /// Mapping for [`crate::admin_credentials::Error::ConfigLoad`] wrapped in + /// [`Error::AdminSecretHandling`]. + AdminCredentialsConfigLoad = 101, + + /// Mapping for [`crate::admin_credentials::Error::ConfigStore`] wrapped in + /// [`Error::AdminSecretHandling`]. + AdminCredentialsConfigStore = 102, + + /// Mapping for [`crate::admin_credentials::Error::ConfigToToml`] wrapped in + /// [`Error::AdminSecretHandling`]. + AdminCredentialsConfigToToml = 103, + + /// Mapping for [`crate::admin_credentials::Error::CredsFileCreate`] wrapped in + /// [`Error::AdminSecretHandling`]. + AdminCredentialsCredsFileCreate = 104, + + /// Mapping for [`crate::admin_credentials::Error::CredsFileMissing`] wrapped in + /// [`Error::AdminSecretHandling`]. + AdminCredentialsCredsFileMissing = 105, + + /// Mapping for [`crate::admin_credentials::Error::CredsFileNotAFile`] wrapped in + /// [`Error::AdminSecretHandling`]. + AdminCredentialsCredsFileNotAFile = 106, + + /// Mapping for [`crate::admin_credentials::Error::CredsFileWrite`] wrapped in + /// [`Error::AdminSecretHandling`]. + AdminCredentialsCredsFileWrite = 107, + + /// Mapping for [`Error::ApplyPermissions`]. + ApplyPermissions = 10, + + /// Mapping for [`Error::Chown`]. + Chown = 11, + + /// Mapping for [`Error::CommandAttachToStdin`]. + CommandAttachToStdin = 12, + + /// Mapping for [`Error::CommandBackground`]. + CommandBackground = 13, + + /// Mapping for [`Error::CommandExec`]. + CommandExec = 14, + + /// Mapping for [`Error::CommandNonZero`]. + CommandNonZero = 15, + + /// Mapping for [`Error::CommandWriteToStdin`]. + CommandWriteToStdin = 16, + + /// Mapping for [`crate::config::Error::ConfigMissing`] wrapped in [`Error::Config`]. + ConfigConfigMissing = 120, + + /// Mapping for [`crate::config::Error::NetHsmConfig`] wrapped in [`Error::Config`]. + ConfigNetHsmConfig = 121, + + /// Mapping for [`crate::non_admin_credentials::Error::CredentialsLoading`] wrapped in + /// [`Error::NonAdminSecretHandling`]. + NonAdminCredentialsCredentialsLoading = 140, + + /// Mapping for [`crate::non_admin_credentials::Error::CredentialsMissing`] wrapped in + /// [`Error::NonAdminSecretHandling`]. + NonAdminCredentialsCredentialsMissing = 141, + + /// Mapping for [`crate::non_admin_credentials::Error::NoSystemUser`] wrapped in + /// [`Error::NonAdminSecretHandling`]. + NonAdminCredentialsNoSystemUser = 142, + + /// Mapping for [`crate::non_admin_credentials::Error::NotSigningUser`] wrapped in + /// [`Error::NonAdminSecretHandling`]. + NonAdminCredentialsNotSigningUser = 143, + + /// Mapping for [`crate::non_admin_credentials::Error::SecretsDirCreate`] wrapped in + /// [`Error::NonAdminSecretHandling`]. + NonAdminCredentialsSecretsDirCreate = 144, + + /// Mapping for [`crate::non_admin_credentials::Error::SecretsFileCreate`] wrapped in + /// [`Error::NonAdminSecretHandling`]. + NonAdminCredentialsSecretsFileCreate = 145, + + /// Mapping for [`crate::non_admin_credentials::Error::SecretsFileMetadata`] wrapped in + /// [`Error::NonAdminSecretHandling`]. + NonAdminCredentialsSecretsFileMetadata = 146, + + /// Mapping for [`crate::non_admin_credentials::Error::SecretsFileMissing`] wrapped in + /// [`Error::NonAdminSecretHandling`]. + NonAdminCredentialsSecretsFileMissing = 147, + + /// Mapping for [`crate::non_admin_credentials::Error::SecretsFileNotAFile`] wrapped in + /// [`Error::NonAdminSecretHandling`]. + NonAdminCredentialsSecretsFileNotAFile = 148, + + /// Mapping for [`crate::non_admin_credentials::Error::SecretsFilePermissions`] wrapped in + /// [`Error::NonAdminSecretHandling`]. + NonAdminCredentialsSecretsFilePermissions = 149, + + /// Mapping for [`crate::non_admin_credentials::Error::SecretsFileRead`] wrapped in + /// [`Error::NonAdminSecretHandling`]. + NonAdminCredentialsSecretsFileRead = 150, + + /// Mapping for [`crate::non_admin_credentials::Error::SecretsFileWrite`] wrapped in + /// [`Error::NonAdminSecretHandling`]. + NonAdminCredentialsSecretsFileWrite = 151, + + /// Mapping for [`signstar_common::admin_credentials::Error::ApplyPermissions`] wrapped in + /// [`Error::SignstarCommonAdminCreds`]. + SignstarCommonAdminCredsApplyPermissions = 170, + + /// Mapping for [`signstar_common::admin_credentials::Error::CreateDirectory`] wrapped in + /// [`Error::SignstarCommonAdminCreds`]. + SignstarCommonAdminCredsCreateDirectory = 171, + + /// Mapping for [`signstar_common::admin_credentials::Error::DirChangeOwner`] wrapped in + /// [`Error::SignstarCommonAdminCreds`]. + SignstarCommonAdminCredsDirChangeOwner = 172, + + /// Mapping for [`Error::Thread`]. + Thread = 17, + + /// Mapping for [`Error::Utf8String`]. + Utf8String = 18, + + /// Mapping for [`crate::utils::Error::ExecutableNotFound`] wrapped in [`Error::Utils`]. + UtilsExecutableNotFound = 190, + + /// Mapping for [`crate::utils::Error::MappingSystemUserGet`] wrapped in [`Error::Utils`]. + UtilsMappingSystemUserGet = 191, + + /// Mapping for [`crate::utils::Error::SystemUserData`] wrapped in [`Error::Utils`]. + UtilsSystemUserData = 192, + + /// Mapping for [`crate::utils::Error::SystemUserLookup`] wrapped in [`Error::Utils`]. + UtilsSystemUserLookup = 193, + + /// Mapping for [`crate::utils::Error::SystemUserMismatch`] wrapped in [`Error::Utils`]. + UtilsSystemUserMismatch = 194, + + /// Mapping for [`crate::utils::Error::SystemUserNotRoot`] wrapped in [`Error::Utils`]. + UtilsSystemUserNotRoot = 195, + + /// Mapping for [`crate::utils::Error::SystemUserRoot`] wrapped in [`Error::Utils`]. + UtilsSystemUserRoot = 196, +} + +impl From<Error> for ErrorExitCode { + fn from(value: Error) -> Self { + match value { + // admin credentials related errors and their exit codes + Error::AdminSecretHandling(error) => match error { + crate::admin_credentials::Error::ConfigFromToml { .. } => { + Self::AdminCredentialsConfigFromToml + } + crate::admin_credentials::Error::ConfigLoad { .. } => { + Self::AdminCredentialsConfigLoad + } + crate::admin_credentials::Error::ConfigStore { .. } => { + Self::AdminCredentialsConfigStore + } + crate::admin_credentials::Error::ConfigToToml(_) => { + Self::AdminCredentialsConfigToToml + } + crate::admin_credentials::Error::CredsFileCreate { .. } => { + Self::AdminCredentialsCredsFileCreate + } + crate::admin_credentials::Error::CredsFileMissing { .. } => { + Self::AdminCredentialsCredsFileMissing + } + crate::admin_credentials::Error::CredsFileNotAFile { .. } => { + Self::AdminCredentialsCredsFileNotAFile + } + crate::admin_credentials::Error::CredsFileWrite { .. } => { + Self::AdminCredentialsCredsFileWrite + } + }, + // config related errors + Error::Config(error) => match error { + crate::config::Error::ConfigMissing => Self::ConfigConfigMissing, + crate::config::Error::NetHsmConfig(_) => Self::ConfigNetHsmConfig, + }, + // non-admin credentials related errors and their exit codes + Error::NonAdminSecretHandling(error) => match error { + crate::non_admin_credentials::Error::CredentialsLoading { .. } => { + Self::NonAdminCredentialsCredentialsLoading + } + crate::non_admin_credentials::Error::CredentialsMissing { .. } => { + Self::NonAdminCredentialsCredentialsMissing + } + crate::non_admin_credentials::Error::NoSystemUser => { + Self::NonAdminCredentialsNoSystemUser + } + crate::non_admin_credentials::Error::NotSigningUser => { + Self::NonAdminCredentialsNotSigningUser + } + crate::non_admin_credentials::Error::SecretsDirCreate { .. } => { + Self::NonAdminCredentialsSecretsDirCreate + } + crate::non_admin_credentials::Error::SecretsFileCreate { .. } => { + Self::NonAdminCredentialsSecretsFileCreate + } + crate::non_admin_credentials::Error::SecretsFileMetadata { .. } => { + Self::NonAdminCredentialsSecretsFileMetadata + } + crate::non_admin_credentials::Error::SecretsFileMissing { .. } => { + Self::NonAdminCredentialsSecretsFileMissing + } + crate::non_admin_credentials::Error::SecretsFileNotAFile { .. } => { + Self::NonAdminCredentialsSecretsFileNotAFile + } + crate::non_admin_credentials::Error::SecretsFilePermissions { .. } => { + Self::NonAdminCredentialsSecretsFilePermissions + } + crate::non_admin_credentials::Error::SecretsFileRead { .. } => { + Self::NonAdminCredentialsSecretsFileRead + } + crate::non_admin_credentials::Error::SecretsFileWrite { .. } => { + Self::NonAdminCredentialsSecretsFileWrite + } + }, + // signstar-common admin credentials related errors + Error::SignstarCommonAdminCreds(error) => match error { + signstar_common::admin_credentials::Error::ApplyPermissions { .. } => { + Self::SignstarCommonAdminCredsApplyPermissions + } + signstar_common::admin_credentials::Error::CreateDirectory { .. } => { + Self::SignstarCommonAdminCredsCreateDirectory + } + signstar_common::admin_credentials::Error::DirChangeOwner { .. } => { + Self::SignstarCommonAdminCredsDirChangeOwner + } + }, + // utils related errors + Error::Utils(error) => match error { + crate::utils::Error::ExecutableNotFound { .. } => Self::UtilsExecutableNotFound, + crate::utils::Error::MappingSystemUserGet(_) => Self::UtilsMappingSystemUserGet, + crate::utils::Error::SystemUserData { .. } => Self::UtilsSystemUserData, + crate::utils::Error::SystemUserLookup { .. } => Self::UtilsSystemUserLookup, + crate::utils::Error::SystemUserMismatch { .. } => Self::UtilsSystemUserMismatch, + crate::utils::Error::SystemUserNotRoot { .. } => Self::UtilsSystemUserNotRoot, + crate::utils::Error::SystemUserRoot => Self::UtilsSystemUserRoot, + }, + // top-level errors and their exit codes + Error::ApplyPermissions { .. } => Self::ApplyPermissions, + Error::CommandAttachToStdin { .. } => Self::CommandAttachToStdin, + Error::Chown { .. } => Self::Chown, + Error::CommandBackground { .. } => Self::CommandBackground, + Error::CommandExec { .. } => Self::CommandExec, + Error::CommandNonZero { .. } => Self::CommandNonZero, + Error::Thread { .. } => Self::Thread, + Error::Utf8String { .. } => Self::Utf8String, + Error::CommandWriteToStdin { .. } => Self::CommandWriteToStdin, + } + } +} + +impl From<ErrorExitCode> for ExitCode { + fn from(value: ErrorExitCode) -> Self { + Self::from(std::convert::Into::<u8>::into(value)) + } +} + +impl From<ErrorExitCode> for i32 { + fn from(value: ErrorExitCode) -> Self { + Self::from(std::convert::Into::<u8>::into(value)) + } +} diff --git a/signstar-config/src/lib.rs b/signstar-config/src/lib.rs new file mode 100644 index 00000000..baf555d3 --- /dev/null +++ b/signstar-config/src/lib.rs @@ -0,0 +1,18 @@ +#![doc = include_str!("../README.md")] + +pub mod admin_credentials; +pub mod config; +pub mod error; +pub mod non_admin_credentials; +pub mod utils; + +pub use admin_credentials::{AdminCredentials, User}; +pub use config::load_config; +pub use error::{Error, ErrorExitCode}; +pub use non_admin_credentials::{ + CredentialsLoading, + CredentialsLoadingError, + CredentialsLoadingErrors, + SecretsReader, + SecretsWriter, +}; diff --git a/signstar-config/src/non_admin_credentials.rs b/signstar-config/src/non_admin_credentials.rs new file mode 100644 index 00000000..5e054e95 --- /dev/null +++ b/signstar-config/src/non_admin_credentials.rs @@ -0,0 +1,765 @@ +//! Non-administrative credentials handling for a NetHSM backend. +use std::{ + fmt::{Debug, Display}, + fs::{File, Permissions, create_dir_all, read_to_string, set_permissions}, + io::Write, + os::unix::fs::{PermissionsExt, chown}, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +#[cfg(doc)] +use nethsm::NetHsm; +use nethsm::{Credentials, Passphrase, UserId}; +#[cfg(doc)] +use nethsm_config::HermeticParallelConfig; +use nethsm_config::{ExtendedUserMapping, NonAdministrativeSecretHandling, SystemUserId}; +use rand::{Rng, distributions::Alphanumeric, thread_rng}; +use signstar_common::{ + common::SECRET_FILE_MODE, + system_user::{ + get_home_base_dir_path, + get_plaintext_secret_file, + get_systemd_creds_secret_file, + get_user_secrets_dir, + }, +}; + +use crate::{ + config::load_config, + utils::{ + fail_if_not_root, + fail_if_root, + get_command, + get_current_system_user, + get_system_user_pair, + match_current_system_user, + }, +}; + +/// An error that may occur when handling non-administrative credentials for a NetHSM backend. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// There are one or more errors when loading credentials for a specific system user. + #[error("Errors occurred when loading credentials for system user {system_user}:\n{errors}")] + CredentialsLoading { + /// The system user for which loading of backend user credentials led to errors. + system_user: SystemUserId, + /// The errors that occurred during loading of backend user credentials for `system_user`. + errors: CredentialsLoadingErrors, + }, + + /// There are no credentials for a specific system user. + #[error("There are no credentials for system user {system_user}")] + CredentialsMissing { + /// The system user for which credentials are missing. + system_user: SystemUserId, + }, + + /// A mapping does not offer a system user. + #[error("There is no system user in the mapping.")] + NoSystemUser, + + /// A user is not a signing user for the NetHSM backend. + #[error("The user is not an operator user in the NetHSM backend used for signing.")] + NotSigningUser, + + /// A passphrase directory can not be created. + #[error("Passphrase directory {path} for user {system_user} can not be created:\n{source}")] + SecretsDirCreate { + /// The path to a secrets directory that could not be created. + path: PathBuf, + /// The system user in whose home directory `path` could not be created. + system_user: SystemUserId, + /// The source error. + source: std::io::Error, + }, + + /// A secrets file can not be created. + #[error("The secrets file {path} can not be created for user {system_user}:\n{source}")] + SecretsFileCreate { + /// The path to a secrets file that could not be created. + path: PathBuf, + /// The system user in whose home directory `path` could not be created. + system_user: SystemUserId, + /// The source error. + source: std::io::Error, + }, + + /// The file metadata of a secrets file cannot be retrieved. + #[error("File metadata of secrets file {path} cannot be retrieved")] + SecretsFileMetadata { + /// The path to a secrets file for which metadata could not be retrieved. + path: PathBuf, + /// The source error. + source: std::io::Error, + }, + + /// A secrets file does not exist. + #[error("Secrets file not found: {path}")] + SecretsFileMissing { + /// The path to a secrets file that is missing. + path: PathBuf, + }, + + /// A secrets file is not a file. + #[error("Secrets file is not a file: {path}")] + SecretsFileNotAFile { + /// The path to a secrets file that is not a file. + path: PathBuf, + }, + + /// A secrets file does not have the correct permissions. + #[error("Secrets file {path} has permissions {mode}, but {SECRET_FILE_MODE} is required")] + SecretsFilePermissions { + /// The path to a secrets file for which permissions could not be set. + path: PathBuf, + /// The file mode that should be applied to the file at `path`. + mode: u32, + }, + + /// A secrets file cannot be read. + #[error("Failed reading secrets file {path}:\n{source}")] + SecretsFileRead { + /// The path to a secrets file that could not be read. + path: PathBuf, + /// The source error. + source: std::io::Error, + }, + + /// A secrets file can not be written to. + #[error("The secrets file {path} can not be written to for user {system_user}: {source}")] + SecretsFileWrite { + /// The path to a secrets file that could not be written to. + path: PathBuf, + /// The system user in whose home directory `path` resides. + system_user: SystemUserId, + /// The source error. + source: std::io::Error, + }, +} + +/// An error that may occur when loading credentials for a [`SystemUserId`]. +/// +/// Alongside an [`Error`][`crate::Error`] contains a target [`UserId`] for which the error +/// occurred. +#[derive(Debug)] +pub struct CredentialsLoadingError { + user_id: UserId, + error: crate::Error, +} + +impl CredentialsLoadingError { + /// Creates a new [`CredentialsLoadingError`]. + pub fn new(user_id: UserId, error: crate::Error) -> Self { + Self { user_id, error } + } + + /// Returns a reference to the [`UserId`]. + pub fn get_user_id(&self) -> &UserId { + &self.user_id + } + + /// Returns a reference to the [`Error`][crate::Error]. + pub fn get_error(&self) -> &crate::Error { + &self.error + } +} + +impl Display for CredentialsLoadingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.user_id, self.error) + } +} + +/// A wrapper for a list of [`CredentialsLoadingError`]s. +#[derive(Debug)] +pub struct CredentialsLoadingErrors { + errors: Vec<CredentialsLoadingError>, +} + +impl CredentialsLoadingErrors { + /// Creates a new [`CredentialsLoadingError`]. + pub fn new(errors: Vec<CredentialsLoadingError>) -> Self { + Self { errors } + } + + /// Returns a reference to the list of [`CredentialsLoadingError`]s. + pub fn get_errors(&self) -> &[CredentialsLoadingError] { + &self.errors + } +} + +impl Display for CredentialsLoadingErrors { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + self.errors + .iter() + .map(|error| error.to_string()) + .collect::<Vec<String>>() + .join("\n") + ) + } +} + +/// A collection of credentials and credential loading errors for a system user. +/// +/// Tracks a [`SystemUserId`], zero or more [`Credentials`] mapped to it, as well as zero or more +/// errors related to loading the passphrase for a [`UserId`]. +#[derive(Debug)] +pub struct CredentialsLoading { + mapping: ExtendedUserMapping, + credentials: Vec<Credentials>, + errors: CredentialsLoadingErrors, +} + +impl CredentialsLoading { + /// Creates a new [`CredentialsLoading`]. + pub fn new( + mapping: ExtendedUserMapping, + credentials: Vec<Credentials>, + errors: CredentialsLoadingErrors, + ) -> Self { + Self { + mapping, + credentials, + errors, + } + } + + /// Creates a [`CredentialsLoading`] for the calling system user. + /// + /// Uses the data of the calling system user to derive the specific mapping for it from the + /// Signstar configuration (a [`HermeticParallelConfig`]). + /// Then continues to retrieve the credentials for all associated [`NetHsm`] users of the + /// mapping. + /// + /// # Errors + /// + /// Returns an error if + /// - it is not possible to derive user data from the calling process, + /// - if there is no user data for the calling process, + /// - the Signstar configuration file does not exist, + /// - it is not possible to load the Signstar configuration, + /// - not exactly one user mapping exists for the calling system user, + /// - or if credentials loading fails due to a severe error. + pub fn from_system_user() -> Result<Self, crate::Error> { + let user = get_current_system_user()?; + + let system_config = load_config()?; + + let mapping = system_config + .get_extended_mapping_for_user(&user.name) + .map_err(|source| crate::Error::Config(crate::config::Error::NetHsmConfig(source)))?; + + // get all credentials for the mapping + let credentials_loading = mapping.load_credentials()?; + + Ok(credentials_loading) + } + + /// Returns the [`ExtendedUserMapping`]. + pub fn get_mapping(&self) -> &ExtendedUserMapping { + &self.mapping + } + + /// Returns all [`Credentials`]. + pub fn get_credentials(&self) -> &[Credentials] { + &self.credentials + } + + /// Returns a reference to a [`SystemUserId`]. + /// + /// # Errors + /// + /// Returns an error if there is no system user in the tracked mapping. + pub fn get_system_user_id(&self) -> Result<&SystemUserId, crate::Error> { + match self.mapping.get_user_mapping().get_system_user() { + Some(system_user) => Ok(system_user), + None => Err(crate::Error::NonAdminSecretHandling(Error::NoSystemUser)), + } + } + + /// Indicates whether there are any errors with [`UserId`]s. + /// + /// Returns `true` if there are errors, `false` otherwise. + pub fn has_userid_errors(&self) -> bool { + !self.errors.get_errors().is_empty() + } + + /// Returns the collected errors for [`UserId`]s. + pub fn get_userid_errors(self) -> CredentialsLoadingErrors { + self.errors + } + + /// Indicates whether the contained [`ExtendedUserMapping`] is that of a signing user. + pub fn has_signing_user(&self) -> bool { + matches!( + self.mapping.get_user_mapping(), + nethsm_config::UserMapping::SystemNetHsmOperatorSigning { + nethsm_user: _, + nethsm_key_setup: _, + ssh_authorized_key: _, + system_user: _, + tag: _, + } + ) + } + + /// Returns the credentials for a signing user. + /// + /// # Errors + /// + /// Returns an error if + /// - the tracked user is not a signing user + /// - errors occurred when loading the system user's credentials + /// - or there are no credentials for the system user. + pub fn credentials_for_signing_user(self) -> Result<Credentials, crate::Error> { + if !self.has_signing_user() { + return Err(crate::Error::NonAdminSecretHandling(Error::NotSigningUser)); + } + + if !self.errors.get_errors().is_empty() { + return Err(crate::Error::NonAdminSecretHandling( + Error::CredentialsLoading { + system_user: self.get_system_user_id()?.clone(), + errors: self.errors, + }, + )); + } + + if let Some(credentials) = self.credentials.first() { + Ok(credentials.clone()) + } else { + return Err(crate::Error::NonAdminSecretHandling( + Error::CredentialsMissing { + system_user: self.get_system_user_id()?.clone(), + }, + )); + } + } +} + +/// A trait to implement loading of credentials, which includes reading of secrets. +pub trait SecretsReader { + /// Loads credentials. + fn load_credentials(self) -> Result<CredentialsLoading, crate::Error>; +} + +/// Checks the accessibility of a secrets file. +/// +/// Checks whether file at `path` +/// - exists, +/// - is a file, +/// - has accessible metadata, +/// - and has the file mode [`SECRET_FILE_MODE`]. +/// +/// # Errors +/// +/// Returns an error, if the file at `path` +/// - does not exist, +/// - is not a file, +/// - does not have accessible metadata, +/// - or has a file mode other than [`SECRET_FILE_MODE`]. +fn check_secrets_file(path: &Path) -> Result<(), crate::Error> { + // check if a path exists + if !path.exists() { + return Err(crate::Error::NonAdminSecretHandling( + Error::SecretsFileMissing { + path: path.to_path_buf(), + }, + )); + } + + // check if this is a file + if !path.is_file() { + return Err(crate::Error::NonAdminSecretHandling( + Error::SecretsFileNotAFile { + path: path.to_path_buf(), + }, + )); + } + + // check for correct permissions + match path.metadata() { + Ok(metadata) => { + let mode = metadata.permissions().mode(); + if mode != SECRET_FILE_MODE { + return Err(crate::Error::NonAdminSecretHandling( + Error::SecretsFilePermissions { + path: path.to_path_buf(), + mode, + }, + )); + } + } + Err(source) => { + return Err(crate::Error::NonAdminSecretHandling( + Error::SecretsFileMetadata { + path: path.to_path_buf(), + source, + }, + )); + } + } + + Ok(()) +} + +impl SecretsReader for ExtendedUserMapping { + /// Loads credentials for each [`UserId`] associated with a [`SystemUserId`]. + /// + /// The [`SystemUserId`] of the mapping must be equal to the current system user calling this + /// function. + /// Relies on [`get_plaintext_secret_file`] and [`get_systemd_creds_secret_file`] to retrieve + /// the specific path to a secret file for each [`UserId`] mapped to a [`SystemUserId`]. + /// + /// Returns a [`CredentialsLoading`], which may contain critical errors related to loading a + /// passphrase for each available [`UserId`]. + /// The caller is expected to handle any errors tracked in the returned object based on context. + /// + /// # Errors + /// + /// Returns an error if + /// - the [`ExtendedUserMapping`] provides no [`SystemUserId`], + /// - no system user equal to the [`SystemUserId`] exists, + /// - the [`SystemUserId`] is not equal to the currently calling system user, + /// - or the [systemd-creds] command is not available when trying to decrypt secrets. + /// + /// [systemd-creds]: https://man.archlinux.org/man/systemd-creds.1 + fn load_credentials(self) -> Result<CredentialsLoading, crate::Error> { + // Retrieve required SystemUserId and User and compare with current User. + let (system_user, user) = get_system_user_pair(&self)?; + let current_system_user = get_current_system_user()?; + + // fail if running as root + fail_if_root(¤t_system_user)?; + match_current_system_user(¤t_system_user, &user)?; + + let secret_handling = self.get_non_admin_secret_handling(); + let mut credentials = Vec::new(); + let mut errors = Vec::new(); + + for user_id in self.get_user_mapping().get_nethsm_users() { + let secrets_file = match secret_handling { + NonAdministrativeSecretHandling::Plaintext => { + get_plaintext_secret_file(system_user.as_ref(), &user_id.to_string()) + } + NonAdministrativeSecretHandling::SystemdCreds => { + get_systemd_creds_secret_file(system_user.as_ref(), &user_id.to_string()) + } + }; + // Ensure the secrets file has correct ownership and permissions. + if let Err(error) = check_secrets_file(secrets_file.as_path()) { + errors.push(CredentialsLoadingError::new(user_id, error)); + continue; + }; + + match secret_handling { + // Read from plaintext secrets file. + NonAdministrativeSecretHandling::Plaintext => { + // get passphrase or error + match read_to_string(&secrets_file) + .map_err(|source| Error::SecretsFileRead { + path: secrets_file, + source, + }) + .map_err(crate::Error::NonAdminSecretHandling) + { + Ok(passphrase) => credentials + .push(Credentials::new(user_id, Some(Passphrase::new(passphrase)))), + Err(error) => { + errors.push(CredentialsLoadingError::new(user_id, error)); + continue; + } + } + } + // Read from systemd-creds encrypted secrets file. + NonAdministrativeSecretHandling::SystemdCreds => { + // Decrypt secret using systemd-creds. + let creds_command = get_command("systemd-creds")?; + let mut command = Command::new(creds_command); + let command = command + .arg("--user") + .arg("decrypt") + .arg(&secrets_file) + .arg("-"); + match command + .output() + .map_err(|source| crate::Error::CommandExec { + command: format!("{command:?}"), + source, + }) { + Ok(command_output) => { + // fail if decryption did not result in a successful status code + if !command_output.status.success() { + errors.push(CredentialsLoadingError::new( + user_id, + crate::Error::CommandNonZero { + command: format!("{command:?}"), + exit_status: command_output.status, + stderr: String::from_utf8_lossy(&command_output.stderr) + .into_owned(), + }, + )); + continue; + } + + let creds = match String::from_utf8(command_output.stdout) { + Ok(creds) => creds, + Err(source) => { + errors.push(CredentialsLoadingError::new( + user_id.clone(), + crate::Error::Utf8String { + path: secrets_file, + context: format!( + "converting stdout of {command:?} to string" + ), + source, + }, + )); + continue; + } + }; + + credentials + .push(Credentials::new(user_id, Some(Passphrase::new(creds)))); + } + Err(error) => { + errors.push(CredentialsLoadingError::new(user_id, error)); + continue; + } + } + } + } + } + + Ok(CredentialsLoading::new( + self, + credentials, + CredentialsLoadingErrors { errors }, + )) + } +} + +/// A trait to create non-administrative secrets and accompanying directories. +pub trait SecretsWriter { + /// Creates secrets directories for all non-administrative mappings. + fn create_secrets_dir(&self) -> Result<(), crate::Error>; + + /// Creates non-administrative secrets for all mappings of system users to backend users. + fn create_non_administrative_secrets(&self) -> Result<(), crate::Error>; +} + +impl SecretsWriter for ExtendedUserMapping { + /// Creates secrets directories for all non-administrative mappings. + /// + /// Matches the [`SystemUserId`] in a mapping with an actual user on the system. + /// Creates the passphrase directory for the user and ensures correct ownership of it and all + /// parent directories up until the user's home directory. + /// + /// # Errors + /// + /// Returns an error if + /// - no system user is available in the mapping, + /// - the system user of the mapping is not available on the system, + /// - the directory could not be created, + /// - the ownership of any directory between the user's home and the passphrase directory can + /// not be changed. + fn create_secrets_dir(&self) -> Result<(), crate::Error> { + // Retrieve required SystemUserId and User and compare with current User. + let (system_user, user) = get_system_user_pair(self)?; + + // fail if not running as root + fail_if_not_root(&get_current_system_user()?)?; + + // get and create the user's passphrase directory + let secrets_dir = get_user_secrets_dir(system_user.as_ref()); + create_dir_all(&secrets_dir).map_err(|source| Error::SecretsDirCreate { + path: secrets_dir.clone(), + system_user: system_user.clone(), + source, + })?; + + // Recursively chown all directories to the user and group, until `HOME_BASE_DIR` is + // reached. + let home_dir = get_home_base_dir_path().join(PathBuf::from(system_user.as_ref())); + let mut chown_dir = secrets_dir.clone(); + while chown_dir != home_dir { + chown(&chown_dir, Some(user.uid.as_raw()), Some(user.gid.as_raw())).map_err( + |source| crate::Error::Chown { + path: chown_dir.to_path_buf(), + user: system_user.to_string(), + source, + }, + )?; + if let Some(parent) = &chown_dir.parent() { + chown_dir = parent.to_path_buf() + } else { + break; + } + } + + Ok(()) + } + + /// Creates passphrases for all non-administrative mappings. + /// + /// Creates a random alphanumeric, 30-char long passphrase for each backend user of each + /// non-administrative user mapping. + /// + /// - If `self` is configured to use [`NonAdministrativeSecretHandling::Plaintext`], the + /// passphrase is stored in a secrets file, defined by [`get_plaintext_secret_file`]. + /// - If `self` is configured to use [`NonAdministrativeSecretHandling::SystemdCreds`], the + /// passphrase is encrypted using [systemd-creds] and stored in a secrets file, defined by + /// [`get_systemd_creds_secret_file`]. + /// + /// # Errors + /// + /// Returns an error if + /// - the targeted system user does not exist in the mapping or on the system, + /// - the function is called using a non-root user, + /// - the [systemd-creds] command is not available when trying to encrypt the passphrase, + /// - the encryption of the passphrase using [systemd-creds] fails, + /// - the secrets file can not be created, + /// - the secrets file can not be written to, + /// - or the ownership and permissions of the secrets file can not be changed. + /// + /// [systemd-creds]: https://man.archlinux.org/man/systemd-creds.1 + fn create_non_administrative_secrets(&self) -> Result<(), crate::Error> { + // Retrieve required SystemUserId and User. + let (system_user, user) = get_system_user_pair(self)?; + + // fail if not running as root + fail_if_not_root(&get_current_system_user()?)?; + + let secret_handling = self.get_non_admin_secret_handling(); + + // add a secret for each NetHSM user + for user_id in self.get_user_mapping().get_nethsm_users() { + let secrets_file = match secret_handling { + NonAdministrativeSecretHandling::Plaintext => { + get_plaintext_secret_file(system_user.as_ref(), &user_id.to_string()) + } + NonAdministrativeSecretHandling::SystemdCreds => { + get_systemd_creds_secret_file(system_user.as_ref(), &user_id.to_string()) + } + }; + println!( + "Create secret for system user {system_user} and backend user {user_id} in file: {secrets_file:?}" + ); + let secret = { + // create initial (unencrypted) secret + let initial_secret: String = thread_rng() + .sample_iter(&Alphanumeric) + .take(30) + .map(char::from) + .collect(); + // Create credentials files depending on secret handling + match secret_handling { + NonAdministrativeSecretHandling::Plaintext => { + initial_secret.as_bytes().to_vec() + } + NonAdministrativeSecretHandling::SystemdCreds => { + // Create systemd-creds encrypted secret. + let creds_command = get_command("systemd-creds")?; + let mut command = Command::new(creds_command); + let command = command + .arg("--user") + .arg("--name=") + .arg("--uid") + .arg(system_user.as_ref()) + .arg("encrypt") + .arg("-") + .arg("-"); + let mut command_child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .map_err(|source| crate::Error::CommandBackground { + command: format!("{command:?}"), + source, + })?; + let Some(mut stdin) = command_child.stdin.take() else { + return Err(crate::Error::CommandAttachToStdin { + command: format!("{command:?}"), + })?; + }; + + let system_user_thread = system_user.clone(); + let handle = std::thread::spawn(move || { + stdin + .write_all(initial_secret.as_bytes()) + .map_err(|source| crate::Error::CommandWriteToStdin { + command: + format!("systemd-creds --user --name= --uid {system_user_thread} encrypt - -"), + source, + }) + }); + + let _handle_result = handle.join().map_err(|source| crate::Error::Thread { + context: format!( + "storing systemd-creds encrypted non-administrative secrets: {source:?}" + ), + })?; + + let command_output = + command_child.wait_with_output().map_err(|source| { + crate::Error::CommandExec { + command: format!("{command:?}"), + source, + } + })?; + + if !command_output.status.success() { + return Err(crate::Error::CommandNonZero { + command: format!("{command:?}"), + exit_status: command_output.status, + stderr: String::from_utf8_lossy(&command_output.stderr) + .into_owned(), + }); + } + command_output.stdout + } + } + }; + + // Write secret to file and adjust permission and ownership of file. + let mut file = File::create(secrets_file.as_path()).map_err(|source| { + Error::SecretsFileCreate { + path: secrets_file.clone(), + system_user: system_user.clone(), + source, + } + })?; + file.write_all(&secret) + .map_err(|source| Error::SecretsFileWrite { + path: secrets_file.clone(), + system_user: system_user.clone(), + source, + })?; + chown( + &secrets_file, + Some(user.uid.as_raw()), + Some(user.gid.as_raw()), + ) + .map_err(|source| crate::Error::Chown { + path: secrets_file.clone(), + user: system_user.to_string(), + source, + })?; + set_permissions( + secrets_file.as_path(), + Permissions::from_mode(SECRET_FILE_MODE), + ) + .map_err(|source| crate::Error::ApplyPermissions { + path: secrets_file.clone(), + mode: SECRET_FILE_MODE, + source, + })?; + } + Ok(()) + } +} diff --git a/signstar-config/src/utils.rs b/signstar-config/src/utils.rs new file mode 100644 index 00000000..1ab8050f --- /dev/null +++ b/signstar-config/src/utils.rs @@ -0,0 +1,198 @@ +//! Utilities for signstar-config. +use std::path::PathBuf; + +use nethsm_config::{ExtendedUserMapping, SystemUserId}; +use nix::unistd::{User, geteuid}; +use which::which; + +/// An error that may occur when using signstar-config utils. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// An executable that is supposed to be called, is not found. + #[error("Unable to to find executable \"{command}\"")] + ExecutableNotFound { + /// The executable that could not be found. + command: String, + /// The source error. + source: which::Error, + }, + + /// An [`ExtendedUserMapping`] does not provide a system user. + #[error("The user mapping does not provide a system user:\n{0}")] + MappingSystemUserGet(String), + + /// There is no data about a system user. + #[error("Data for system user {user} is missing")] + SystemUserData { + /// The user identifier for which data is missing. + user: NameOrUid, + }, + + /// Unable to lookup system user data (by name or from process EUID). + #[error( + "Unable to lookup data for system user {}:\n{source}", + match user { + NameOrUid::Name(name) => format!("user {name}"), + NameOrUid::Uid(uid) => format!("uid {uid}"), + } + )] + SystemUserLookup { + /// The user identifier for which data could not be looked up. + user: NameOrUid, + /// The source error. + source: nix::errno::Errno, + }, + + /// The calling user does not match the targeted system user. + #[error( + "The targeted system user {target_user} is not the currently calling system user {current_user}." + )] + SystemUserMismatch { + /// The system user that is the target of the operation. + target_user: String, + /// The currently calling system user. + current_user: String, + }, + + /// The current user is an unprivileged user, but should be root. + #[error("The command requires running as root, but running as \"{user}\"")] + SystemUserNotRoot { + /// The system user that is used instead of `root`. + user: String, + }, + + /// The current user is root, but should be an unprivileged user. + #[error("The command must not be run as root, but running as \"root\"")] + SystemUserRoot, +} + +/// A name or uid of a system user on a host +#[derive(Debug, strum::Display)] +pub enum NameOrUid { + /// The name of the system user. + Name(SystemUserId), + /// The ID of the system user. + Uid(nix::unistd::Uid), +} + +/// Returns the path to a `command`. +/// +/// Searches for an executable in `$PATH` of the current environment and returns the first one +/// found. +/// +/// # Errors +/// +/// Returns an error if no executable matches the provided `command`. +pub(crate) fn get_command(command: &str) -> Result<PathBuf, Error> { + which(command).map_err(|source| Error::ExecutableNotFound { + command: command.to_string(), + source, + }) +} + +/// Fails if not running as root. +/// +/// Evaluates the effective user ID. +/// +/// # Errors +/// +/// Returns an error if the effective user ID is not that of root. +pub(crate) fn fail_if_not_root(user: &User) -> Result<(), Error> { + if !user.uid.is_root() { + return Err(Error::SystemUserNotRoot { + user: user.name.clone(), + }); + } + Ok(()) +} + +/// Fails if running as root. +/// +/// Evaluates the effective user ID. +/// +/// # Errors +/// +/// Returns an error if the effective user ID is that of root. +pub(crate) fn fail_if_root(user: &User) -> Result<(), Error> { + if user.uid.is_root() { + return Err(Error::SystemUserRoot); + } + Ok(()) +} + +/// Returns the [`User`] associated with the current process. +/// +/// Retrieves user data of the system based on the effective user ID of the current process. +/// +/// # Errors +/// +/// Returns an error if +/// - no user data can be derived from the current process +/// - no user data can be found on the system, associated with the ID of the user of the current +/// process. +pub(crate) fn get_current_system_user() -> Result<User, Error> { + let euid = geteuid(); + let Some(user) = User::from_uid(euid).map_err(|source| Error::SystemUserLookup { + user: NameOrUid::Uid(euid), + source, + })? + else { + return Err(Error::SystemUserData { + user: NameOrUid::Uid(euid), + }); + }; + Ok(user) +} + +/// Checks whether the current system user is the targeted user. +/// +/// Compares two [`User`] instances and fails if they are not the same. +/// +/// # Errors +/// +/// Returns an error if the current system user is not the targeted user. +pub(crate) fn match_current_system_user( + current_user: &User, + target_user: &User, +) -> Result<(), Error> { + if current_user != target_user { + return Err(Error::SystemUserMismatch { + target_user: target_user.name.clone(), + current_user: current_user.name.clone(), + }); + } + Ok(()) +} + +/// Returns a [`SystemUserId`] and matching Unix system [`User`] associated with it. +/// +/// # Errors +/// +/// Returns an error if +/// - there is no [`SystemUserId`] in the mapping, +/// - or no [`User`] data can be retrieved from a found [`SystemUserId`]. +pub(crate) fn get_system_user_pair( + mapping: &ExtendedUserMapping, +) -> Result<(SystemUserId, User), Error> { + // retrieve the targeted system user from the mapping + let Some(system_user) = mapping.get_user_mapping().get_system_user() else { + return Err(Error::MappingSystemUserGet(format!( + "{:?}", + mapping.get_user_mapping() + ))); + }; + + // retrieve the actual user data on the system + let Some(user) = + User::from_name(system_user.as_ref()).map_err(|source| Error::SystemUserLookup { + user: NameOrUid::Name(system_user.clone()), + source, + })? + else { + return Err(Error::SystemUserData { + user: NameOrUid::Name(system_user.clone()), + }); + }; + + Ok((system_user.clone(), user)) +} diff --git a/signstar-config/tests/admin_credentials/mod.rs b/signstar-config/tests/admin_credentials/mod.rs new file mode 100644 index 00000000..a02f6726 --- /dev/null +++ b/signstar-config/tests/admin_credentials/mod.rs @@ -0,0 +1,182 @@ +//! Integration tests for [`signstar_config::admin_credentials`]. +use std::fs::{copy, create_dir_all, read_to_string}; + +use nethsm::UserId; +use nethsm_config::AdministrativeSecretHandling; +use rstest::rstest; +use signstar_common::admin_credentials::{ + create_credentials_dir, + get_credentials_dir, + get_plaintext_credentials_file, + get_systemd_creds_credentials_file, +}; +use signstar_config::admin_credentials::{AdminCredentials, User}; +use testresult::TestResult; + +use super::utils::write_machine_id; +use crate::utils::{SIGNSTAR_ADMIN_CREDS_SIMPLE, get_tmp_config}; + +#[rstest] +fn fail_to_load_on_path_not_a_file() -> TestResult { + let credentials_file = get_plaintext_credentials_file(); + create_dir_all(&credentials_file)?; + let error = AdminCredentials::load(AdministrativeSecretHandling::Plaintext) + .expect_err("Did not return an error!"); + if let signstar_config::Error::AdminSecretHandling( + signstar_config::admin_credentials::Error::CredsFileNotAFile { path }, + ) = error + { + assert_eq!(credentials_file, path) + } else { + panic!("Did not return an the correct error variant!") + } + + Ok(()) +} + +/// Copies a plaintext TOML containing admin credentials to the default location and loads it. +#[rstest] +fn load_plaintext_toml() -> TestResult { + create_credentials_dir()?; + let config_file = get_tmp_config(SIGNSTAR_ADMIN_CREDS_SIMPLE)?; + + copy(config_file, get_plaintext_credentials_file())?; + + let creds = AdminCredentials::load(AdministrativeSecretHandling::Plaintext)?; + println!("{creds:?}"); + Ok(()) +} + +/// Stores as plaintext TOML in the default location and compares to a fixture. +#[rstest] +fn store_plaintext_toml() -> TestResult { + let config_file = get_tmp_config(SIGNSTAR_ADMIN_CREDS_SIMPLE)?; + let creds = AdminCredentials::new( + 1, + "backup-passphrase".to_string(), + "unlock-passphrase".to_string(), + vec![User::new( + UserId::new("admin".to_string())?, + "admin-passphrase".to_string(), + )], + vec![User::new( + UserId::new("ns1~admin".to_string())?, + "ns1-admin-passphrase".to_string(), + )], + ); + creds.store(AdministrativeSecretHandling::Plaintext)?; + + let creds_string = read_to_string(get_plaintext_credentials_file())?; + let fixture_string = read_to_string(config_file)?; + assert_eq!(creds_string, fixture_string); + Ok(()) +} + +/// Stores as systemd-creds encrypted TOML in the default location and compares to a fixture. +#[rstest] +fn store_and_load_systemd_creds() -> TestResult { + write_machine_id()?; + + let config_file = get_tmp_config(SIGNSTAR_ADMIN_CREDS_SIMPLE)?; + + // load AdminCredentials from plaintext fixture + let creds = + AdminCredentials::load_from_file(&config_file, AdministrativeSecretHandling::Plaintext)?; + + println!("Store systemd-creds encrypted"); + creds.store(AdministrativeSecretHandling::SystemdCreds)?; + + println!("Load systemd-creds encrypted"); + let read_creds = AdminCredentials::load(AdministrativeSecretHandling::SystemdCreds)?; + + println!("Write systemd-creds encrypted to plaintext"); + read_creds.store(AdministrativeSecretHandling::Plaintext)?; + + println!("Compare plaintext of roundtripped and fixture"); + let creds_string = read_to_string(get_plaintext_credentials_file())?; + let fixture_string = read_to_string(&config_file)?; + assert_eq!(creds_string, fixture_string); + Ok(()) +} + +#[rstest] +#[case(AdministrativeSecretHandling::Plaintext)] +#[case(AdministrativeSecretHandling::SystemdCreds)] +fn load_admin_creds_from_default_location( + #[case] handling: AdministrativeSecretHandling, +) -> TestResult { + write_machine_id()?; + create_credentials_dir()?; + let config_file = get_tmp_config(SIGNSTAR_ADMIN_CREDS_SIMPLE)?; + + match handling { + AdministrativeSecretHandling::Plaintext => { + // make sure a dummy plaintext file exists + copy(config_file, get_plaintext_credentials_file())?; + } + AdministrativeSecretHandling::SystemdCreds => { + // load AdminCredentials from plaintext fixture and store it systemd-creds encrypted + let creds = AdminCredentials::load_from_file( + &config_file, + AdministrativeSecretHandling::Plaintext, + )?; + creds.store(AdministrativeSecretHandling::SystemdCreds)?; + } + AdministrativeSecretHandling::ShamirsSecretSharing => { + unimplemented!("Shamir's Secret Sharing is not yet implemented!"); + } + } + AdminCredentials::load(handling)?; + + Ok(()) +} + +#[rstest] +#[case(AdministrativeSecretHandling::Plaintext)] +#[case(AdministrativeSecretHandling::SystemdCreds)] +fn store_admin_creds_to_default_location( + #[case] handling: AdministrativeSecretHandling, +) -> TestResult { + write_machine_id()?; + + let config_file = get_tmp_config(SIGNSTAR_ADMIN_CREDS_SIMPLE)?; + + // load credentials from plaintext fixture + let admin_creds = + AdminCredentials::load_from_file(&config_file, AdministrativeSecretHandling::Plaintext)?; + + // force remove any files that may be present in the persistent location + let _remove = std::fs::remove_dir_all(get_credentials_dir()); + admin_creds.store(handling)?; + + match handling { + AdministrativeSecretHandling::Plaintext => { + assert!(get_plaintext_credentials_file().exists()); + } + AdministrativeSecretHandling::SystemdCreds => { + assert!(get_systemd_creds_credentials_file().exists()); + } + AdministrativeSecretHandling::ShamirsSecretSharing => { + unimplemented!("Shamir's Secret Sharing is not yet implemented!"); + } + } + + Ok(()) +} + +#[rstest] +fn fail_to_load_on_missing_file() -> TestResult { + let credentials_file = get_plaintext_credentials_file(); + let error = AdminCredentials::load(AdministrativeSecretHandling::Plaintext) + .expect_err("Did not return an error!"); + if let signstar_config::Error::AdminSecretHandling( + signstar_config::admin_credentials::Error::CredsFileMissing { path }, + ) = error + { + assert_eq!(credentials_file, path) + } else { + panic!("Did not return an the correct error variant!") + } + + Ok(()) +} diff --git a/signstar-config/tests/config/mod.rs b/signstar-config/tests/config/mod.rs new file mode 100644 index 00000000..1ece26d1 --- /dev/null +++ b/signstar-config/tests/config/mod.rs @@ -0,0 +1,64 @@ +//! Integration tests for [`signstar_config::config`]. +use std::{fs::copy, path::PathBuf}; + +use rstest::rstest; +use signstar_common::config::{ + create_default_config_dir, + create_etc_override_config_dir, + create_run_override_config_dir, + create_usr_local_override_config_dir, + get_default_config_dir_path, + get_default_config_file_path, + get_etc_override_config_file_path, + get_etc_override_dir_path, + get_run_override_config_file_path, + get_run_override_dir_path, + get_usr_local_override_config_file_path, + get_usr_local_override_dir_path, +}; +use signstar_config::config::load_config; +use testresult::TestResult; + +use crate::utils::{SIGNSTAR_CONFIG_FULL, get_tmp_config}; + +#[rstest] +#[case(get_default_config_dir_path())] +#[case(get_etc_override_dir_path())] +#[case(get_run_override_dir_path())] +#[case(get_usr_local_override_dir_path())] +fn load_config_from_default_location(#[case] config_dir: PathBuf) -> TestResult { + println!("Config dir to test: {config_dir:?}"); + let config_file_fixture = get_tmp_config(SIGNSTAR_CONFIG_FULL)?; + // force remove any files that may be present in any of the configuration dirs + for dir in [ + get_default_config_dir_path(), + get_etc_override_dir_path(), + get_run_override_dir_path(), + get_usr_local_override_dir_path(), + ] { + let _remove = std::fs::remove_dir_all(dir); + } + + let config_file_path = if config_dir == get_usr_local_override_dir_path() { + create_usr_local_override_config_dir()?; + get_usr_local_override_config_file_path() + } else if config_dir == get_default_config_dir_path() { + create_default_config_dir()?; + get_default_config_file_path() + } else if config_dir == get_etc_override_dir_path() { + create_etc_override_config_dir()?; + get_etc_override_config_file_path() + } else if config_dir == get_run_override_dir_path() { + create_run_override_config_dir()?; + get_run_override_config_file_path() + } else { + unimplemented!("No test case for config dir: {config_dir:?}") + }; + + println!("Copying {config_file_fixture:?} to {config_file_path:?}"); + copy(config_file_fixture, &config_file_path)?; + + load_config()?; + + Ok(()) +} diff --git a/signstar-config/tests/fixtures/admin-creds-simple.toml b/signstar-config/tests/fixtures/admin-creds-simple.toml new file mode 100644 index 00000000..ec5796e0 --- /dev/null +++ b/signstar-config/tests/fixtures/admin-creds-simple.toml @@ -0,0 +1,11 @@ +iteration = 1 +backup_passphrase = "backup-passphrase" +unlock_passphrase = "unlock-passphrase" + +[[administrators]] +name = "admin" +passphrase = "admin-passphrase" + +[[namespace_administrators]] +name = "ns1~admin" +passphrase = "ns1-admin-passphrase" diff --git a/signstar-config/tests/fixtures/signstar-config-full.toml b/signstar-config/tests/fixtures/signstar-config-full.toml new file mode 100644 index 00000000..152ad3ef --- /dev/null +++ b/signstar-config/tests/fixtures/signstar-config-full.toml @@ -0,0 +1,129 @@ +iteration = 1 +admin_secret_handling = "shamirs-secret-sharing" +non_admin_secret_handling = "systemd-creds" + +[[connections]] +url = "https://localhost:8443/api/v1/" +tls_security = "Unsafe" + +[[users]] +nethsm_only_admin = "admin" + +[[users]] + +[users.system_nethsm_backup] +nethsm_user = "backup1" +ssh_authorized_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPkpXKiNhy39A3bZ1u19a5d4sFwYMBkWQyCbzgUfdKBm user@host" +system_user = "ssh-backup1" + +[[users]] + +[users.system_nethsm_metrics] +ssh_authorized_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPDgwGfIRBAsOUuDEZw/uJQZSwOYr4sg2DAZpcc7MfOj user@host" +system_user = "ssh-metrics1" + +[users.system_nethsm_metrics.nethsm_users] +metrics_user = "metrics1" +operator_users = ["operator1metrics1", "ns1~operator1metrics1"] + +[[users]] + +[users.system_nethsm_operator_signing] +nethsm_user = "operator1" +ssh_authorized_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAN54Gd1jMz+yNDjBRwX1SnOtWuUsVF64RJIeYJ8DI7b user@host" +system_user = "ssh-operator1" +tag = "tag1" + +[users.system_nethsm_operator_signing.nethsm_key_setup] +key_id = "key1" +key_type = "Curve25519" +key_mechanisms = ["EdDsaSignature"] +signature_type = "EdDsa" + +[users.system_nethsm_operator_signing.nethsm_key_setup.key_context.openpgp] +user_ids = ["Foobar McFooface <foobar@mcfooface.org>"] +version = "4" + +[[users]] + +[users.system_nethsm_operator_signing] +nethsm_user = "operator2" +ssh_authorized_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh9BTe81DC6A0YZALsq9dWcyl6xjjqlxWPwlExTFgBt user@host" +system_user = "ssh-operator2" +tag = "tag2" + +[users.system_nethsm_operator_signing.nethsm_key_setup] +key_id = "key2" +key_type = "Curve25519" +key_mechanisms = ["EdDsaSignature"] +signature_type = "EdDsa" + +[users.system_nethsm_operator_signing.nethsm_key_setup.key_context.openpgp] +user_ids = ["Foobar McFooface <foobar@mcfooface.org>"] +version = "4" + +[[users]] +nethsm_only_admin = "ns1~admin" + +[[users]] + +[users.system_nethsm_operator_signing] +nethsm_user = "ns1~operator1" +ssh_authorized_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILWqWyMCk5BdSl1c3KYoLEokKr7qNVPbI1IbBhgEBQj5 user@host" +system_user = "ns1-ssh-operator1" +tag = "tag1" + +[users.system_nethsm_operator_signing.nethsm_key_setup] +key_id = "key1" +key_type = "Curve25519" +key_mechanisms = ["EdDsaSignature"] +signature_type = "EdDsa" + +[users.system_nethsm_operator_signing.nethsm_key_setup.key_context.openpgp] +user_ids = ["Foobar McFooface <foobar@mcfooface.org>"] +version = "4" + +[[users]] + +[users.system_nethsm_operator_signing] +nethsm_user = "ns1~operator2" +ssh_authorized_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIYA+bfMBThUP5lKbMFEHiytmcCPhpkGrB/85n0mAN user@host" +system_user = "ns1-ssh-operator2" +tag = "tag2" + +[users.system_nethsm_operator_signing.nethsm_key_setup] +key_id = "key2" +key_type = "Curve25519" +key_mechanisms = ["EdDsaSignature"] +signature_type = "EdDsa" + +[users.system_nethsm_operator_signing.nethsm_key_setup.key_context.openpgp] +user_ids = ["Foobar McFooface <foobar@mcfooface.org>"] +version = "4" + +[[users]] + +[users.hermetic_system_nethsm_metrics] +system_user = "local-metrics1" + +[users.hermetic_system_nethsm_metrics.nethsm_users] +metrics_user = "metrics2" +operator_users = ["operator2metrics1"] + +[[users]] + +[users.system_only_share_download] +ssh_authorized_keys = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host"] +system_user = "ssh-share-down" + +[[users]] + +[users.system_only_share_upload] +ssh_authorized_keys = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host"] +system_user = "ssh-share-up" + +[[users]] + +[users.system_only_wireguard_download] +ssh_authorized_keys = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIClIXZdx0aDOPcIQA+6Qx68cwSUgGTL3TWzDSX3qUEOQ user@host"] +system_user = "ssh-wireguard-down" diff --git a/signstar-config/tests/fixtures/signstar-config-plaintext.toml b/signstar-config/tests/fixtures/signstar-config-plaintext.toml new file mode 100644 index 00000000..205e4f5f --- /dev/null +++ b/signstar-config/tests/fixtures/signstar-config-plaintext.toml @@ -0,0 +1,129 @@ +iteration = 1 +admin_secret_handling = "shamirs-secret-sharing" +non_admin_secret_handling = "plaintext" + +[[connections]] +url = "https://localhost:8443/api/v1/" +tls_security = "Unsafe" + +[[users]] +nethsm_only_admin = "admin" + +[[users]] + +[users.system_nethsm_backup] +nethsm_user = "backup1" +ssh_authorized_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPkpXKiNhy39A3bZ1u19a5d4sFwYMBkWQyCbzgUfdKBm user@host" +system_user = "ssh-backup1" + +[[users]] + +[users.system_nethsm_metrics] +ssh_authorized_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPDgwGfIRBAsOUuDEZw/uJQZSwOYr4sg2DAZpcc7MfOj user@host" +system_user = "ssh-metrics1" + +[users.system_nethsm_metrics.nethsm_users] +metrics_user = "metrics1" +operator_users = ["operator1metrics1", "ns1~operator1metrics1"] + +[[users]] + +[users.system_nethsm_operator_signing] +nethsm_user = "operator1" +ssh_authorized_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAN54Gd1jMz+yNDjBRwX1SnOtWuUsVF64RJIeYJ8DI7b user@host" +system_user = "ssh-operator1" +tag = "tag1" + +[users.system_nethsm_operator_signing.nethsm_key_setup] +key_id = "key1" +key_type = "Curve25519" +key_mechanisms = ["EdDsaSignature"] +signature_type = "EdDsa" + +[users.system_nethsm_operator_signing.nethsm_key_setup.key_context.openpgp] +user_ids = ["Foobar McFooface <foobar@mcfooface.org>"] +version = "4" + +[[users]] + +[users.system_nethsm_operator_signing] +nethsm_user = "operator2" +ssh_authorized_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh9BTe81DC6A0YZALsq9dWcyl6xjjqlxWPwlExTFgBt user@host" +system_user = "ssh-operator2" +tag = "tag2" + +[users.system_nethsm_operator_signing.nethsm_key_setup] +key_id = "key2" +key_type = "Curve25519" +key_mechanisms = ["EdDsaSignature"] +signature_type = "EdDsa" + +[users.system_nethsm_operator_signing.nethsm_key_setup.key_context.openpgp] +user_ids = ["Foobar McFooface <foobar@mcfooface.org>"] +version = "4" + +[[users]] +nethsm_only_admin = "ns1~admin" + +[[users]] + +[users.system_nethsm_operator_signing] +nethsm_user = "ns1~operator1" +ssh_authorized_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILWqWyMCk5BdSl1c3KYoLEokKr7qNVPbI1IbBhgEBQj5 user@host" +system_user = "ns1-ssh-operator1" +tag = "tag1" + +[users.system_nethsm_operator_signing.nethsm_key_setup] +key_id = "key1" +key_type = "Curve25519" +key_mechanisms = ["EdDsaSignature"] +signature_type = "EdDsa" + +[users.system_nethsm_operator_signing.nethsm_key_setup.key_context.openpgp] +user_ids = ["Foobar McFooface <foobar@mcfooface.org>"] +version = "4" + +[[users]] + +[users.system_nethsm_operator_signing] +nethsm_user = "ns1~operator2" +ssh_authorized_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIYA+bfMBThUP5lKbMFEHiytmcCPhpkGrB/85n0mAN user@host" +system_user = "ns1-ssh-operator2" +tag = "tag2" + +[users.system_nethsm_operator_signing.nethsm_key_setup] +key_id = "key2" +key_type = "Curve25519" +key_mechanisms = ["EdDsaSignature"] +signature_type = "EdDsa" + +[users.system_nethsm_operator_signing.nethsm_key_setup.key_context.openpgp] +user_ids = ["Foobar McFooface <foobar@mcfooface.org>"] +version = "4" + +[[users]] + +[users.hermetic_system_nethsm_metrics] +system_user = "local-metrics1" + +[users.hermetic_system_nethsm_metrics.nethsm_users] +metrics_user = "metrics2" +operator_users = ["operator2metrics1"] + +[[users]] + +[users.system_only_share_download] +ssh_authorized_keys = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host"] +system_user = "ssh-share-down" + +[[users]] + +[users.system_only_share_upload] +ssh_authorized_keys = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh96uFTnvX6P1ebbLxXFvy6sK7qFqlMHDOuJ0TmuXQQ user@host"] +system_user = "ssh-share-up" + +[[users]] + +[users.system_only_wireguard_download] +ssh_authorized_keys = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIClIXZdx0aDOPcIQA+6Qx68cwSUgGTL3TWzDSX3qUEOQ user@host"] +system_user = "ssh-wireguard-down" diff --git a/signstar-config/tests/integration.rs b/signstar-config/tests/integration.rs new file mode 100644 index 00000000..88fec84b --- /dev/null +++ b/signstar-config/tests/integration.rs @@ -0,0 +1,12 @@ +//! Integration tests for signstar-config modules. +#[cfg(feature = "_containerized-integration-test")] +pub mod admin_credentials; + +#[cfg(feature = "_containerized-integration-test")] +pub mod config; + +#[cfg(feature = "_containerized-integration-test")] +pub mod non_admin_credentials; + +#[cfg(feature = "_containerized-integration-test")] +mod utils; diff --git a/signstar-config/tests/non_admin_credentials/mod.rs b/signstar-config/tests/non_admin_credentials/mod.rs new file mode 100644 index 00000000..439e5eee --- /dev/null +++ b/signstar-config/tests/non_admin_credentials/mod.rs @@ -0,0 +1,383 @@ +//! Integration tests for [`signstar_config::non_admin_credentials`]. +use std::{ + fs::{File, Permissions, remove_file, set_permissions}, + io::Write, + os::unix::fs::{PermissionsExt, chown}, +}; + +use rstest::rstest; +use signstar_common::{ + common::{SECRET_FILE_MODE, get_data_home}, + config::get_default_config_file_path, + system_user::{get_systemd_creds_secret_file, get_user_secrets_dir}, +}; +use signstar_config::{error::ErrorExitCode, non_admin_credentials::SecretsWriter}; +use testresult::TestResult; + +use crate::utils::{ + SIGNSTAR_CONFIG_FULL, + SIGNSTAR_CONFIG_PLAINTEXT, + create_users, + list_files_in_dir, + prepare_system_with_config, + run_command_as_user, +}; + +const GET_CREDENTIALS_PAYLOAD: &str = "get-nethsm-credentials"; + +/// Loading credentials for unprivileged system users succeeds. +/// +/// Tests integration with `systemd-creds` encrypted secrets and plaintext secrets. +#[rstest] +#[case(SIGNSTAR_CONFIG_FULL)] +#[case(SIGNSTAR_CONFIG_PLAINTEXT)] +fn load_credentials_for_user(#[case] config_data: &[u8]) -> TestResult { + let (creds_mapping, _credentials_socket) = prepare_system_with_config(config_data)?; + // Get all system users + let system_users = creds_mapping + .iter() + .filter_map(|mapping| { + mapping + .get_user_mapping() + .get_system_user() + .map(|user| user.to_string()) + }) + .collect::<Vec<String>>(); + // Create all system users and their homes + create_users(system_users.as_slice())?; + // Create secrets for each system user and their backend users + for mapping in &creds_mapping { + mapping.create_secrets_dir()?; + mapping.create_non_administrative_secrets()?; + } + // List all files and directories in the data home. + list_files_in_dir(get_data_home())?; + + // Retrieve backend credentials for each system user + for mapping in &creds_mapping { + if let Some(system_user_id) = mapping.get_user_mapping().get_system_user() { + let (output, command_string) = + run_command_as_user(GET_CREDENTIALS_PAYLOAD, &[], system_user_id.as_ref())?; + + if !output.status.success() { + return Err(signstar_config::Error::CommandNonZero { + command: command_string, + exit_status: output.status, + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + } + .into()); + } + } + } + + Ok(()) +} + +/// Loading credentials for unprivileged system users fails on missing Signstar configuration. +#[rstest] +fn load_credentials_for_user_fails_on_missing_signstar_config() -> TestResult { + let (creds_mapping, _credentials_socket) = prepare_system_with_config(SIGNSTAR_CONFIG_FULL)?; + // Get all system users + let system_users = creds_mapping + .iter() + .filter_map(|mapping| { + mapping + .get_user_mapping() + .get_system_user() + .map(|user| user.to_string()) + }) + .collect::<Vec<String>>(); + // Create all system users and their homes + create_users(system_users.as_slice())?; + // Create secrets for each system user and their backend users + for mapping in &creds_mapping { + mapping.create_secrets_dir()?; + mapping.create_non_administrative_secrets()?; + } + // List all files and directories in the data home. + list_files_in_dir(get_data_home())?; + + // Remove signstar config from default location + remove_file(get_default_config_file_path())?; + + // Retrieve backend credentials for each system user + for mapping in &creds_mapping { + if let Some(system_user_id) = mapping.get_user_mapping().get_system_user() { + let (output, command_string) = + run_command_as_user(GET_CREDENTIALS_PAYLOAD, &[], system_user_id.as_ref())?; + if !output.status.success() { + let Some(exit_code) = output.status.code() else { + panic!("There should be an exit code for {command_string}!") + }; + assert_eq!( + exit_code, + std::convert::Into::<i32>::into(ErrorExitCode::ConfigConfigMissing) + ); + } else { + panic!("The command {command_string} should have failed!") + } + } + } + + Ok(()) +} + +/// Loading credentials for unprivileged system users fails on /run/systemd/io.systemd.Credentials +/// socket not being available. +#[rstest] +fn load_credentials_for_user_fails_on_credentials_socket() -> TestResult { + let (creds_mapping, mut credentials_socket) = prepare_system_with_config(SIGNSTAR_CONFIG_FULL)?; + // Get all system users + let system_users = creds_mapping + .iter() + .filter_map(|mapping| { + mapping + .get_user_mapping() + .get_system_user() + .map(|user| user.to_string()) + }) + .collect::<Vec<String>>(); + // Create all system users and their homes + create_users(system_users.as_slice())?; + // Create secrets for each system user and their backend users + for mapping in &creds_mapping { + mapping.create_secrets_dir()?; + mapping.create_non_administrative_secrets()?; + } + // List all files and directories in the data home. + list_files_in_dir(get_data_home())?; + + // Kill socket /run/systemd/io.systemd.Credentials and `systemd-creds` process to not leak the + // subprocess + credentials_socket.kill()?; + + // Retrieve backend credentials for each system user + for mapping in &creds_mapping { + if let Some(system_user_id) = mapping.get_user_mapping().get_system_user() { + let (output, command_string) = + run_command_as_user(GET_CREDENTIALS_PAYLOAD, &[], system_user_id.as_ref())?; + + if !output.status.success() { + let Some(exit_code) = output.status.code() else { + panic!("There should be an exit code for {command_string}!") + }; + assert_eq!( + exit_code, + std::convert::Into::<i32>::into( + ErrorExitCode::NonAdminCredentialsCredentialsLoading + ) + ); + } else { + panic!("The command {command_string} should have failed!") + } + } + } + + Ok(()) +} + +/// Loading credentials for unprivileged system users fails on missing secrets dir. +#[rstest] +fn load_credentials_for_user_fails_on_missing_secrets_dir() -> TestResult { + let (creds_mapping, _credentials_socket) = prepare_system_with_config(SIGNSTAR_CONFIG_FULL)?; + // Get all system users + let system_users = creds_mapping + .iter() + .filter_map(|mapping| { + mapping + .get_user_mapping() + .get_system_user() + .map(|user| user.to_string()) + }) + .collect::<Vec<String>>(); + // Create all system users and their homes + create_users(system_users.as_slice())?; + // List all files and directories in the data home. + list_files_in_dir(get_data_home())?; + + // Retrieve backend credentials for each system user + for mapping in &creds_mapping { + if let Some(system_user_id) = mapping.get_user_mapping().get_system_user() { + let (output, command_string) = + run_command_as_user(GET_CREDENTIALS_PAYLOAD, &[], system_user_id.as_ref())?; + + if !output.status.success() { + let Some(exit_code) = output.status.code() else { + panic!("There should be an exit code for {command_string}!") + }; + assert_eq!( + exit_code, + std::convert::Into::<i32>::into( + ErrorExitCode::NonAdminCredentialsCredentialsLoading + ) + ); + } else { + panic!("The command {command_string} should have failed!") + } + } + } + + Ok(()) +} + +/// Loading credentials for unprivileged system users fails on missing secrets file. +#[rstest] +fn load_credentials_for_user_fails_on_missing_secrets_file() -> TestResult { + let (creds_mapping, _credentials_socket) = prepare_system_with_config(SIGNSTAR_CONFIG_FULL)?; + // Get all system users + let system_users = creds_mapping + .iter() + .filter_map(|mapping| { + mapping + .get_user_mapping() + .get_system_user() + .map(|user| user.to_string()) + }) + .collect::<Vec<String>>(); + // Create all system users and their homes + create_users(system_users.as_slice())?; + // Only create secret dir for each system user. + for mapping in &creds_mapping { + mapping.create_secrets_dir()?; + } + // List all files and directories in the data home. + list_files_in_dir(get_data_home())?; + + // Retrieve backend credentials for each system user + for mapping in &creds_mapping { + if let Some(system_user_id) = mapping.get_user_mapping().get_system_user() { + let (output, command_string) = + run_command_as_user(GET_CREDENTIALS_PAYLOAD, &[], system_user_id.as_ref())?; + + if !output.status.success() { + let Some(exit_code) = output.status.code() else { + panic!("There should be an exit code for {command_string}!") + }; + assert_eq!( + exit_code, + std::convert::Into::<i32>::into( + ErrorExitCode::NonAdminCredentialsCredentialsLoading + ) + ); + } else { + panic!("The command {command_string} should have failed!") + } + } + } + + Ok(()) +} + +/// Loading credentials for unprivileged system users fails on inaccessible secrets file. +#[rstest] +fn load_credentials_for_user_fails_on_inaccessible_secrets_file() -> TestResult { + let (creds_mapping, _credentials_socket) = prepare_system_with_config(SIGNSTAR_CONFIG_FULL)?; + // Get all system users + let system_users = creds_mapping + .iter() + .filter_map(|mapping| { + mapping + .get_user_mapping() + .get_system_user() + .map(|user| user.to_string()) + }) + .collect::<Vec<String>>(); + // Create all system users and their homes + create_users(system_users.as_slice())?; + // Create secrets file for each system user (and then render it inaccessible). + for mapping in &creds_mapping { + mapping.create_secrets_dir()?; + mapping.create_non_administrative_secrets()?; + if let Some(user) = mapping.get_user_mapping().get_system_user() { + let secrets_dir = get_user_secrets_dir(user.as_ref()); + chown(secrets_dir.as_path(), Some(0), Some(0))?; + set_permissions( + secrets_dir.as_path(), + Permissions::from_mode(SECRET_FILE_MODE), + )?; + } + } + // List all files and directories in the data home. + list_files_in_dir(get_data_home())?; + + // Retrieve backend credentials for each system user + for mapping in &creds_mapping { + if let Some(system_user_id) = mapping.get_user_mapping().get_system_user() { + let (output, command_string) = + run_command_as_user(GET_CREDENTIALS_PAYLOAD, &[], system_user_id.as_ref())?; + + if !output.status.success() { + let Some(exit_code) = output.status.code() else { + panic!("There should be an exit code for {command_string}!") + }; + assert_eq!( + exit_code, + std::convert::Into::<i32>::into( + ErrorExitCode::NonAdminCredentialsCredentialsLoading + ) + ); + } else { + panic!("The command {command_string} should have failed!") + } + } + } + + Ok(()) +} + +/// Loading credentials for unprivileged system users fails on garbage "encrypted" secrets file. +#[rstest] +fn load_credentials_for_user_fails_on_garbage_secrets_file() -> TestResult { + let (creds_mapping, _credentials_socket) = prepare_system_with_config(SIGNSTAR_CONFIG_FULL)?; + // Get all system users + let system_users = creds_mapping + .iter() + .filter_map(|mapping| { + mapping + .get_user_mapping() + .get_system_user() + .map(|user| user.to_string()) + }) + .collect::<Vec<String>>(); + // Create all system users and their homes + create_users(system_users.as_slice())?; + // Create secrets file for each system user (and then write garbage to them). + for mapping in &creds_mapping { + mapping.create_secrets_dir()?; + mapping.create_non_administrative_secrets()?; + if let Some(user) = mapping.get_user_mapping().get_system_user() { + for backend_user in mapping.get_user_mapping().get_nethsm_users() { + let secrets_file = + get_systemd_creds_secret_file(user.as_ref(), &backend_user.to_string()); + let mut output = File::create(secrets_file.as_path())?; + write!(output, "GARBAGE")?; + } + } + } + // List all files and directories in the data home. + list_files_in_dir(get_data_home())?; + + // Retrieve backend credentials for each system user + for mapping in &creds_mapping { + if let Some(system_user_id) = mapping.get_user_mapping().get_system_user() { + let (output, command_string) = + run_command_as_user(GET_CREDENTIALS_PAYLOAD, &[], system_user_id.as_ref())?; + + if !output.status.success() { + let Some(exit_code) = output.status.code() else { + panic!("There should be an exit code for {command_string}!") + }; + assert_eq!( + exit_code, + std::convert::Into::<i32>::into( + ErrorExitCode::NonAdminCredentialsCredentialsLoading + ) + ); + } else { + panic!("The command {command_string} should have failed!") + } + } + } + + Ok(()) +} diff --git a/signstar-config/tests/utils/mod.rs b/signstar-config/tests/utils/mod.rs new file mode 100644 index 00000000..b8bf4cc2 --- /dev/null +++ b/signstar-config/tests/utils/mod.rs @@ -0,0 +1,424 @@ +//! Utilities used for test setups. +use std::{ + fs::{Permissions, create_dir_all, read_dir, read_to_string, set_permissions, write}, + os::{linux::fs::MetadataExt, unix::fs::PermissionsExt}, + path::{Path, PathBuf}, + process::{Child, Command, Output}, + thread, + time, +}; + +use nethsm_config::{ + ConfigInteractivity, + ConfigSettings, + ExtendedUserMapping, + HermeticParallelConfig, +}; +use signstar_common::{config::get_default_config_file_path, system_user::get_home_base_dir_path}; +use tempfile::NamedTempFile; +use testresult::TestResult; +use which::which; + +pub const SIGNSTAR_CONFIG_FULL: &[u8] = include_bytes!("../fixtures/signstar-config-full.toml"); +pub const SIGNSTAR_CONFIG_PLAINTEXT: &[u8] = + include_bytes!("../fixtures/signstar-config-plaintext.toml"); +pub const SIGNSTAR_ADMIN_CREDS_SIMPLE: &[u8] = + include_bytes!("../fixtures/admin-creds-simple.toml"); + +/// An error that may occur when using test utils. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Applying permissions to a file failed. + #[error("Unable to apply permissions to {path}:\n{source}")] + ApplyPermissions { + path: PathBuf, + source: std::io::Error, + }, + + /// A directory can not be created. + #[error("Unable to create directory {dir}:\n{source}")] + CreateDirectory { + dir: PathBuf, + source: std::io::Error, + }, + + /// The socket for io.systemd.Credentials could not be started. + #[error("Unable to start socket for io.systemd.Credentials:\n{0}")] + CredentialsSocket(#[source] std::io::Error), + + /// An I/O error. + #[error("I/O error while {context}:\n{source}")] + Io { + context: &'static str, + source: std::io::Error, + }, + + /// An I/O error with a specific path. + #[error("I/O error at {path} while {context}:\n{source}")] + IoPath { + path: PathBuf, + context: &'static str, + source: std::io::Error, + }, + + /// A signstar-config error. + #[error("Signstar-config error:\n{0}")] + SignstarConfig(#[from] signstar_config::Error), + + /// A timeout has been reached. + #[error("Timeout of {timeout}ms reached while {context}")] + Timeout { timeout: u64, context: String }, + + /// A temporary file cannot be created. + #[error("A temporary file for {purpose} cannot be created:\n{source}")] + Tmpfile { + purpose: &'static str, + source: std::io::Error, + }, +} + +/// Recursively lists files, their permissions and ownership. +pub fn list_files_in_dir(path: impl AsRef<Path>) -> Result<(), Error> { + let path = path.as_ref(); + let entries = read_dir(path).map_err(|source| Error::IoPath { + path: path.to_path_buf(), + context: "reading its children", + source, + })?; + + for entry in entries { + let entry = entry.map_err(|source| Error::IoPath { + path: path.to_path_buf(), + context: "getting an entry below it", + source, + })?; + let meta = entry.metadata().map_err(|source| Error::IoPath { + path: path.to_path_buf(), + context: "getting metadata", + source, + })?; + + println!( + "{} {}/{} {entry:?}", + meta.permissions().mode(), + meta.st_uid(), + meta.st_gid() + ); + + if meta.is_dir() { + list_files_in_dir(entry.path())?; + } + } + + Ok(()) +} + +/// Returns a configuration file with `data` as contents in a temporary location. +pub fn get_tmp_config(data: &[u8]) -> Result<NamedTempFile, Error> { + let tmp_config = NamedTempFile::new().map_err(|source| Error::Tmpfile { + purpose: "full signstar configuration", + source, + })?; + write(&tmp_config, data).map_err(|source| Error::Io { + context: "writing full signstar configuration to temporary file", + source, + })?; + Ok(tmp_config) +} + +/// Writes a dummy /etc/machine-id, which is required for systemd-creds. +pub fn write_machine_id() -> Result<(), Error> { + println!("Write dummy /etc/machine-id, required for systemd-creds"); + let machine_id = PathBuf::from("/etc/machine-id"); + std::fs::write(&machine_id, "d3b07384d113edec49eaa6238ad5ff00").map_err(|source| { + Error::IoPath { + path: machine_id.to_path_buf(), + context: "writing machine-id", + source, + } + })?; + + let metadata = machine_id.metadata().map_err(|source| Error::IoPath { + path: machine_id, + context: "getting metadata of file", + source, + })?; + println!( + "/etc/machine-id\nmode: {}\nuid: {}\ngid: {}", + metadata.permissions().mode(), + metadata.st_uid(), + metadata.st_gid() + ); + Ok(()) +} + +/// A background process. +/// +/// Tracks a [`Child`] which represents a process that runs in the background. +/// The background process is automatically killed upon dropping the [`BackgroundProcess`]. +pub struct BackgroundProcess { + child: Child, + command: String, +} + +impl BackgroundProcess { + /// Kills the tracked background process. + /// + /// # Errors + /// + /// Returns an error if the process could not be killed. + pub fn kill(&mut self) -> Result<(), Error> { + self.child.kill().map_err(|source| Error::Io { + context: "killing process", + source, + }) + } +} + +impl Drop for BackgroundProcess { + /// Kills the tracked background process when destructing the [`BackgroundProcess`]. + fn drop(&mut self) { + if let Err(error) = self.child.kill() { + eprintln!( + "Unable to kill background process of command {}:\n{error}", + self.command + ) + } + } +} + +/// Starts a socket for `io.systemd.Credentials` using `systemd-socket-activate`. +/// +/// Sets the file mode of the socket to `666` so that all users on the system have access. +/// +/// # Errors +/// +/// Returns an error if +/// +/// - `systemd-socket-activate` is unable to start the required socket, +/// - one or more files in `/run/systemd` can not be listed, +/// - applying of permissions on `/run/systemd/io.systemd.Credentials` fails, +/// - or the socket has not been made available within 10000ms. +pub fn start_credentials_socket() -> Result<BackgroundProcess, Error> { + let systemd_run_path = PathBuf::from("/run/systemd"); + let socket_path = PathBuf::from("/run/systemd/io.systemd.Credentials"); + create_dir_all(&systemd_run_path).map_err(|source| Error::CreateDirectory { + dir: systemd_run_path, + source, + })?; + + // Run systemd-socket-activate to provide /run/systemd/io.systemd.Credentials + let command = "systemd-socket-activate"; + let systemd_socket_activate = which(command).map_err(|source| { + Error::SignstarConfig( + signstar_config::utils::Error::ExecutableNotFound { + command: command.to_string(), + source, + } + .into(), + ) + })?; + let mut command = Command::new(systemd_socket_activate); + let command = command.args([ + "--listen", + "/run/systemd/io.systemd.Credentials", + "--accept", + "--fdname=varlink", + "systemd-creds", + ]); + let child = command.spawn().map_err(Error::CredentialsSocket)?; + + // Set the socket to be writable by all, once it's available. + let timeout = 10000; + let step = 100; + let mut elapsed = 0; + let mut permissions_set = false; + while elapsed < timeout { + if socket_path.exists() { + println!("Found {socket_path:?}"); + set_permissions(socket_path.as_path(), Permissions::from_mode(0o666)).map_err( + |source| Error::ApplyPermissions { + path: socket_path.to_path_buf(), + source, + }, + )?; + permissions_set = true; + break; + } else { + thread::sleep(time::Duration::from_millis(step)); + elapsed += step; + } + } + if !permissions_set { + return Err(Error::Timeout { + timeout, + context: format!("waiting for {socket_path:?}"), + }); + } + + Ok(BackgroundProcess { + child, + command: format!("{command:?}"), + }) +} + +/// Runs a `command` as a specific `user` and returns its [`Output`] and the command's [`String`] +/// representation. +/// +/// Uses `su` to run the the command as a specific user. +pub fn run_command_as_user( + user_command: &str, + user_command_args: &[&str], + user: &str, +) -> Result<(Output, String), Error> { + /// Returns the path to a `command`. + /// + /// # Errors + /// + /// Returns an error if `command` can not be found in PATH. + fn get_command(command: &str) -> Result<PathBuf, Error> { + which(command).map_err(|source| { + Error::SignstarConfig(signstar_config::Error::Utils( + signstar_config::utils::Error::ExecutableNotFound { + command: command.to_string(), + source, + }, + )) + }) + } + + let priv_command = get_command("runuser")?; + eprintln!("Checking availability of command {user_command}"); + get_command(user_command)?; + + // Run command as user + let mut command = Command::new(priv_command); + let command = command + .arg(format!( + "--command='{user_command}{}'", + if !user_command_args.is_empty() { + format!(" {}", user_command_args.join(" ")) + } else { + "".to_string() + } + )) + .arg("--group") + .arg(user) + .arg("--login") + .arg(user); + + let command_string = format!("{command:?}"); + eprintln!("Running command {command_string}"); + let command_output = command.output().map_err(|source| { + Error::SignstarConfig(signstar_config::Error::CommandExec { + command: command_string.clone(), + source, + }) + })?; + eprintln!( + "stdout:\n{}", + String::from_utf8_lossy(&command_output.stdout).into_owned() + ); + eprintln!( + "stderr:\n{}", + String::from_utf8_lossy(&command_output.stderr).into_owned() + ); + + Ok((command_output, command_string)) +} + +/// Creates a set of users. +pub fn create_users(users: &[String]) -> TestResult { + println!("Creating users: {:?}", users); + for user in users { + println!("Creating user: {}", user); + + // create the user and its home + let mut command = Command::new("useradd"); + let command = command + .arg("--base-dir") + .arg(get_home_base_dir_path()) + .arg("--create-home") + .arg("--user-group") + .arg("--shell") + .arg("/usr/bin/bash") + .arg(user); + + let command_output = command.output()?; + if !command_output.status.success() { + return Err(signstar_config::Error::CommandNonZero { + command: format!("{command:?}"), + exit_status: command_output.status, + stderr: String::from_utf8_lossy(&command_output.stderr).into_owned(), + } + .into()); + } + + // unlock the user + let mut command = Command::new("usermod"); + command.arg("--unlock"); + command.arg(user); + let command_output = command.output()?; + if !command_output.status.success() { + return Err(signstar_config::Error::CommandNonZero { + command: format!("{command:?}"), + exit_status: command_output.status, + stderr: String::from_utf8_lossy(&command_output.stderr).into_owned(), + } + .into()); + } + } + + println!("/etc/passwd:\n{}", read_to_string("/etc/passwd")?); + + Ok(()) +} + +/// Prepares a system for use with Signstar. +/// +/// Prepares the following: +/// +/// - Creates `/etc/machine-id`, which is needed for `systemd-creds` to function. +/// - Reads Signstar configuration from data and writes to default config location. +/// - Creates `/run/systemd/io.systemd.Credentials` by running `systemd-socket-activate` in the +/// background +/// +/// Returns the list of [`ExtendedUserMapping`]s derived from the Signstar configuration and the +/// [`Child`] of the `systemd-socket-activate` process that created +/// `/run/systemd/io.systemd.Credentials`. +pub fn prepare_system_with_config( + config_data: &[u8], +) -> Result<(Vec<ExtendedUserMapping>, BackgroundProcess), Error> { + write_machine_id()?; + + // Read Signstar config from `config_data` + let config = HermeticParallelConfig::new_from_file( + ConfigSettings::new( + "my_app".to_string(), + ConfigInteractivity::NonInteractive, + None, + ), + Some(get_tmp_config(config_data)?.path()), + ) + .map_err(|source| { + Error::SignstarConfig(signstar_config::Error::Config( + signstar_config::config::Error::NetHsmConfig(source), + )) + })?; + + // Store Signstar config in default location + config + .store(Some(&get_default_config_file_path())) + .map_err(|source| { + Error::SignstarConfig(signstar_config::Error::Config( + signstar_config::config::Error::NetHsmConfig(source), + )) + })?; + + // Get extended user mappings for all users. + let creds_mapping: Vec<ExtendedUserMapping> = config.into(); + + // Return extended user mappings contained in Signstar config and the background process + // providing /run/systemd/io.systemd.Credentials + Ok((creds_mapping, start_credentials_socket()?)) +} -- GitLab From c33fe4c99e0d8d602d05257346b844a3b67e100d Mon Sep 17 00:00:00 2001 From: David Runge <dvzrv@archlinux.org> Date: Mon, 17 Mar 2025 15:39:18 +0100 Subject: [PATCH 6/6] ci(GitLab): Add integration test target for containerized tests Signed-off-by: David Runge <dvzrv@archlinux.org> --- .gitlab-ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b29e828f..feedd393 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -104,6 +104,17 @@ integration-test: tags: - vm +containerized-integration-test: + extends: .default + script: + - useradd -m testuser + - loginctl enable-linger testuser + - chown -R testuser:testuser . + - WORKDIR="$(pwd)" runuser -w WORKDIR -l testuser -c 'env && cd "$WORKDIR" && just install-rust-dev-tools && just build-container-integration-test-image && just containerized-integration-tests' + stage: test + tags: + - vm + test-readmes: extends: .default script: -- GitLab