lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

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:
MCargo.lock | 5+++++
Mcrates/runtime/Cargo.toml | 7++++++-
Mcrates/runtime/src/error.rs | 49++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/runtime/src/lib.rs | 4++++
Acrates/runtime/src/secret_file.rs | 284+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/runtime/src/service.rs | 6++++--
Mcrates/runtime/src/tracing.rs | 10++++++----
Mcrates/secret-vault/src/vault.rs | 2+-
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;