lib

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

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 }