key.rs (12585B)
1 //! Nostr key encoding and NIP-19 conversion for Radroots identities. 2 //! 3 //! This durable adapter keeps Nostr representation policy out of the identity 4 //! crate and keeps local secret material opaque. 5 6 use alloc::string::String; 7 8 use nostr::nips::nip19::{FromBech32, ToBech32}; 9 use radroots_identity::PublicKey; 10 11 use crate::Error; 12 13 /// Converts a canonical Radroots public key into its Nostr representation. 14 pub fn public_key_to_nostr(public_key: PublicKey) -> Result<nostr::PublicKey, Error> { 15 nostr::PublicKey::from_slice(public_key.as_bytes()).map_err(|_| Error::InvalidPublicKey) 16 } 17 18 /// Converts a Nostr public key into the canonical Radroots representation. 19 pub fn public_key_from_nostr(public_key: nostr::PublicKey) -> Result<PublicKey, Error> { 20 PublicKey::from_bytes(public_key.to_bytes()).map_err(|_| Error::InvalidPublicKey) 21 } 22 23 /// Encodes a canonical Radroots public key as a NIP-19 `npub`. 24 pub fn public_key_to_npub(public_key: PublicKey) -> Result<String, Error> { 25 let public_key = public_key_to_nostr(public_key)?; 26 match public_key.to_bech32() { 27 Ok(encoded) => Ok(encoded), 28 Err(error) => match error {}, 29 } 30 } 31 32 /// Decodes a NIP-19 `npub` into the canonical Radroots public-key value. 33 pub fn public_key_from_npub(encoded: &str) -> Result<PublicKey, Error> { 34 nostr::PublicKey::from_bech32(encoded) 35 .map_err(|_| Error::InvalidNpub) 36 .and_then(public_key_from_nostr) 37 } 38 39 /// Parses a canonical hexadecimal public key or a NIP-19 `npub`. 40 pub fn parse_public_key(encoded: &str) -> Result<PublicKey, Error> { 41 if encoded.starts_with("npub1") { 42 public_key_from_npub(encoded) 43 } else { 44 PublicKey::from_hex(encoded).map_err(|_| Error::InvalidPublicKey) 45 } 46 } 47 48 /// An opaque local Nostr secret key. 49 /// 50 /// The value does not implement `Clone`, serialization, or unrestricted 51 /// plaintext access. Debug output is always redacted; the concrete local 52 /// signing adapter consumes the value through crate-private integration. 53 /// 54 /// ```compile_fail 55 /// use radroots_nostr::key::SecretKey; 56 /// 57 /// let key = SecretKey::parse( 58 /// "0000000000000000000000000000000000000000000000000000000000000001", 59 /// )?; 60 /// let _duplicate = key.clone(); 61 /// # Ok::<(), radroots_nostr::Error>(()) 62 /// ``` 63 #[cfg(feature = "signing")] 64 pub struct SecretKey { 65 inner: nostr::SecretKey, 66 } 67 68 #[cfg(feature = "signing")] 69 impl SecretKey { 70 /// Generates a fresh local secret without exposing its representation. 71 #[must_use] 72 pub fn generate() -> Self { 73 Self { 74 inner: nostr::SecretKey::generate(), 75 } 76 } 77 78 /// Parses exact hexadecimal or NIP-19 `nsec` text. 79 /// 80 /// Errors never retain or render the supplied secret material. 81 pub fn parse(encoded: &str) -> Result<Self, Error> { 82 nostr::SecretKey::parse(encoded) 83 .map(|inner| Self { inner }) 84 .map_err(|_| Error::InvalidSecretKey) 85 } 86 87 /// Derives the canonical public identity without exposing secret bytes. 88 pub fn public_key(&self) -> Result<PublicKey, Error> { 89 let public_key = nostr::Keys::new(self.inner.clone()).public_key(); 90 public_key_from_nostr(public_key) 91 } 92 93 pub(crate) fn into_keys(self) -> nostr::Keys { 94 nostr::Keys::new(self.inner) 95 } 96 } 97 98 #[cfg(feature = "signing")] 99 impl core::fmt::Debug for SecretKey { 100 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 101 formatter 102 .debug_tuple("SecretKey") 103 .field(&"[redacted]") 104 .finish() 105 } 106 } 107 108 /// NIP-49 metadata describing how the plaintext key was previously handled. 109 #[cfg(feature = "signing")] 110 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] 111 pub enum Nip49KeySecurity { 112 /// The plaintext key is known to have been handled insecurely. 113 Weak, 114 /// The plaintext key is not known to have been handled insecurely. 115 Medium, 116 /// The caller does not track plaintext-key handling. 117 #[default] 118 Unknown, 119 } 120 121 #[cfg(feature = "signing")] 122 impl From<Nip49KeySecurity> for nostr::nips::nip49::KeySecurity { 123 fn from(value: Nip49KeySecurity) -> Self { 124 match value { 125 Nip49KeySecurity::Weak => Self::Weak, 126 Nip49KeySecurity::Medium => Self::Medium, 127 Nip49KeySecurity::Unknown => Self::Unknown, 128 } 129 } 130 } 131 132 /// Explicit NIP-49 encryption parameters. 133 #[cfg(feature = "signing")] 134 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 135 pub struct Nip49Options { 136 log_n: u8, 137 key_security: Nip49KeySecurity, 138 } 139 140 #[cfg(feature = "signing")] 141 impl Nip49Options { 142 /// Creates NIP-49 options with an explicit scrypt `log2(N)` work factor. 143 #[must_use] 144 pub const fn new(log_n: u8, key_security: Nip49KeySecurity) -> Self { 145 Self { 146 log_n, 147 key_security, 148 } 149 } 150 151 /// Returns the scrypt `log2(N)` work factor. 152 #[must_use] 153 pub const fn log_n(self) -> u8 { 154 self.log_n 155 } 156 157 /// Returns the NIP-49 plaintext-key handling metadata. 158 #[must_use] 159 pub const fn key_security(self) -> Nip49KeySecurity { 160 self.key_security 161 } 162 } 163 164 #[cfg(feature = "signing")] 165 impl Default for Nip49Options { 166 fn default() -> Self { 167 Self::new(16, Nip49KeySecurity::Unknown) 168 } 169 } 170 171 /// Parses a Nostr secret key from exact hexadecimal or NIP-19 `nsec` text. 172 /// 173 /// Errors never retain or render the supplied secret material. 174 #[cfg(feature = "signing")] 175 pub fn parse_secret_key(encoded: &str) -> Result<SecretKey, Error> { 176 SecretKey::parse(encoded) 177 } 178 179 /// Encodes a Nostr secret key as NIP-19 `nsec` text. 180 #[cfg(feature = "signing")] 181 pub fn secret_key_to_nsec(secret_key: &SecretKey) -> String { 182 match secret_key.inner.to_bech32() { 183 Ok(encoded) => encoded, 184 Err(error) => match error {}, 185 } 186 } 187 188 /// Encrypts a Nostr secret key into a NIP-49 `ncryptsec` payload. 189 #[cfg(feature = "signing")] 190 pub fn encrypt_secret_key_nip49(secret_key: &SecretKey, password: &str) -> Result<String, Error> { 191 encrypt_secret_key_nip49_with_options(secret_key, password, Nip49Options::default()) 192 } 193 194 /// Encrypts a Nostr secret key with explicit NIP-49 parameters. 195 #[cfg(feature = "signing")] 196 pub fn encrypt_secret_key_nip49_with_options( 197 secret_key: &SecretKey, 198 password: &str, 199 options: Nip49Options, 200 ) -> Result<String, Error> { 201 let encrypted = nostr::nips::nip49::EncryptedSecretKey::new( 202 &secret_key.inner, 203 password, 204 options.log_n, 205 options.key_security.into(), 206 ) 207 .map_err(|_| Error::SecretKeyEncryption)?; 208 encrypted 209 .to_bech32() 210 .map_err(|_| Error::SecretKeyEncryption) 211 } 212 213 /// Decrypts a NIP-49 `ncryptsec` payload into a Nostr secret key. 214 /// 215 /// Parse, password, and ciphertext failures are deliberately normalized so 216 /// diagnostics never retain the encrypted payload, password, or plaintext. 217 #[cfg(feature = "signing")] 218 pub fn decrypt_secret_key_nip49(encrypted: &str, password: &str) -> Result<SecretKey, Error> { 219 nostr::nips::nip49::EncryptedSecretKey::from_bech32(encrypted) 220 .map_err(|_| Error::InvalidEncryptedSecretKey)? 221 .decrypt(password) 222 .map(|inner| SecretKey { inner }) 223 .map_err(|_| Error::SecretKeyDecryption) 224 } 225 226 #[cfg(test)] 227 mod tests { 228 use super::*; 229 use crate::test_fixtures::FIXTURE_ALICE; 230 231 #[cfg(feature = "signing")] 232 const NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; 233 #[cfg(feature = "signing")] 234 const NCRYPTSEC_SECRET_HEX: &str = 235 "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683"; 236 237 #[test] 238 fn native_public_key_round_trips_through_nostr_hex_and_npub() { 239 let native = 240 PublicKey::from_hex(FIXTURE_ALICE.public_key_hex).expect("native public key fixture"); 241 let nostr = public_key_to_nostr(native).expect("Nostr public key"); 242 243 assert_eq!(nostr.to_hex(), FIXTURE_ALICE.public_key_hex); 244 assert_eq!( 245 public_key_from_nostr(nostr).expect("native public key"), 246 native 247 ); 248 assert_eq!( 249 public_key_to_npub(native).expect("npub"), 250 FIXTURE_ALICE.npub 251 ); 252 assert_eq!( 253 public_key_from_npub(FIXTURE_ALICE.npub).expect("native npub"), 254 native 255 ); 256 assert_eq!( 257 parse_public_key(FIXTURE_ALICE.public_key_hex).expect("hex public key"), 258 native 259 ); 260 assert_eq!( 261 parse_public_key(FIXTURE_ALICE.npub).expect("npub public key"), 262 native 263 ); 264 } 265 266 #[test] 267 fn public_key_parsing_rejects_wrong_nip19_kinds_without_echoing_input() { 268 let invalid = "nsec1-do-not-disclose-public-key-input"; 269 let error = parse_public_key(invalid).expect_err("secret HRP is not a public key"); 270 271 assert!(matches!(error, Error::InvalidPublicKey)); 272 assert!(!error.to_string().contains(invalid)); 273 assert!(!format!("{error:?}").contains(invalid)); 274 275 let malformed_npub = "npub1-do-not-disclose-public-key-input"; 276 let error = public_key_from_npub(malformed_npub).expect_err("malformed npub"); 277 assert!(matches!(error, Error::InvalidNpub)); 278 assert!(!error.to_string().contains(malformed_npub)); 279 assert!(!format!("{error:?}").contains(malformed_npub)); 280 } 281 282 #[cfg(feature = "signing")] 283 #[test] 284 fn secret_key_hex_and_nsec_vectors_round_trip() { 285 let from_hex = parse_secret_key(FIXTURE_ALICE.secret_key_hex).expect("hex secret key"); 286 let from_nsec = parse_secret_key(FIXTURE_ALICE.nsec).expect("nsec secret key"); 287 288 assert_eq!(secret_key_to_nsec(&from_hex), FIXTURE_ALICE.nsec); 289 assert_eq!(secret_key_to_nsec(&from_nsec), FIXTURE_ALICE.nsec); 290 assert_eq!( 291 from_hex.public_key().expect("public key").to_hex(), 292 FIXTURE_ALICE.public_key_hex 293 ); 294 for rendered in [format!("{from_hex:?}"), format!("{from_nsec:?}")] { 295 assert!(rendered.contains("[redacted]")); 296 assert!(!rendered.contains(FIXTURE_ALICE.secret_key_hex)); 297 assert!(!rendered.contains(FIXTURE_ALICE.nsec)); 298 } 299 } 300 301 #[cfg(feature = "signing")] 302 #[test] 303 fn nip49_known_vector_decrypts_and_round_trips_with_explicit_options() { 304 let decrypted = decrypt_secret_key_nip49(NCRYPTSEC, "nostr").expect("known ncryptsec"); 305 let expected = parse_secret_key(NCRYPTSEC_SECRET_HEX).expect("known secret key"); 306 assert_eq!( 307 secret_key_to_nsec(&decrypted), 308 secret_key_to_nsec(&expected) 309 ); 310 311 let options = Nip49Options::new(10, Nip49KeySecurity::Medium); 312 assert_eq!(options.log_n(), 10); 313 assert_eq!(options.key_security(), Nip49KeySecurity::Medium); 314 let encrypted = encrypt_secret_key_nip49_with_options(&decrypted, "test-password", options) 315 .expect("encrypt ncryptsec"); 316 let round_trip = 317 decrypt_secret_key_nip49(&encrypted, "test-password").expect("decrypt ncryptsec"); 318 assert_eq!( 319 secret_key_to_nsec(&round_trip), 320 secret_key_to_nsec(&decrypted) 321 ); 322 } 323 324 #[cfg(feature = "signing")] 325 #[test] 326 fn secret_failures_are_redacted() { 327 let invalid_secret = "nsec1-do-not-disclose-secret-input"; 328 let parse_error = parse_secret_key(invalid_secret).expect_err("invalid secret"); 329 assert!(matches!(parse_error, Error::InvalidSecretKey)); 330 assert!(!parse_error.to_string().contains(invalid_secret)); 331 assert!(!format!("{parse_error:?}").contains(invalid_secret)); 332 333 let password = "do-not-disclose-password"; 334 let decrypt_error = 335 decrypt_secret_key_nip49(NCRYPTSEC, password).expect_err("wrong password"); 336 assert!(matches!(decrypt_error, Error::SecretKeyDecryption)); 337 for rendered in [decrypt_error.to_string(), format!("{decrypt_error:?}")] { 338 assert!(!rendered.contains(password)); 339 assert!(!rendered.contains(NCRYPTSEC)); 340 assert!(!rendered.contains(NCRYPTSEC_SECRET_HEX)); 341 } 342 343 let encrypted_error = 344 decrypt_secret_key_nip49(invalid_secret, password).expect_err("invalid ncryptsec"); 345 assert!(matches!(encrypted_error, Error::InvalidEncryptedSecretKey)); 346 assert!(!encrypted_error.to_string().contains(invalid_secret)); 347 assert!(!format!("{encrypted_error:?}").contains(invalid_secret)); 348 } 349 }