Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
node: Support systemd credentials for secret key and passphrase
Merged lorenz opened 6 months ago

Secret Key

While it is possible to use systemd credentials via

LoadCredential=xyz.radicle.node.secret:…
ExecStart=radicle-node … --secret "${CREDENTIALS_DIRECTORY}/xyz.radicle.node.secret"

Make usage more convenient and directly support passing the secret key via a systemd credential. The ID of the credential must be

xyz.radicle.node.secret

and is not user-configurable.

A systemd service unit file might contain:

LoadCredential=xyz.radicle.node.secret:…
ExecStart=radicle-node …

This requires just a bit of plumbing in radicle-node.

The preference order for the path of the secret key is:

  1. The command line argument --secret.
  2. The systemd credential.
  3. The configuration file.
  4. The default location to preserve backward compatibility.

The reason to prefer the systemd credential over the configuration file is that it uses a mechanism that is influenced by the environment of the process, which is deemed “closer at runtime” or “more dynamic” than a configuration file. Ad-hoc overrides are still possible via the commandline argument.

Passphrase

While it is possible to pass the passphrase via the environment, e.g. EnvironmentFile=<path to file that contains "RAD_PASSPHRASE=…"> this is less secure than passing it via a file, because the environment is inherited down the process tree.

Thus, allow using a systemd credential. The ID of the credential must be

xyz.radicle.node.passphrase

and is not user-configurable.

Passing the passphrase via file is now possible with LoadCredential=xyz.radicle.node.passphrase:<path to file that contains passphrase>

This requires just a bit of plumbing in radicle-node.

Because this mechanism is more secure than using the environment variable RAD_PASSPHRASE, it takes priority. That is, if both the systemd credential is available, and the environment variable RAD_PASSPHRASE is set, the former is preferred.

Heads-up:

  1. The contents of the file must be valid UTF-8 (see documentation of std::fs::read_to_string). Assuming that the passphrase is at some point chosen by the user and typed on a keyboard, this does not seem like a severe restriction.
  2. The contents of the file are not processed otherwise, i.e. line breaks (notably at the end of the file) are not stripped.

The related issue/8bd040e9de05e7fc27e373ebc1649ff4ad930e7a asked for a very similar feature: Passing the passphrase via a file named by the value of the of the environment variable RAD_PASSPHRASE_FILE. It was also briefly discussed at https://radicle.zulipchat.com/#narrow/channel/369277-heartwood/topic/.60RAD_PASSPHRASE_FILE.60/with/529104447.

Plumbing in radicle-systemd

Add mod credential with fn path which implements a simple lookup of systemd credentials. See https://systemd.io/CREDENTIALS/

4 files changed +95 -3 8c1073b9 ac572e64
modified CHANGELOG.md
@@ -15,6 +15,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- `rad issue` now uses `clap` to parse its command-line arguments.
   This affects error reporting as well as help output.
+
- `radicle-node` now supports systemd Credentials (refer to
+
  <https://systemd.io/CREDENTIALS> for more information) to load:
+
    1. The secret key, in addition to the commandline argument
+
       `--secret` (higher priority than the credential) and the
+
       configuration file (lower priority than the credential).
+
       The identifier of the credential is "xyz.radicle.node.secret".
+
    2. The optional passphrase for the secret key, in addition to the
+
       environment variable `RAD_PASSPHRASE` (lower priority than the
+
       credential).
+
       The identifier of the credential is "xyz.radicle.node.passphrase".

## Fixed Bugs

modified crates/radicle-node/src/main.rs
@@ -235,10 +235,44 @@ fn execute(options: Options) -> Result<(), ExecutionError> {
    log::info!(target: "node", "Version {} ({})", env!("RADICLE_VERSION"), env!("GIT_HEAD"));
    log::info!(target: "node", "Unlocking node keystore..");

-
    let passphrase = profile::env::passphrase();
+
    let passphrase = None;

-
    let secret_path = options
-
        .secret
+
    #[cfg(all(feature = "systemd", target_os = "linux"))]
+
    let passphrase = passphrase.or_else(|| {
+
        const ID: &str = "xyz.radicle.node.passphrase";
+
        match radicle_systemd::credential::path(ID) {
+
            Err(err) => {
+
                log::warn!(target: "node", "Failed to obtain path of the passphrase file via systemd credential with '{ID}': {err}");
+
                None
+
            },
+
            Ok(Some(ref path)) => match std::fs::read_to_string(path) {
+
                Ok(passphrase) => Some(passphrase.into()),
+
                Err(err) => {
+
                    log::warn!(target: "node", "Failed to read passphrase from '{}': {err}", path.display());
+
                    None
+
                }
+
            }
+
            Ok(None) => None,
+
        }
+
    });
+

+
    let passphrase = passphrase.or_else(profile::env::passphrase);
+

+
    let secret_path = options.secret;
+

+
    #[cfg(all(feature = "systemd", target_os = "linux"))]
+
    let secret_path = secret_path.or_else(|| {
+
        const ID: &str = "xyz.radicle.node.secret";
+
        match radicle_systemd::credential::path(ID) {
+
            Err(err) => {
+
                log::warn!(target: "node", "Failed to obtain path of the secret key via systemd credential with ID '{ID}': {err}");
+
                None
+
            },
+
            Ok(path) => path
+
        }
+
    });
+

+
    let secret_path = secret_path
        .or_else(|| config.node.secret.clone())
        .unwrap_or_else(|| home.keys().join("radicle"));

added crates/radicle-systemd/src/credential.rs
@@ -0,0 +1,46 @@
+
use std::env::{var, VarError::*};
+
use std::ffi::OsString;
+
use std::fmt;
+
use std::path::{is_separator, PathBuf};
+

+
const CREDENTIALS_DIRECTORY: &str = "CREDENTIALS_DIRECTORY";
+

+
/// Takes a systemd credential ID. If the environment variable
+
/// `CREDENTIALS_DIRECTORY` is set and valid Unicode, and the file corresponding
+
/// to the credential exists, returns the path of the file corresponding to the
+
/// credential.
+
///
+
/// Absence of the environment variable and inexistence of the file are handled
+
/// gracefully returning `Ok(None)`.
+
pub fn path(id: &str) -> Result<Option<PathBuf>, PathError> {
+
    use PathError::*;
+

+
    if id.contains(is_separator) {
+
        return Err(InvalidCredentialId { id: id.to_owned() });
+
    }
+

+
    let credential = match var(CREDENTIALS_DIRECTORY) {
+
        Err(NotUnicode(os)) => return Err(EnvVarNotUnicode { os }),
+
        Err(NotPresent) => return Ok(None),
+
        Ok(env) => PathBuf::from(env).join(id),
+
    };
+

+
    Ok(credential.exists().then_some(credential))
+
}
+

+
/// The error returned by [`path`].
+
#[derive(Debug)]
+
pub enum PathError {
+
    InvalidCredentialId { id: String },
+
    EnvVarNotUnicode { os: OsString },
+
}
+

+
impl fmt::Display for PathError {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        use PathError::*;
+
        match self {
+
		InvalidCredentialId { id } => write!(f, "The systemd credential ID '{id}' is invalid."),
+
		EnvVarNotUnicode { os } => write!(f, "The value of environment variable '{CREDENTIALS_DIRECTORY}' is not valid Unicode (it lossily translates to '{}').", os.to_string_lossy()),
+
	}
+
    }
+
}
modified crates/radicle-systemd/src/lib.rs
@@ -5,3 +5,5 @@ pub mod journal;

#[cfg(all(feature = "listen", unix))]
pub mod listen;
+

+
pub mod credential;