key.rs (14645B)
1 //! Validated Nostr public and secret-key boundary values. 2 3 use std::fmt::{self, Display, Formatter}; 4 use std::str::FromStr; 5 6 use secrecy::{ExposeSecret, SecretString}; 7 use zeroize::Zeroizing; 8 9 use crate::{SafeError, SafeErrorCode, SafeMessage}; 10 11 pub const PUBLIC_KEY_BYTE_LENGTH: usize = 32; 12 pub const PUBLIC_KEY_HEX_LENGTH: usize = PUBLIC_KEY_BYTE_LENGTH * 2; 13 pub const MAX_SECRET_KEY_INPUT_BYTES: usize = 128; 14 const NIP19_KEY_LENGTH: usize = 63; 15 const BECH32_DATA_CHARSET: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l"; 16 17 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] 18 pub struct Npub(String); 19 20 impl Npub { 21 /// Constructs a human-facing npub after structural validation. 22 /// 23 /// Cryptographic conversion and checksum validation are performed by the 24 /// selected Nostr adapter before this domain value is created in runtime 25 /// flows. 26 /// 27 /// # Errors 28 /// 29 /// Returns a safe invalid-public-key error for a malformed npub shape. 30 pub fn from_encoded(value: String) -> Result<Self, SafeError> { 31 if !is_nip19_key_shape(&value, "npub1") { 32 return Err(invalid_public_key()); 33 } 34 Ok(Self(value)) 35 } 36 37 /// Derives the canonical NIP-19 display identity from a public key. 38 /// 39 /// # Errors 40 /// 41 /// Returns a safe public-key error if canonical encoding fails. 42 pub fn derive(public_key: PublicKey) -> Result<Self, SafeError> { 43 let hrp = bech32::Hrp::parse("npub").map_err(|_| invalid_public_key())?; 44 bech32::encode::<bech32::Bech32>(hrp, public_key.as_bytes()) 45 .map_err(|_| invalid_public_key()) 46 .and_then(Self::from_encoded) 47 } 48 49 /// Validates that encoded display identity belongs to the canonical key. 50 /// 51 /// # Errors 52 /// 53 /// Returns a safe public-key error when the values do not match. 54 pub fn verify(public_key: PublicKey, encoded: String) -> Result<Self, SafeError> { 55 let candidate = Self::from_encoded(encoded)?; 56 if candidate != Self::derive(public_key)? { 57 return Err(invalid_public_key()); 58 } 59 Ok(candidate) 60 } 61 62 #[must_use] 63 pub fn as_str(&self) -> &str { 64 &self.0 65 } 66 67 #[must_use] 68 pub fn short(&self) -> String { 69 format!("{}…{}", &self.0[..12], &self.0[self.0.len() - 8..]) 70 } 71 } 72 73 impl Display for Npub { 74 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { 75 formatter.write_str(&self.0) 76 } 77 } 78 79 pub struct Nsec(SecretString); 80 81 impl Nsec { 82 /// Constructs a secret nsec display value after structural validation. 83 /// 84 /// # Errors 85 /// 86 /// Returns a safe invalid-secret-key error for a malformed nsec shape. 87 pub fn from_encoded(value: String) -> Result<Self, SafeError> { 88 if !is_nip19_key_shape(&value, "nsec1") { 89 return Err(invalid_secret_key()); 90 } 91 Ok(Self(SecretString::from(value))) 92 } 93 94 pub fn with_exposed_secret<T>(&self, operation: impl FnOnce(&str) -> T) -> T { 95 operation(self.0.expose_secret()) 96 } 97 } 98 99 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 100 pub enum SecretKeyInputKind { 101 Nsec, 102 Hex, 103 } 104 105 pub struct SecretKeyInput { 106 value: SecretString, 107 kind: SecretKeyInputKind, 108 } 109 110 impl SecretKeyInput { 111 /// Moves bounded transport bytes into the zeroizing secret boundary. 112 /// 113 /// The source byte allocation is cleared on every return path. 114 /// 115 /// # Errors 116 /// 117 /// Returns a safe invalid-secret-key error for oversized, non-UTF-8, or 118 /// structurally invalid input. 119 pub fn parse_bytes(value: Vec<u8>) -> Result<Self, SafeError> { 120 let value = Zeroizing::new(value); 121 if value.len() > MAX_SECRET_KEY_INPUT_BYTES { 122 return Err(invalid_secret_key()); 123 } 124 let encoded = std::str::from_utf8(&value).map_err(|_| invalid_secret_key())?; 125 Self::parse(encoded.to_owned()) 126 } 127 128 /// Moves one secret input string into a zeroizing boundary. 129 /// 130 /// Nsec inputs receive complete NIP-19 validation in the Nostr adapter. 131 /// Hex input is structurally validated here to prevent ambiguous fallback. 132 /// 133 /// # Errors 134 /// 135 /// Returns a safe invalid-secret-key error when the input is neither an 136 /// nsec-looking value nor exactly 64 lowercase hexadecimal characters. 137 pub fn parse(value: String) -> Result<Self, SafeError> { 138 let mut value = Zeroizing::new(value); 139 let kind = if value.len() == PUBLIC_KEY_HEX_LENGTH 140 && value 141 .bytes() 142 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 143 { 144 SecretKeyInputKind::Hex 145 } else if is_nip19_key_shape(&value, "nsec1") { 146 SecretKeyInputKind::Nsec 147 } else { 148 return Err(invalid_secret_key()); 149 }; 150 151 Ok(Self { 152 value: SecretString::from(std::mem::take(&mut *value)), 153 kind, 154 }) 155 } 156 157 #[must_use] 158 pub const fn kind(&self) -> SecretKeyInputKind { 159 self.kind 160 } 161 162 pub fn with_exposed_secret<T>(&self, operation: impl FnOnce(&str) -> T) -> T { 163 operation(self.value.expose_secret()) 164 } 165 } 166 167 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] 168 pub struct PublicKey(radroots_identity::PublicKey); 169 170 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 171 pub enum PersistedPublicKeyClassification { 172 Canonical(PublicKey), 173 NonCanonicalEncoding, 174 InvalidCurvePoint, 175 MalformedEncoding, 176 } 177 178 impl PublicKey { 179 /// Validates canonical x-only secp256k1 public-key bytes. 180 /// 181 /// # Errors 182 /// 183 /// Returns a safe invalid-public-key error when the bytes are not a valid 184 /// x-only secp256k1 point. 185 pub fn from_bytes(bytes: [u8; PUBLIC_KEY_BYTE_LENGTH]) -> Result<Self, SafeError> { 186 radroots_identity::PublicKey::from_bytes(bytes) 187 .map(Self) 188 .map_err(|_| invalid_public_key()) 189 } 190 191 /// Parses a canonical lowercase hexadecimal Nostr public key. 192 /// 193 /// # Errors 194 /// 195 /// Returns a safe invalid-public-key error when the value is not exactly 196 /// 64 lowercase hexadecimal characters. 197 pub fn from_hex(value: &str) -> Result<Self, SafeError> { 198 if value.len() != PUBLIC_KEY_HEX_LENGTH 199 || !value 200 .bytes() 201 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 202 { 203 return Err(invalid_public_key()); 204 } 205 206 radroots_identity::PublicKey::from_hex(value) 207 .map(Self) 208 .map_err(|_| invalid_public_key()) 209 } 210 211 #[must_use] 212 pub const fn as_bytes(&self) -> &[u8; PUBLIC_KEY_BYTE_LENGTH] { 213 self.0.as_bytes() 214 } 215 216 #[must_use] 217 pub const fn canonical(self) -> radroots_identity::PublicKey { 218 self.0 219 } 220 221 #[must_use] 222 pub const fn from_canonical(public_key: radroots_identity::PublicKey) -> Self { 223 Self(public_key) 224 } 225 226 #[must_use] 227 pub fn to_hex(self) -> String { 228 self.0.to_hex() 229 } 230 231 #[must_use] 232 pub fn short_hex(self) -> String { 233 let hex = self.to_hex(); 234 format!("{}…{}", &hex[..8], &hex[PUBLIC_KEY_HEX_LENGTH - 8..]) 235 } 236 } 237 238 impl Display for PublicKey { 239 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { 240 formatter.write_str(&self.to_hex()) 241 } 242 } 243 244 impl From<radroots_identity::PublicKey> for PublicKey { 245 fn from(value: radroots_identity::PublicKey) -> Self { 246 Self::from_canonical(value) 247 } 248 } 249 250 impl From<PublicKey> for radroots_identity::PublicKey { 251 fn from(value: PublicKey) -> Self { 252 value.canonical() 253 } 254 } 255 256 impl FromStr for PublicKey { 257 type Err = SafeError; 258 259 fn from_str(value: &str) -> Result<Self, Self::Err> { 260 Self::from_hex(value) 261 } 262 } 263 264 const fn invalid_public_key() -> SafeError { 265 SafeError::new( 266 SafeErrorCode::InvalidPublicKey, 267 SafeMessage::new("The Nostr public key is invalid."), 268 ) 269 } 270 271 const fn invalid_secret_key() -> SafeError { 272 SafeError::new( 273 SafeErrorCode::InvalidSecretKey, 274 SafeMessage::new("The Nostr secret key is invalid."), 275 ) 276 } 277 278 #[must_use] 279 pub fn classify_persisted_public_key(value: &str) -> PersistedPublicKeyClassification { 280 if value.len() != PUBLIC_KEY_HEX_LENGTH || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { 281 return PersistedPublicKeyClassification::MalformedEncoding; 282 } 283 if !value 284 .bytes() 285 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 286 { 287 return PersistedPublicKeyClassification::NonCanonicalEncoding; 288 } 289 match PublicKey::from_hex(value) { 290 Ok(public_key) => PersistedPublicKeyClassification::Canonical(public_key), 291 Err(_) => PersistedPublicKeyClassification::InvalidCurvePoint, 292 } 293 } 294 295 fn is_nip19_key_shape(value: &str, prefix: &str) -> bool { 296 value.len() == NIP19_KEY_LENGTH 297 && value.starts_with(prefix) 298 && value[prefix.len()..] 299 .bytes() 300 .all(|byte| BECH32_DATA_CHARSET.contains(&byte)) 301 } 302 303 #[cfg(test)] 304 mod tests { 305 use std::str::FromStr; 306 307 use super::{ 308 MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PUBLIC_KEY_BYTE_LENGTH, 309 PersistedPublicKeyClassification, PublicKey, SecretKeyInput, SecretKeyInputKind, 310 classify_persisted_public_key, 311 }; 312 use crate::SafeErrorCode; 313 314 const HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; 315 const NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"; 316 const NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; 317 318 #[test] 319 fn public_key_round_trips_canonical_hex_and_bytes() { 320 let key = PublicKey::from_str(HEX).expect("valid public key"); 321 322 assert_eq!(key.to_hex(), HEX); 323 assert_eq!(key.to_string(), HEX); 324 assert_eq!(key.short_hex(), "7e7e9c42…2107f6d7"); 325 assert_eq!( 326 PublicKey::from_bytes(*key.as_bytes()).expect("valid bytes"), 327 key 328 ); 329 assert_eq!(key.as_bytes().len(), PUBLIC_KEY_BYTE_LENGTH); 330 } 331 332 #[test] 333 fn public_key_rejects_noncanonical_or_malformed_hex() { 334 for value in [ 335 "", 336 "00", 337 "7E7E9C42A91BFEF19FA7EA99D52D8AFDB67D893A8FEFBA1F5CB9793F2107F6D7", 338 "ze7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", 339 " 7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", 340 "00e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", 341 ] { 342 let error = PublicKey::from_hex(value).expect_err("invalid public key"); 343 assert_eq!(error.code(), SafeErrorCode::InvalidPublicKey); 344 } 345 } 346 347 #[test] 348 fn public_keys_are_ordered_by_canonical_bytes() { 349 let low = 350 PublicKey::from_hex("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df") 351 .expect("low key"); 352 let high = 353 PublicKey::from_hex("e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af") 354 .expect("high key"); 355 356 assert!(low < high); 357 } 358 359 #[test] 360 fn persisted_public_key_classification_is_explicit_and_fail_closed() { 361 assert!(matches!( 362 classify_persisted_public_key(HEX), 363 PersistedPublicKeyClassification::Canonical(_) 364 )); 365 assert_eq!( 366 classify_persisted_public_key(HEX.to_ascii_uppercase().as_str()), 367 PersistedPublicKeyClassification::NonCanonicalEncoding 368 ); 369 assert_eq!( 370 classify_persisted_public_key(&"00".repeat(PUBLIC_KEY_BYTE_LENGTH)), 371 PersistedPublicKeyClassification::InvalidCurvePoint 372 ); 373 assert_eq!( 374 classify_persisted_public_key("not-a-public-key"), 375 PersistedPublicKeyClassification::MalformedEncoding 376 ); 377 } 378 379 #[test] 380 fn secret_input_is_redacted_and_exposed_only_to_a_scoped_operation() { 381 let secret = "11".repeat(PUBLIC_KEY_BYTE_LENGTH); 382 let input = SecretKeyInput::parse(secret.clone()).expect("valid secret hex"); 383 384 assert_eq!(input.kind(), SecretKeyInputKind::Hex); 385 assert_eq!(input.with_exposed_secret(str::len), secret.len()); 386 assert_eq!(input.with_exposed_secret(str::len), 64); 387 } 388 389 #[test] 390 fn secret_input_accepts_nsec_shape_without_exposing_it() { 391 let secret = NSEC.to_owned(); 392 let input = SecretKeyInput::parse(secret.clone()).expect("nsec-shaped input"); 393 394 assert_eq!(input.kind(), SecretKeyInputKind::Nsec); 395 assert_eq!(input.with_exposed_secret(str::len), secret.len()); 396 } 397 398 #[test] 399 fn secret_input_rejects_invalid_hex_and_arbitrary_text() { 400 for value in [ 401 "", 402 "very-sensitive-input", 403 &"GG".repeat(PUBLIC_KEY_BYTE_LENGTH), 404 ] { 405 let Err(error) = SecretKeyInput::parse(value.to_owned()) else { 406 panic!("invalid secret accepted"); 407 }; 408 assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); 409 if !value.is_empty() { 410 assert!(!format!("{error:?}").contains(value)); 411 } 412 } 413 } 414 415 #[test] 416 fn secret_byte_transport_is_bounded_and_validated() { 417 let parsed = SecretKeyInput::parse_bytes(HEX.as_bytes().to_vec()).expect("bytes"); 418 assert_eq!(parsed.with_exposed_secret(str::len), 64); 419 assert!(SecretKeyInput::parse_bytes(vec![0xff]).is_err()); 420 assert!(SecretKeyInput::parse_bytes(vec![b'a'; MAX_SECRET_KEY_INPUT_BYTES + 1]).is_err()); 421 } 422 423 #[test] 424 fn npub_is_public_display_data_but_not_canonical_identity() { 425 let npub = Npub::from_encoded(NPUB.to_owned()).expect("valid npub shape"); 426 427 assert_eq!(npub.as_str(), NPUB); 428 assert_eq!(npub.to_string(), NPUB); 429 } 430 431 #[test] 432 fn nsec_is_redacted_and_exposed_only_to_a_scoped_operation() { 433 let nsec = Nsec::from_encoded(NSEC.to_owned()).expect("valid nsec shape"); 434 435 assert_eq!(nsec.with_exposed_secret(str::len), NSEC.len()); 436 assert_eq!(nsec.with_exposed_secret(str::len), NSEC.len()); 437 } 438 439 #[test] 440 fn nip19_display_types_reject_wrong_prefix_length_and_charset() { 441 for invalid in [ 442 "", 443 "npub1short", 444 "nsec1short", 445 "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjp!g", 446 ] { 447 assert!(Npub::from_encoded(invalid.to_owned()).is_err()); 448 assert!(Nsec::from_encoded(invalid.to_owned()).is_err()); 449 } 450 } 451 }