commit edb617171cbf78ee3eb05238f43e2f346c7552ec
parent 57bb7bfa11143c15644e9859e386223255c71963
Author: triesap <tyson@radroots.org>
Date: Fri, 3 Oct 2025 19:24:09 +0100
net-core: add `keys` module with Nostr key management
Diffstat:
13 files changed, 612 insertions(+), 30 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -577,6 +577,27 @@ dependencies = [
]
[[package]]
+name = "directories"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
name = "displaydoc"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -800,6 +821,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
name = "hex-conservative"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1161,6 +1188,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
[[package]]
+name = "libredox"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3"
+dependencies = [
+ "bitflags",
+ "libc",
+]
+
+[[package]]
name = "linux-raw-sys"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1365,6 +1402,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
name = "ordered-multimap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1655,11 +1698,19 @@ dependencies = [
name = "radroots-net-core"
version = "0.1.0"
dependencies = [
+ "directories",
+ "hex",
+ "nostr",
"radroots-log",
+ "secrecy",
"serde",
+ "serde_json",
+ "tempfile",
"thiserror 1.0.69",
"tokio",
"tracing",
+ "tracing-appender",
+ "tracing-subscriber",
]
[[package]]
@@ -1765,6 +1816,17 @@ dependencies = [
]
[[package]]
+name = "redox_users"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
+dependencies = [
+ "getrandom 0.2.16",
+ "libredox",
+ "thiserror 2.0.16",
+]
+
+[[package]]
name = "regex"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2017,6 +2079,15 @@ dependencies = [
]
[[package]]
+name = "secrecy"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
name = "serde"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2484,7 +2555,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
"pin-project-lite",
- "tracing-attributes",
"tracing-core",
]
@@ -2501,17 +2571,6 @@ dependencies = [
]
[[package]]
-name = "tracing-attributes"
-version = "0.1.30"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
name = "tracing-core"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -46,7 +46,7 @@ tempfile = { version = "3" }
thiserror = { version = "1" }
tokio = { version = "1" }
toml = { version = "0.8" }
-tracing = { version = "0.1" }
+tracing = { version = "0.1", default-features = false }
tracing-appender = { version = "0.2" }
tracing-log = { version = "0.2" }
tracing-subscriber = { version = "0.3" }
diff --git a/crates/log/Cargo.toml b/crates/log/Cargo.toml
@@ -6,8 +6,12 @@ authors = ["Radroots Authors"]
rust-version.workspace = true
license.workspace = true
+[features]
+default = ["std"]
+std = ["dep:thiserror", "dep:tracing-subscriber", "dep:tracing-appender"]
+
[dependencies]
-thiserror = { workspace = true }
-tracing = { workspace = true }
-tracing-subscriber = { workspace = true, features = ["fmt", "env-filter"] }
-tracing-appender = { workspace = true }
-\ No newline at end of file
+tracing = { workspace = true, default-features = false }
+thiserror = { workspace = true, optional = true }
+tracing-subscriber = { workspace = true, optional = true, features = ["fmt", "env-filter"] }
+tracing-appender = { workspace = true, optional = true }
+\ No newline at end of file
diff --git a/crates/log/src/error.rs b/crates/log/src/error.rs
@@ -1,9 +1,16 @@
+use alloc::string::String;
+
+#[cfg(feature = "std")]
use thiserror::Error;
-#[derive(Debug, Error)]
+#[cfg_attr(feature = "std", derive(Error))]
+#[derive(Debug)]
pub enum Error {
+ #[cfg(feature = "std")]
#[error("{0}")]
Msg(String),
+
+ #[cfg(feature = "std")]
#[error("logging init failed: {0}")]
Init(&'static str),
}
diff --git a/crates/log/src/init.rs b/crates/log/src/init.rs
@@ -27,7 +27,6 @@ pub fn init_logging(opts: LoggingOptions) -> Result<()> {
};
let env = EnvFilter::from_default_env().add_directive(Level::INFO.into());
-
let fmt_layer_file = writer.as_ref().map(|w| fmt::layer().with_writer(w.clone()));
let fmt_layer_stdout = if opts.also_stdout() {
Some(fmt::layer())
@@ -60,7 +59,7 @@ pub fn init_logging(opts: LoggingOptions) -> Result<()> {
pub fn init_stdout() -> Result<()> {
init_logging(LoggingOptions {
dir: None,
+ file_name: "radroots.log".into(),
stdout: true,
- ..Default::default()
})
}
diff --git a/crates/log/src/lib.rs b/crates/log/src/lib.rs
@@ -1,9 +1,19 @@
+#![cfg_attr(not(feature = "std"), no_std)]
+
+extern crate alloc;
+
mod error;
+
+#[cfg(feature = "std")]
mod init;
+#[cfg(feature = "std")]
mod options;
pub use error::{Error, Result};
+
+#[cfg(feature = "std")]
pub use init::{init_logging, init_stdout};
+#[cfg(feature = "std")]
pub use options::LoggingOptions;
use tracing::{debug, error, info};
@@ -22,3 +32,19 @@ pub fn log_error<S: AsRef<str>>(msg: S) {
pub fn log_debug<S: AsRef<str>>(msg: S) {
debug!("{}", msg.as_ref());
}
+
+#[cfg(not(feature = "std"))]
+pub fn init_no_std() -> Result<()> {
+ Ok(())
+}
+
+pub fn init_default() -> Result<()> {
+ #[cfg(feature = "std")]
+ {
+ return init_stdout();
+ }
+ #[cfg(not(feature = "std"))]
+ {
+ return init_no_std();
+ }
+}
diff --git a/crates/net-core/Cargo.toml b/crates/net-core/Cargo.toml
@@ -10,10 +10,21 @@ license.workspace = true
default = ["std"]
std = []
rt = ["dep:tokio"]
+nostr-client = ["dep:nostr", "dep:secrecy", "dep:hex", "dep:tempfile", "dep:serde_json"]
+directories = ["dep:directories"]
+fs-persistence = []
[dependencies]
radroots-log = { workspace = true }
+directories = { workspace = true, optional = true }
+hex = { workspace = true, optional = true }
+nostr = { workspace = true, optional = true }
+secrecy = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true, optional = true }
+tempfile = { workspace = true, optional = true }
thiserror = { workspace = true }
tokio = { workspace = true, optional = true, features = ["rt-multi-thread"] }
tracing = { workspace = true }
+tracing-subscriber = { workspace = true, features = ["fmt", "env-filter"] }
+tracing-appender = { workspace = true }
diff --git a/crates/net-core/src/config.rs b/crates/net-core/src/config.rs
@@ -1,4 +1,20 @@
use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NetConfig {}
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+pub enum KeyFormat {
+ Json,
+ Nsec,
+ Hex,
+ Bin,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct KeyPersistenceConfig {
+ pub path: Option<PathBuf>,
+ pub format: KeyFormat,
+ pub no_overwrite: bool,
+}
diff --git a/crates/net-core/src/error.rs b/crates/net-core/src/error.rs
@@ -2,7 +2,7 @@ use alloc::string::String;
use thiserror::Error;
#[derive(Debug, Error)]
-pub enum Error {
+pub enum NetError {
#[error("{0}")]
Msg(String),
@@ -12,12 +12,57 @@ pub enum Error {
#[cfg(feature = "std")]
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
+
+ #[error("missing key")]
+ MissingKey,
+
+ #[error("invalid hex32")]
+ InvalidHex32,
+
+ #[error("invalid bech32")]
+ InvalidBech32,
+
+ #[error("invalid key file")]
+ InvalidKeyFile,
+
+ #[error("key I/O")]
+ KeyIo,
+
+ #[error("overwrite denied")]
+ OverwriteDenied,
+
+ #[error("persistence unsupported")]
+ PersistenceUnsupported,
+
+ #[error("logging init failed: {0}")]
+ LoggingInit(&'static str),
}
-impl Error {
+impl NetError {
pub fn msg<M: Into<String>>(msg: M) -> Self {
- Error::Msg(msg.into())
+ NetError::Msg(msg.into())
+ }
+}
+
+impl Clone for NetError {
+ fn clone(&self) -> Self {
+ match self {
+ NetError::Msg(m) => NetError::Msg(m.clone()),
+ NetError::Poisoned => NetError::Poisoned,
+ #[cfg(feature = "std")]
+ NetError::Io(_) => {
+ panic!("cannot clone std::io::Error");
+ }
+ NetError::MissingKey => NetError::MissingKey,
+ NetError::InvalidHex32 => NetError::InvalidHex32,
+ NetError::InvalidBech32 => NetError::InvalidBech32,
+ NetError::InvalidKeyFile => NetError::InvalidKeyFile,
+ NetError::KeyIo => NetError::KeyIo,
+ NetError::OverwriteDenied => NetError::OverwriteDenied,
+ NetError::PersistenceUnsupported => NetError::PersistenceUnsupported,
+ NetError::LoggingInit(s) => NetError::LoggingInit(s),
+ }
}
}
-pub type Result<T> = core::result::Result<T, Error>;
+pub type Result<T> = core::result::Result<T, NetError>;
diff --git a/crates/net-core/src/keys.rs b/crates/net-core/src/keys.rs
@@ -0,0 +1,336 @@
+#[cfg(feature = "nostr-client")]
+use crate::config::{KeyFormat, KeyPersistenceConfig};
+#[cfg(feature = "nostr-client")]
+use crate::error::{NetError, Result};
+#[cfg(feature = "nostr-client")]
+use serde::Deserialize;
+#[cfg(feature = "nostr-client")]
+use std::path::{Path, PathBuf};
+#[cfg(feature = "nostr-client")]
+use std::str::FromStr;
+
+#[cfg(feature = "nostr-client")]
+#[derive(Debug, Clone, Deserialize, serde::Serialize)]
+#[serde(deny_unknown_fields)]
+struct KeysFile {
+ pub key: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub npub: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub created_at: Option<u64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub note: Option<String>,
+}
+
+#[cfg(feature = "nostr-client")]
+#[derive(Debug, Clone, Default)]
+pub struct KeysState {
+ pub loaded: bool,
+ pub source: Option<PathBuf>,
+ pub npub: Option<String>,
+ pub last_error: Option<NetError>,
+}
+
+#[cfg(feature = "nostr-client")]
+#[derive(Debug, Clone, Default)]
+pub struct KeysManager {
+ pub keys: Option<nostr::Keys>,
+ pub state: KeysState,
+}
+
+#[cfg(feature = "nostr-client")]
+#[derive(Debug, Clone)]
+pub enum LoadOutcome {
+ FromFile(PathBuf),
+ GeneratedEphemeral,
+}
+
+#[cfg(feature = "nostr-client")]
+impl KeysManager {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn is_valid_hex32(s: &str) -> bool {
+ let is_hex = s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit());
+ is_hex
+ }
+ pub fn is_valid_nsec(s: &str) -> bool {
+ s.starts_with("nsec1")
+ }
+
+ pub fn load_from_secret_bytes(&mut self, sk: &[u8; 32]) -> Result<()> {
+ use nostr::secp256k1::SecretKey as SecpSecret;
+ let secp = SecpSecret::from_slice(&sk[..]).map_err(|_| NetError::InvalidHex32)?;
+ let nostr_sk = nostr::SecretKey::from(secp);
+ let keys = nostr::Keys::new(nostr_sk);
+ self.set_keys(keys);
+ Ok(())
+ }
+
+ pub fn load_from_hex32(&mut self, hex: &str) -> Result<()> {
+ use secrecy::{ExposeSecret, SecretString};
+ let secret = SecretString::new(hex.to_owned().into());
+ let k = nostr::SecretKey::from_str(secret.expose_secret())
+ .map_err(|_| NetError::InvalidHex32)?;
+ let keys = nostr::Keys::new(k);
+ self.set_keys(keys);
+ Ok(())
+ }
+
+ pub fn load_from_nsec(&mut self, nsec: &str) -> Result<()> {
+ use secrecy::{ExposeSecret, SecretString};
+ let secret = SecretString::new(nsec.to_owned().into());
+ let keys =
+ nostr::Keys::parse(secret.expose_secret()).map_err(|_| NetError::InvalidBech32)?;
+ self.set_keys(keys);
+ Ok(())
+ }
+
+ pub fn set_keys(&mut self, keys: nostr::Keys) {
+ use nostr::nips::nip19::ToBech32;
+
+ let npub = keys.public_key().to_bech32().ok();
+ self.keys = Some(keys);
+ self.state.loaded = true;
+ self.state.source = None;
+ self.state.npub = npub;
+ self.state.last_error = None;
+ }
+
+ pub fn clear(&mut self) {
+ *self = Self::default();
+ }
+
+ pub fn require(&self) -> Result<&nostr::Keys> {
+ self.keys.as_ref().ok_or(NetError::MissingKey)
+ }
+
+ pub fn load_from_path_auto(&mut self, path: impl AsRef<Path>) -> Result<()> {
+ let p = path.as_ref();
+ match std::fs::read_to_string(p) {
+ Ok(s) => {
+ if let Ok(jf) = serde_json::from_str::<KeysFile>(&s) {
+ return self.load_from_hex32(&jf.key).map(|_| {
+ self.state.source = Some(p.to_path_buf());
+ });
+ }
+ let trimmed = s.trim();
+ if Self::is_valid_nsec(trimmed) {
+ return self.load_from_nsec(trimmed).map(|_| {
+ self.state.source = Some(p.to_path_buf());
+ });
+ }
+ if Self::is_valid_hex32(trimmed) {
+ return self.load_from_hex32(trimmed).map(|_| {
+ self.state.source = Some(p.to_path_buf());
+ });
+ }
+ }
+ Err(_) => {}
+ }
+ match std::fs::read(p) {
+ Ok(bytes) if bytes.len() == 32 => {
+ let mut arr = [0u8; 32];
+ arr.copy_from_slice(&bytes);
+ self.load_from_secret_bytes(&arr)?;
+ self.state.source = Some(p.to_path_buf());
+ Ok(())
+ }
+ _ => Err(NetError::InvalidKeyFile),
+ }
+ }
+
+ pub fn load_from_file(&mut self, path: impl AsRef<Path>) -> Result<()> {
+ self.load_from_path_auto(path)
+ }
+
+ pub fn save_to_path_with_format(
+ &self,
+ path: impl AsRef<Path>,
+ format: KeyFormat,
+ no_overwrite: bool,
+ ) -> Result<()> {
+ if no_overwrite && path.as_ref().exists() {
+ return Err(NetError::OverwriteDenied);
+ }
+ match format {
+ KeyFormat::Json => self.save_json(path),
+ KeyFormat::Nsec => self.save_nsec_text(path, no_overwrite),
+ KeyFormat::Hex => self.save_hex_text(path, no_overwrite),
+ KeyFormat::Bin => self.save_raw_bin(path, no_overwrite),
+ }
+ }
+
+ fn require_secret_hex(&self) -> Result<String> {
+ let keys = self.require()?;
+ Ok(keys.secret_key().to_secret_hex())
+ }
+
+ pub fn export_secret_hex(&self) -> Result<String> {
+ self.require_secret_hex()
+ }
+
+ fn save_json(&self, path: impl AsRef<Path>) -> Result<()> {
+ use std::time::{SystemTime, UNIX_EPOCH};
+ let keys = self.require()?;
+ let secret_hex = keys.secret_key().to_secret_hex();
+ let payload = KeysFile {
+ key: secret_hex,
+ npub: self.npub(),
+ created_at: SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .ok()
+ .map(|d| d.as_secs()),
+ note: None,
+ };
+ let json = serde_json::to_string_pretty(&payload).map_err(|_| NetError::KeyIo)?;
+ write_secret_atomically_noclobber(path.as_ref(), json.as_bytes())
+ .map_err(|_| NetError::KeyIo)?;
+ Ok(())
+ }
+
+ fn save_nsec_text(&self, path: impl AsRef<Path>, no_overwrite: bool) -> Result<()> {
+ use nostr::nips::nip19::ToBech32;
+ if no_overwrite && path.as_ref().exists() {
+ return Err(NetError::OverwriteDenied);
+ }
+ let keys = self.require()?;
+ let nsec = keys.secret_key().to_bech32().map_err(|_| NetError::KeyIo)?;
+ write_secret_atomically_noclobber(path.as_ref(), nsec.as_bytes())
+ .map_err(|_| NetError::KeyIo)?;
+ Ok(())
+ }
+
+ fn save_hex_text(&self, path: impl AsRef<Path>, no_overwrite: bool) -> Result<()> {
+ if no_overwrite && path.as_ref().exists() {
+ return Err(NetError::OverwriteDenied);
+ }
+ let hex = self.require_secret_hex()?;
+ write_secret_atomically_noclobber(path.as_ref(), hex.as_bytes())
+ .map_err(|_| NetError::KeyIo)?;
+ Ok(())
+ }
+
+ fn save_raw_bin(&self, path: impl AsRef<Path>, no_overwrite: bool) -> Result<()> {
+ if no_overwrite && path.as_ref().exists() {
+ return Err(NetError::OverwriteDenied);
+ }
+ let hex = self.require_secret_hex()?;
+ let mut out = [0u8; 32];
+ hex::decode_to_slice(hex, &mut out).map_err(|_| NetError::KeyIo)?;
+ write_secret_atomically_noclobber(path.as_ref(), &out).map_err(|_| NetError::KeyIo)?;
+ Ok(())
+ }
+
+ pub fn generate_in_memory(&mut self) -> &nostr::Keys {
+ let keys = nostr::Keys::generate();
+ self.set_keys(keys);
+ self.keys.as_ref().unwrap()
+ }
+
+ pub fn ensure_loaded_from_file_outcome(
+ &mut self,
+ path: impl AsRef<Path>,
+ allow_generate: bool,
+ ) -> Result<LoadOutcome> {
+ let p = path.as_ref();
+ if p.exists() {
+ self.load_from_path_auto(p)?;
+ return Ok(LoadOutcome::FromFile(p.to_path_buf()));
+ }
+ if !allow_generate {
+ self.state.last_error = Some(NetError::MissingKey);
+ return Err(NetError::MissingKey);
+ }
+ let _ = self.generate_in_memory();
+ Ok(LoadOutcome::GeneratedEphemeral)
+ }
+
+ pub fn ensure_loaded_from_file(
+ &mut self,
+ path: impl AsRef<Path>,
+ allow_generate: bool,
+ ) -> Result<()> {
+ let _ = self.ensure_loaded_from_file_outcome(path, allow_generate)?;
+ Ok(())
+ }
+
+ pub fn npub(&self) -> Option<String> {
+ self.state.npub.clone()
+ }
+
+ #[cfg(all(feature = "directories", feature = "fs-persistence"))]
+ pub fn default_key_path() -> Option<PathBuf> {
+ directories::ProjectDirs::from("com", "Radroots", "radroots")
+ .map(|d| d.config_dir().join("identity.json"))
+ }
+
+ #[cfg(all(feature = "directories", feature = "fs-persistence"))]
+ pub fn persist_best_practice(&self) -> Result<PathBuf> {
+ let path = Self::default_key_path().ok_or(NetError::PersistenceUnsupported)?;
+ if path.exists() {
+ return Err(NetError::OverwriteDenied);
+ }
+ self.save_to_path_with_format(&path, KeyFormat::Json, true)?;
+ Ok(path)
+ }
+
+ #[cfg(not(all(feature = "directories", feature = "fs-persistence")))]
+ pub fn persist_best_practice(&self) -> Result<PathBuf> {
+ Err(NetError::PersistenceUnsupported)
+ }
+
+ #[cfg(feature = "fs-persistence")]
+ pub fn persist_with_config(&self, cfg: &KeyPersistenceConfig) -> Result<PathBuf> {
+ let path = if let Some(p) = &cfg.path {
+ p.clone()
+ } else {
+ #[cfg(all(feature = "directories", feature = "fs-persistence"))]
+ {
+ Self::default_key_path().ok_or(NetError::PersistenceUnsupported)?
+ }
+ #[cfg(not(all(feature = "directories", feature = "fs-persistence")))]
+ {
+ return Err(NetError::PersistenceUnsupported);
+ }
+ };
+ self.save_to_path_with_format(&path, cfg.format, cfg.no_overwrite)?;
+ Ok(path)
+ }
+
+ #[cfg(not(feature = "fs-persistence"))]
+ pub fn persist_with_config(&self, _cfg: &KeyPersistenceConfig) -> Result<PathBuf> {
+ Err(NetError::PersistenceUnsupported)
+ }
+}
+
+#[cfg(feature = "nostr-client")]
+fn write_secret_atomically_noclobber(path: &Path, data: &[u8]) -> crate::error::Result<()> {
+ use std::io::Write;
+ let dir = path.parent().unwrap_or_else(|| Path::new("."));
+ std::fs::create_dir_all(dir)?;
+
+ let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
+ tmp.write_all(data)?;
+ tmp.flush()?;
+
+ let persist_result = tmp.persist_noclobber(path);
+
+ if let Err(e) = persist_result {
+ if e.error.kind() == std::io::ErrorKind::AlreadyExists {
+ return Err(crate::error::NetError::OverwriteDenied);
+ } else {
+ return Err(crate::error::NetError::KeyIo);
+ }
+ }
+
+ #[cfg(unix)]
+ {
+ use std::fs::Permissions;
+ use std::os::unix::fs::PermissionsExt;
+ let _ = std::fs::set_permissions(path, Permissions::from_mode(0o600));
+ }
+
+ Ok(())
+}
diff --git a/crates/net-core/src/lib.rs b/crates/net-core/src/lib.rs
@@ -11,4 +11,7 @@ pub mod logging;
pub mod builder;
pub mod config;
+#[cfg(feature = "nostr-client")]
+pub mod keys;
+
pub use net::{Net, NetHandle, NetInfo};
diff --git a/crates/net-core/src/logging.rs b/crates/net-core/src/logging.rs
@@ -1,2 +1,71 @@
-#[cfg(feature = "std")]
-pub use radroots_log::{LoggingOptions, init_logging, init_stdout};
+use crate::error::{NetError, Result};
+use std::sync::OnceLock;
+use std::{fs, path::PathBuf};
+use tracing::{Level, info};
+use tracing_subscriber::prelude::*;
+use tracing_subscriber::{EnvFilter, fmt};
+
+#[derive(Debug, Clone)]
+pub struct LoggingOptions {
+ pub dir: Option<PathBuf>,
+ pub file_name: String,
+ pub also_stdout: bool,
+}
+
+impl Default for LoggingOptions {
+ fn default() -> Self {
+ Self {
+ dir: None,
+ file_name: "radroots.log".into(),
+ also_stdout: true,
+ }
+ }
+}
+
+static GUARD: OnceLock<tracing_appender::non_blocking::WorkerGuard> = OnceLock::new();
+static INIT: OnceLock<()> = OnceLock::new();
+
+pub fn init_logging(opts: LoggingOptions) -> Result<()> {
+ if INIT.get().is_some() {
+ return Ok(());
+ }
+
+ let writer = if let Some(dir) = &opts.dir {
+ fs::create_dir_all(dir).map_err(|_| NetError::LoggingInit("mkdir"))?;
+ let file_appender = tracing_appender::rolling::daily(dir, &opts.file_name);
+ let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
+ let _ = GUARD.set(guard);
+ Some(non_blocking)
+ } else {
+ None
+ };
+
+ let env = EnvFilter::from_default_env().add_directive(Level::INFO.into());
+ let fmt_layer_file = writer.as_ref().map(|w| fmt::layer().with_writer(w.clone()));
+ let fmt_layer_stdout = if opts.also_stdout {
+ Some(fmt::layer())
+ } else {
+ None
+ };
+
+ let subscriber = tracing_subscriber::registry()
+ .with(env)
+ .with(fmt_layer_file)
+ .with(fmt_layer_stdout);
+
+ match subscriber.try_init() {
+ Ok(()) => {
+ let _ = INIT.set(());
+ info!(
+ "logging initialized (file: {}, stdout: {})",
+ opts.dir
+ .as_ref()
+ .map(|d| d.join(&opts.file_name).display().to_string())
+ .unwrap_or_else(|| "<disabled>".into()),
+ opts.also_stdout
+ );
+ Ok(())
+ }
+ Err(_) => Ok(()),
+ }
+}
diff --git a/crates/net-core/src/net.rs b/crates/net-core/src/net.rs
@@ -1,7 +1,9 @@
use serde::Serialize;
use std::sync::{Arc, Mutex, MutexGuard};
-use crate::error::{Error, Result};
+use crate::error::{NetError, Result};
+#[cfg(feature = "nostr-client")]
+use crate::keys::KeysManager;
#[derive(Debug, Clone, Serialize)]
pub struct BuildInfo {
@@ -26,6 +28,9 @@ pub struct Net {
pub info: NetInfo,
pub config: crate::config::NetConfig,
+ #[cfg(feature = "nostr-client")]
+ pub keys: KeysManager,
+
#[cfg(feature = "rt")]
pub rt: Option<tokio::runtime::Runtime>,
}
@@ -44,6 +49,8 @@ impl Net {
},
},
config: cfg,
+ #[cfg(feature = "nostr-client")]
+ keys: KeysManager::default(),
#[cfg(feature = "rt")]
rt: None,
}
@@ -66,7 +73,7 @@ impl Net {
.worker_threads(threads)
.enable_all()
.build()
- .map_err(|e| Error::msg(format!("failed to build tokio runtime: {e}")))?;
+ .map_err(|e| NetError::msg(format!("failed to build tokio runtime: {e}")))?;
self.rt = Some(rt);
Ok(())
@@ -82,7 +89,7 @@ impl NetHandle {
}
pub fn lock(&self) -> Result<MutexGuard<'_, Net>> {
- self.0.lock().map_err(|_| Error::Poisoned)
+ self.0.lock().map_err(|_| NetError::Poisoned)
}
}