commit f59f7ecac6a667f1906ed9da913e7fd53ef45f0d
parent 4e0b99b96800f255a0db42daef06c63a2062750e
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Apr 2026 22:00:43 +0000
runtime: add encrypted local service secret files
Diffstat:
8 files changed, 358 insertions(+), 9 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -2598,9 +2598,13 @@ name = "radroots-runtime"
version = "0.1.0-alpha.1"
dependencies = [
"anyhow",
+ "chacha20poly1305",
"clap",
"config",
+ "getrandom 0.2.17",
"radroots-log",
+ "radroots-protected-store",
+ "radroots-secret-vault",
"serde",
"serde_json",
"tempfile",
@@ -2608,6 +2612,7 @@ dependencies = [
"tokio",
"toml",
"tracing",
+ "zeroize",
]
[[package]]
diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml
@@ -19,13 +19,18 @@ cli = ["dep:clap"]
[dependencies]
anyhow = { workspace = true }
+chacha20poly1305 = { workspace = true }
clap = { workspace = true, features = ["derive", "env"], optional = true }
config = { workspace = true }
+getrandom = { workspace = true }
radroots-log = { workspace = true, features = ["std"] }
+radroots-protected-store = { workspace = true, features = ["std"] }
+radroots-secret-vault = { workspace = true, features = ["std"] }
serde = { workspace = true }
serde_json = { workspace = true }
+tempfile = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] }
toml = { workspace = true }
tracing = { workspace = true }
-tempfile = { workspace = true }
+zeroize = { workspace = true }
diff --git a/crates/runtime/src/error.rs b/crates/runtime/src/error.rs
@@ -24,6 +24,42 @@ pub enum RuntimeTracingError {
}
#[derive(Debug, Error)]
+pub enum RuntimeProtectedFileError {
+ #[error("failed to create directory {path}: {source}")]
+ CreateDir {
+ path: std::path::PathBuf,
+ #[source]
+ source: std::io::Error,
+ },
+ #[error("protected secret file io error at {path}: {source}")]
+ Io {
+ path: std::path::PathBuf,
+ #[source]
+ source: std::io::Error,
+ },
+ #[error("failed to seal protected secret file {path}: {message}")]
+ Seal {
+ path: std::path::PathBuf,
+ message: String,
+ },
+ #[error("failed to decode protected secret file {path}: {message}")]
+ Decode {
+ path: std::path::PathBuf,
+ message: String,
+ },
+ #[error("failed to open protected secret file {path}: {message}")]
+ Open {
+ path: std::path::PathBuf,
+ message: String,
+ },
+ #[error("failed to update secret permissions for {path}: {message}")]
+ Permissions {
+ path: std::path::PathBuf,
+ message: String,
+ },
+}
+
+#[derive(Debug, Error)]
pub enum RuntimeError {
#[error(transparent)]
Config(#[from] RuntimeConfigError),
@@ -38,7 +74,7 @@ pub enum RuntimeError {
#[cfg(test)]
mod tests {
- use super::{RuntimeConfigError, RuntimeError, RuntimeTracingError};
+ use super::{RuntimeConfigError, RuntimeError, RuntimeProtectedFileError, RuntimeTracingError};
use std::error::Error as _;
use std::path::PathBuf;
@@ -70,4 +106,15 @@ mod tests {
assert!(runtime_from_tracing.to_string().contains("log-failure"));
assert!(runtime_from_tracing.source().is_none());
}
+
+ #[test]
+ fn protected_file_error_displays_path_and_message() {
+ let err = RuntimeProtectedFileError::Open {
+ path: PathBuf::from("identity.secret.json"),
+ message: "missing wrapping key".to_string(),
+ };
+ let display = err.to_string();
+ assert!(display.contains("identity.secret.json"));
+ assert!(display.contains("missing wrapping key"));
+ }
}
diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs
@@ -4,6 +4,7 @@ pub mod cli;
pub mod config;
pub mod error;
pub mod json;
+pub mod secret_file;
pub mod service;
pub mod signals;
pub mod tracing;
@@ -21,9 +22,12 @@ pub use config::{
#[cfg(feature = "cli")]
pub use error::RuntimeCliError;
+pub use error::RuntimeProtectedFileError;
pub use error::{RuntimeConfigError, RuntimeError, RuntimeTracingError};
pub use json::{JsonFile, JsonWriteOptions, RuntimeJsonError};
+pub use secret_file::{local_wrapping_key_path, open_local_secret_file, seal_local_secret_file};
+pub use service::DEFAULT_SERVICE_IDENTITY_PATH;
pub use service::RadrootsNostrServiceConfig;
#[cfg(feature = "cli")]
pub use service::RadrootsServiceCliArgs;
diff --git a/crates/runtime/src/secret_file.rs b/crates/runtime/src/secret_file.rs
@@ -0,0 +1,284 @@
+use std::ffi::OsString;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use chacha20poly1305::aead::{Aead, KeyInit, Payload};
+use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
+use getrandom::getrandom;
+use radroots_protected_store::{
+ RADROOTS_PROTECTED_STORE_KEY_LENGTH, RADROOTS_PROTECTED_STORE_NONCE_LENGTH,
+ RadrootsProtectedStoreEnvelope,
+};
+use radroots_secret_vault::{RadrootsSecretKeyWrapping, RadrootsSecretVaultAccessError};
+use zeroize::Zeroize;
+
+use crate::error::RuntimeProtectedFileError;
+
+const LOCAL_WRAPPING_KEY_SUFFIX: &str = ".key";
+const WRAPPED_KEY_VERSION: u8 = 1;
+
+#[derive(Debug, Clone)]
+struct LocalWrappedKeySource {
+ key_path: PathBuf,
+}
+
+impl LocalWrappedKeySource {
+ fn new(path: &Path) -> Self {
+ Self {
+ key_path: local_wrapping_key_path(path),
+ }
+ }
+
+ fn load_or_create_wrapping_key(
+ &self,
+ ) -> Result<[u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH], RadrootsSecretVaultAccessError> {
+ if self.key_path.exists() {
+ return self.load_wrapping_key();
+ }
+
+ if let Some(parent) = self.key_path.parent()
+ && !parent.as_os_str().is_empty()
+ {
+ fs::create_dir_all(parent).map_err(io_backend_error)?;
+ }
+
+ let mut key = [0_u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH];
+ getrandom(&mut key)
+ .map_err(|_| RadrootsSecretVaultAccessError::Backend("entropy unavailable".into()))?;
+ fs::write(&self.key_path, key.as_slice()).map_err(io_backend_error)?;
+ set_secret_permissions(&self.key_path)?;
+ Ok(key)
+ }
+
+ fn load_wrapping_key(
+ &self,
+ ) -> Result<[u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH], RadrootsSecretVaultAccessError> {
+ let raw = fs::read(&self.key_path).map_err(io_backend_error)?;
+ if raw.len() != RADROOTS_PROTECTED_STORE_KEY_LENGTH {
+ return Err(RadrootsSecretVaultAccessError::Backend(format!(
+ "wrapping key {} has invalid length {}",
+ self.key_path.display(),
+ raw.len()
+ )));
+ }
+
+ let mut key = [0_u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH];
+ key.copy_from_slice(&raw);
+ Ok(key)
+ }
+}
+
+impl RadrootsSecretKeyWrapping for LocalWrappedKeySource {
+ type Error = RadrootsSecretVaultAccessError;
+
+ fn wrap_data_key(&self, key_slot: &str, plaintext_key: &[u8]) -> Result<Vec<u8>, Self::Error> {
+ let mut master_key = self.load_or_create_wrapping_key()?;
+ let mut nonce = [0_u8; RADROOTS_PROTECTED_STORE_NONCE_LENGTH];
+ getrandom(&mut nonce)
+ .map_err(|_| RadrootsSecretVaultAccessError::Backend("entropy unavailable".into()))?;
+ let cipher = XChaCha20Poly1305::new(Key::from_slice(&master_key));
+ let ciphertext = cipher
+ .encrypt(
+ XNonce::from_slice(&nonce),
+ Payload {
+ msg: plaintext_key,
+ aad: key_slot.as_bytes(),
+ },
+ )
+ .map_err(|_| {
+ RadrootsSecretVaultAccessError::Backend(
+ "failed to wrap protected secret data key".into(),
+ )
+ })?;
+ master_key.zeroize();
+
+ let mut encoded = Vec::with_capacity(1 + nonce.len() + ciphertext.len());
+ encoded.push(WRAPPED_KEY_VERSION);
+ encoded.extend_from_slice(&nonce);
+ encoded.extend_from_slice(ciphertext.as_slice());
+ Ok(encoded)
+ }
+
+ fn unwrap_data_key(&self, key_slot: &str, wrapped_key: &[u8]) -> Result<Vec<u8>, Self::Error> {
+ if wrapped_key.len() <= 1 + RADROOTS_PROTECTED_STORE_NONCE_LENGTH {
+ return Err(RadrootsSecretVaultAccessError::Backend(
+ "wrapped protected secret data key is truncated".into(),
+ ));
+ }
+ if wrapped_key[0] != WRAPPED_KEY_VERSION {
+ return Err(RadrootsSecretVaultAccessError::Backend(format!(
+ "unsupported wrapped protected secret data key version {}",
+ wrapped_key[0]
+ )));
+ }
+
+ let mut master_key = self.load_wrapping_key()?;
+ let nonce_offset = 1;
+ let ciphertext_offset = nonce_offset + RADROOTS_PROTECTED_STORE_NONCE_LENGTH;
+ let cipher = XChaCha20Poly1305::new(Key::from_slice(&master_key));
+ let plaintext = cipher
+ .decrypt(
+ XNonce::from_slice(&wrapped_key[nonce_offset..ciphertext_offset]),
+ Payload {
+ msg: &wrapped_key[ciphertext_offset..],
+ aad: key_slot.as_bytes(),
+ },
+ )
+ .map_err(|_| {
+ RadrootsSecretVaultAccessError::Backend(
+ "failed to unwrap protected secret data key".into(),
+ )
+ })?;
+ master_key.zeroize();
+ Ok(plaintext)
+ }
+}
+
+pub fn local_wrapping_key_path(path: impl AsRef<Path>) -> PathBuf {
+ let path = path.as_ref();
+ let mut value = OsString::from(path.as_os_str());
+ value.push(LOCAL_WRAPPING_KEY_SUFFIX);
+ PathBuf::from(value)
+}
+
+pub fn seal_local_secret_file(
+ path: impl AsRef<Path>,
+ key_slot: &str,
+ payload: &[u8],
+) -> Result<(), RuntimeProtectedFileError> {
+ let path = path.as_ref();
+ if let Some(parent) = path.parent()
+ && !parent.as_os_str().is_empty()
+ {
+ fs::create_dir_all(parent).map_err(|source| RuntimeProtectedFileError::CreateDir {
+ path: parent.to_path_buf(),
+ source,
+ })?;
+ }
+
+ let key_source = LocalWrappedKeySource::new(path);
+ let envelope =
+ RadrootsProtectedStoreEnvelope::seal_with_wrapped_key(&key_source, key_slot, payload)
+ .map_err(|error| RuntimeProtectedFileError::Seal {
+ path: path.to_path_buf(),
+ message: error.to_string(),
+ })?;
+ let encoded = envelope
+ .encode_json()
+ .map_err(|error| RuntimeProtectedFileError::Seal {
+ path: path.to_path_buf(),
+ message: error.to_string(),
+ })?;
+ fs::write(path, encoded).map_err(|source| RuntimeProtectedFileError::Io {
+ path: path.to_path_buf(),
+ source,
+ })?;
+ set_secret_permissions(path).map_err(|error| RuntimeProtectedFileError::Permissions {
+ path: path.to_path_buf(),
+ message: error.to_string(),
+ })?;
+ Ok(())
+}
+
+pub fn open_local_secret_file(
+ path: impl AsRef<Path>,
+ key_slot: &str,
+) -> Result<Vec<u8>, RuntimeProtectedFileError> {
+ let path = path.as_ref();
+ let encoded = fs::read(path).map_err(|source| RuntimeProtectedFileError::Io {
+ path: path.to_path_buf(),
+ source,
+ })?;
+ let key_source = LocalWrappedKeySource::new(path);
+ let envelope = RadrootsProtectedStoreEnvelope::decode_json(&encoded).map_err(|error| {
+ RuntimeProtectedFileError::Decode {
+ path: path.to_path_buf(),
+ message: error.to_string(),
+ }
+ })?;
+ if envelope.header.key_slot != key_slot {
+ return Err(RuntimeProtectedFileError::Open {
+ path: path.to_path_buf(),
+ message: format!(
+ "expected key slot {key_slot}, found {}",
+ envelope.header.key_slot
+ ),
+ });
+ }
+ envelope
+ .open_with_wrapped_key(&key_source)
+ .map_err(|error| RuntimeProtectedFileError::Open {
+ path: path.to_path_buf(),
+ message: error.to_string(),
+ })
+}
+
+fn io_backend_error(source: std::io::Error) -> RadrootsSecretVaultAccessError {
+ RadrootsSecretVaultAccessError::Backend(source.to_string())
+}
+
+#[cfg(unix)]
+fn set_secret_permissions(path: &Path) -> Result<(), RadrootsSecretVaultAccessError> {
+ use std::os::unix::fs::PermissionsExt;
+
+ let permissions = std::fs::Permissions::from_mode(0o600);
+ fs::set_permissions(path, permissions).map_err(io_backend_error)
+}
+
+#[cfg(not(unix))]
+fn set_secret_permissions(_path: &Path) -> Result<(), RadrootsSecretVaultAccessError> {
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{local_wrapping_key_path, open_local_secret_file, seal_local_secret_file};
+
+ #[test]
+ fn secret_file_round_trips_with_sidecar_key() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let path = temp.path().join("identity.secret.json");
+
+ seal_local_secret_file(
+ &path,
+ "runtime_test_identity",
+ br#"{"secret_key":"secret"}"#,
+ )
+ .expect("seal local secret file");
+
+ let payload =
+ open_local_secret_file(&path, "runtime_test_identity").expect("open local secret file");
+ assert_eq!(payload, br#"{"secret_key":"secret"}"#);
+ assert!(local_wrapping_key_path(&path).is_file());
+ }
+
+ #[test]
+ fn secret_file_open_fails_when_wrapping_key_is_missing() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let path = temp.path().join("identity.secret.json");
+
+ seal_local_secret_file(&path, "runtime_test_identity", b"payload")
+ .expect("seal local secret file");
+ std::fs::remove_file(local_wrapping_key_path(&path)).expect("remove wrapping key");
+
+ let err = open_local_secret_file(&path, "runtime_test_identity")
+ .expect_err("missing wrapping key should fail");
+ assert!(err.to_string().contains("identity.secret.json"));
+ }
+
+ #[test]
+ fn secret_file_open_fails_when_key_slot_does_not_match() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let path = temp.path().join("identity.secret.json");
+
+ seal_local_secret_file(&path, "runtime_test_identity", b"payload")
+ .expect("seal local secret file");
+
+ let err = open_local_secret_file(&path, "unexpected_slot")
+ .expect_err("slot mismatch should fail");
+ assert!(
+ err.to_string()
+ .contains("expected key slot unexpected_slot")
+ );
+ }
+}
diff --git a/crates/runtime/src/service.rs b/crates/runtime/src/service.rs
@@ -5,6 +5,8 @@ use clap::{ArgAction, Args, ValueHint};
#[cfg(feature = "cli")]
use std::path::PathBuf;
+pub const DEFAULT_SERVICE_IDENTITY_PATH: &str = "identity.secret.json";
+
#[cfg(feature = "cli")]
#[derive(Args, Debug, Clone)]
pub struct RadrootsServiceCliArgs {
@@ -21,14 +23,14 @@ pub struct RadrootsServiceCliArgs {
long,
value_name = "PATH",
value_hint = ValueHint::FilePath,
- help = "Path to the daemon identity file (json, txt, or raw 32-byte key; defaults to identity.json)"
+ help = "Path to the daemon encrypted identity envelope; generated identities default to identity.secret.json with a sibling .key wrapping key file"
)]
pub identity: Option<PathBuf>,
#[arg(
long,
action = ArgAction::SetTrue,
- help = "Allow generating a new identity file if missing; if not set and identity file is absent, the daemon will fail"
+ help = "Allow generating a new encrypted identity envelope when the configured path is missing; if not set and the identity is absent, the daemon will fail"
)]
pub allow_generate_identity: bool,
}
diff --git a/crates/runtime/src/tracing.rs b/crates/runtime/src/tracing.rs
@@ -47,16 +47,18 @@ fn log_name_from_path(exe: Option<PathBuf>) -> Option<String> {
#[cfg(test)]
mod test_hooks {
- use std::sync::atomic::{AtomicBool, Ordering};
+ use std::cell::Cell;
- static IGNORE_ENV: AtomicBool = AtomicBool::new(false);
+ thread_local! {
+ static IGNORE_ENV: Cell<bool> = const { Cell::new(false) };
+ }
pub fn set_ignore_env(ignore: bool) {
- IGNORE_ENV.store(ignore, Ordering::SeqCst);
+ IGNORE_ENV.with(|state| state.set(ignore));
}
pub fn ignore_env() -> bool {
- IGNORE_ENV.load(Ordering::SeqCst)
+ IGNORE_ENV.with(Cell::get)
}
}
diff --git a/crates/secret-vault/src/vault.rs b/crates/secret-vault/src/vault.rs
@@ -1,4 +1,4 @@
-use alloc::string::{String, ToString};
+use alloc::string::String;
use crate::error::RadrootsSecretVaultAccessError;