keys.rs (11366B)
1 #[cfg(feature = "nostr-client")] 2 use crate::config::{KeyFormat, KeyPersistenceConfig}; 3 #[cfg(feature = "nostr-client")] 4 use crate::error::{NetError, Result}; 5 #[cfg(feature = "nostr-client")] 6 use radroots_nostr::prelude::{ 7 RadrootsNostrKeys, RadrootsNostrSecp256k1SecretKey, RadrootsNostrSecretKey, 8 RadrootsNostrToBech32, 9 }; 10 #[cfg(feature = "nostr-client")] 11 use serde::Deserialize; 12 #[cfg(feature = "nostr-client")] 13 use std::path::{Path, PathBuf}; 14 #[cfg(feature = "nostr-client")] 15 use std::str::FromStr; 16 17 #[cfg(feature = "nostr-client")] 18 #[derive(Debug, Clone, Deserialize, serde::Serialize)] 19 #[serde(deny_unknown_fields)] 20 struct KeysFile { 21 pub key: String, 22 #[serde(skip_serializing_if = "Option::is_none")] 23 pub npub: Option<String>, 24 #[serde(skip_serializing_if = "Option::is_none")] 25 pub created_at: Option<u64>, 26 #[serde(skip_serializing_if = "Option::is_none")] 27 pub note: Option<String>, 28 } 29 30 #[cfg(feature = "nostr-client")] 31 #[derive(Debug, Clone, Default)] 32 pub struct KeysState { 33 pub loaded: bool, 34 pub source: Option<PathBuf>, 35 pub npub: Option<String>, 36 pub last_error: Option<NetError>, 37 } 38 39 #[cfg(feature = "nostr-client")] 40 #[derive(Debug, Clone, Default)] 41 pub struct KeysManager { 42 pub keys: Option<RadrootsNostrKeys>, 43 pub state: KeysState, 44 } 45 46 #[cfg(feature = "nostr-client")] 47 #[derive(Debug, Clone)] 48 pub enum LoadOutcome { 49 FromFile(PathBuf), 50 GeneratedEphemeral, 51 } 52 53 #[cfg(feature = "nostr-client")] 54 impl KeysManager { 55 pub fn new() -> Self { 56 Self::default() 57 } 58 59 pub fn is_valid_hex32(s: &str) -> bool { 60 s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) 61 } 62 pub fn is_valid_nsec(s: &str) -> bool { 63 s.starts_with("nsec1") 64 } 65 66 pub fn load_from_secret_bytes(&mut self, sk: &[u8; 32]) -> Result<()> { 67 let secp = RadrootsNostrSecp256k1SecretKey::from_slice(&sk[..]) 68 .map_err(|_| NetError::InvalidHex32)?; 69 let nostr_sk = RadrootsNostrSecretKey::from(secp); 70 let keys = RadrootsNostrKeys::new(nostr_sk); 71 self.set_keys(keys); 72 Ok(()) 73 } 74 75 pub fn load_from_hex32(&mut self, hex: &str) -> Result<()> { 76 use secrecy::{ExposeSecret, SecretString}; 77 let secret = SecretString::new(hex.to_owned().into()); 78 let k = RadrootsNostrSecretKey::from_str(secret.expose_secret()) 79 .map_err(|_| NetError::InvalidHex32)?; 80 let keys = RadrootsNostrKeys::new(k); 81 self.set_keys(keys); 82 Ok(()) 83 } 84 85 pub fn load_from_nsec(&mut self, nsec: &str) -> Result<()> { 86 use secrecy::{ExposeSecret, SecretString}; 87 let secret = SecretString::new(nsec.to_owned().into()); 88 let keys = RadrootsNostrKeys::parse(secret.expose_secret()) 89 .map_err(|_| NetError::InvalidBech32)?; 90 self.set_keys(keys); 91 Ok(()) 92 } 93 94 pub fn set_keys(&mut self, keys: RadrootsNostrKeys) { 95 let npub = keys.public_key().to_bech32().ok(); 96 self.keys = Some(keys); 97 self.state.loaded = true; 98 self.state.source = None; 99 self.state.npub = npub; 100 self.state.last_error = None; 101 } 102 103 pub fn clear(&mut self) { 104 *self = Self::default(); 105 } 106 107 pub fn require(&self) -> Result<&RadrootsNostrKeys> { 108 self.keys.as_ref().ok_or(NetError::MissingKey) 109 } 110 111 pub fn load_from_path_auto(&mut self, path: impl AsRef<Path>) -> Result<()> { 112 let p = path.as_ref(); 113 if let Ok(s) = std::fs::read_to_string(p) { 114 if let Ok(jf) = serde_json::from_str::<KeysFile>(&s) { 115 return self.load_from_hex32(&jf.key).map(|_| { 116 self.state.source = Some(p.to_path_buf()); 117 }); 118 } 119 let trimmed = s.trim(); 120 if Self::is_valid_nsec(trimmed) { 121 return self.load_from_nsec(trimmed).map(|_| { 122 self.state.source = Some(p.to_path_buf()); 123 }); 124 } 125 if Self::is_valid_hex32(trimmed) { 126 return self.load_from_hex32(trimmed).map(|_| { 127 self.state.source = Some(p.to_path_buf()); 128 }); 129 } 130 } 131 match std::fs::read(p) { 132 Ok(bytes) if bytes.len() == 32 => { 133 let mut arr = [0u8; 32]; 134 arr.copy_from_slice(&bytes); 135 self.load_from_secret_bytes(&arr)?; 136 self.state.source = Some(p.to_path_buf()); 137 Ok(()) 138 } 139 _ => Err(NetError::InvalidKeyFile), 140 } 141 } 142 143 pub fn load_from_file(&mut self, path: impl AsRef<Path>) -> Result<()> { 144 self.load_from_path_auto(path) 145 } 146 147 pub fn save_to_path_with_format( 148 &self, 149 path: impl AsRef<Path>, 150 format: KeyFormat, 151 no_overwrite: bool, 152 ) -> Result<()> { 153 if no_overwrite && path.as_ref().exists() { 154 return Err(NetError::OverwriteDenied); 155 } 156 match format { 157 KeyFormat::Json => self.save_json(path), 158 KeyFormat::Nsec => self.save_nsec_text(path, no_overwrite), 159 KeyFormat::Hex => self.save_hex_text(path, no_overwrite), 160 KeyFormat::Bin => self.save_raw_bin(path, no_overwrite), 161 } 162 } 163 164 fn require_secret_hex(&self) -> Result<String> { 165 let keys = self.require()?; 166 Ok(keys.secret_key().to_secret_hex()) 167 } 168 169 pub fn export_secret_hex(&self) -> Result<String> { 170 self.require_secret_hex() 171 } 172 173 fn save_json(&self, path: impl AsRef<Path>) -> Result<()> { 174 use std::time::{SystemTime, UNIX_EPOCH}; 175 let keys = self.require()?; 176 let secret_hex = keys.secret_key().to_secret_hex(); 177 let payload = KeysFile { 178 key: secret_hex, 179 npub: self.npub(), 180 created_at: SystemTime::now() 181 .duration_since(UNIX_EPOCH) 182 .ok() 183 .map(|d| d.as_secs()), 184 note: None, 185 }; 186 let json = serde_json::to_string_pretty(&payload).map_err(|_| NetError::KeyIo)?; 187 write_secret_atomically_noclobber(path.as_ref(), json.as_bytes()) 188 .map_err(|_| NetError::KeyIo)?; 189 Ok(()) 190 } 191 192 fn save_nsec_text(&self, path: impl AsRef<Path>, no_overwrite: bool) -> Result<()> { 193 if no_overwrite && path.as_ref().exists() { 194 return Err(NetError::OverwriteDenied); 195 } 196 let keys = self.require()?; 197 let nsec = keys.secret_key().to_bech32().map_err(|_| NetError::KeyIo)?; 198 write_secret_atomically_noclobber(path.as_ref(), nsec.as_bytes()) 199 .map_err(|_| NetError::KeyIo)?; 200 Ok(()) 201 } 202 203 fn save_hex_text(&self, path: impl AsRef<Path>, no_overwrite: bool) -> Result<()> { 204 if no_overwrite && path.as_ref().exists() { 205 return Err(NetError::OverwriteDenied); 206 } 207 let hex = self.require_secret_hex()?; 208 write_secret_atomically_noclobber(path.as_ref(), hex.as_bytes()) 209 .map_err(|_| NetError::KeyIo)?; 210 Ok(()) 211 } 212 213 fn save_raw_bin(&self, path: impl AsRef<Path>, no_overwrite: bool) -> Result<()> { 214 if no_overwrite && path.as_ref().exists() { 215 return Err(NetError::OverwriteDenied); 216 } 217 let hex = self.require_secret_hex()?; 218 let mut out = [0u8; 32]; 219 hex::decode_to_slice(hex, &mut out).map_err(|_| NetError::KeyIo)?; 220 write_secret_atomically_noclobber(path.as_ref(), &out).map_err(|_| NetError::KeyIo)?; 221 Ok(()) 222 } 223 224 pub fn generate_in_memory(&mut self) -> &RadrootsNostrKeys { 225 let keys = RadrootsNostrKeys::generate(); 226 self.set_keys(keys); 227 self.keys.as_ref().unwrap() 228 } 229 230 pub fn ensure_loaded_from_file_outcome( 231 &mut self, 232 path: impl AsRef<Path>, 233 allow_generate: bool, 234 ) -> Result<LoadOutcome> { 235 let p = path.as_ref(); 236 if p.exists() { 237 self.load_from_path_auto(p)?; 238 return Ok(LoadOutcome::FromFile(p.to_path_buf())); 239 } 240 if !allow_generate { 241 self.state.last_error = Some(NetError::MissingKey); 242 return Err(NetError::MissingKey); 243 } 244 let _ = self.generate_in_memory(); 245 Ok(LoadOutcome::GeneratedEphemeral) 246 } 247 248 pub fn ensure_loaded_from_file( 249 &mut self, 250 path: impl AsRef<Path>, 251 allow_generate: bool, 252 ) -> Result<()> { 253 let _ = self.ensure_loaded_from_file_outcome(path, allow_generate)?; 254 Ok(()) 255 } 256 257 pub fn npub(&self) -> Option<String> { 258 self.state.npub.clone() 259 } 260 261 #[cfg(feature = "fs-persistence")] 262 pub fn persist_with_config(&self, cfg: &KeyPersistenceConfig) -> Result<PathBuf> { 263 let path = cfg.path.clone().ok_or(NetError::PersistencePathRequired)?; 264 self.save_to_path_with_format(&path, cfg.format, cfg.no_overwrite)?; 265 Ok(path) 266 } 267 268 #[cfg(not(feature = "fs-persistence"))] 269 pub fn persist_with_config(&self, _cfg: &KeyPersistenceConfig) -> Result<PathBuf> { 270 Err(NetError::PersistenceUnsupported) 271 } 272 } 273 274 #[cfg(feature = "nostr-client")] 275 fn write_secret_atomically_noclobber(path: &Path, data: &[u8]) -> crate::error::Result<()> { 276 use std::io::Write; 277 let dir = path.parent().unwrap_or_else(|| Path::new(".")); 278 std::fs::create_dir_all(dir)?; 279 280 let mut tmp = tempfile::NamedTempFile::new_in(dir)?; 281 tmp.write_all(data)?; 282 tmp.flush()?; 283 284 let persist_result = tmp.persist_noclobber(path); 285 286 if let Err(e) = persist_result { 287 if e.error.kind() == std::io::ErrorKind::AlreadyExists { 288 return Err(crate::error::NetError::OverwriteDenied); 289 } else { 290 return Err(crate::error::NetError::KeyIo); 291 } 292 } 293 294 #[cfg(unix)] 295 { 296 use std::fs::Permissions; 297 use std::os::unix::fs::PermissionsExt; 298 let _ = std::fs::set_permissions(path, Permissions::from_mode(0o600)); 299 } 300 301 Ok(()) 302 } 303 304 #[cfg(all(test, feature = "nostr-client", feature = "fs-persistence"))] 305 mod tests { 306 use tempfile::tempdir; 307 308 use crate::{ 309 config::{KeyFormat, KeyPersistenceConfig}, 310 error::NetError, 311 }; 312 313 use super::KeysManager; 314 315 #[test] 316 fn persist_with_config_requires_explicit_path() { 317 let mut manager = KeysManager::new(); 318 let _ = manager.generate_in_memory(); 319 let cfg = KeyPersistenceConfig { 320 path: None, 321 format: KeyFormat::Json, 322 no_overwrite: true, 323 }; 324 325 let err = manager 326 .persist_with_config(&cfg) 327 .expect_err("implicit default persistence path should be rejected"); 328 329 assert!(matches!(err, NetError::PersistencePathRequired)); 330 } 331 332 #[test] 333 fn persist_with_config_writes_only_to_explicit_path() { 334 let dir = tempdir().expect("tempdir"); 335 let path = dir.path().join("keys.json"); 336 let mut manager = KeysManager::new(); 337 let _ = manager.generate_in_memory(); 338 let cfg = KeyPersistenceConfig { 339 path: Some(path.clone()), 340 format: KeyFormat::Json, 341 no_overwrite: true, 342 }; 343 344 let written = manager 345 .persist_with_config(&cfg) 346 .expect("explicit persistence path should succeed"); 347 348 assert_eq!(written, path); 349 assert!(written.exists()); 350 } 351 }