app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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 }