lib

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

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 }