lib

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

commit aa50e41dad8884372c6c0258d256301c405f98f4
parent b0240e02c7e0ef9fcbd27e43c3ea138955d9f4cd
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 15:39:19 +0000

identity: add ncryptsec secret key support

Diffstat:
Mcrates/identity/Cargo.toml | 3++-
Mcrates/identity/src/error.rs | 12++++++++++++
Mcrates/identity/src/identity.rs | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/identity/src/lib.rs | 4++++
Mcrates/identity/tests/identity.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 162 insertions(+), 1 deletion(-)

diff --git a/crates/identity/Cargo.toml b/crates/identity/Cargo.toml @@ -15,10 +15,11 @@ readme.workspace = true build = "build.rs" [features] -default = ["std", "profile", "json-file"] +default = ["std", "profile", "json-file", "nip49"] std = [] profile = ["dep:radroots-events"] json-file = ["std", "dep:radroots-runtime"] +nip49 = ["std", "nostr/nip49"] secrecy = ["dep:secrecy"] zeroize = ["dep:zeroize"] ts-rs = ["dep:ts-rs"] diff --git a/crates/identity/src/error.rs b/crates/identity/src/error.rs @@ -31,6 +31,18 @@ pub enum IdentityError { #[error("invalid secret key: {0}")] InvalidSecretKey(#[from] nostr::key::Error), + #[cfg(feature = "nip49")] + #[error("failed to encrypt secret key: {0}")] + EncryptSecretKey(String), + + #[cfg(feature = "nip49")] + #[error("invalid encrypted secret key: {0}")] + InvalidEncryptedSecretKey(String), + + #[cfg(feature = "nip49")] + #[error("failed to decrypt encrypted secret key: {0}")] + DecryptEncryptedSecretKey(String), + #[error("invalid public key: {0}")] InvalidPublicKey(String), diff --git a/crates/identity/src/identity.rs b/crates/identity/src/identity.rs @@ -2,6 +2,11 @@ use crate::error::IdentityError; use core::convert::Infallible; use core::fmt; use nostr::{Keys, SecretKey}; +#[cfg(feature = "nip49")] +use nostr::{ + nips::nip19::{FromBech32, ToBech32}, + nips::nip49::{EncryptedSecretKey, KeySecurity}, +}; #[cfg(feature = "profile")] use radroots_events::profile::RadrootsProfile; use serde::{Deserialize, Serialize}; @@ -70,6 +75,43 @@ pub enum RadrootsIdentitySecretKeyFormat { Nsec, } +#[cfg(feature = "nip49")] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum RadrootsIdentityEncryptedSecretKeySecurity { + Weak, + Medium, + #[default] + Unknown, +} + +#[cfg(feature = "nip49")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RadrootsIdentityEncryptedSecretKeyOptions { + pub log_n: u8, + pub key_security: RadrootsIdentityEncryptedSecretKeySecurity, +} + +#[cfg(feature = "nip49")] +impl Default for RadrootsIdentityEncryptedSecretKeyOptions { + fn default() -> Self { + Self { + log_n: 16, + key_security: RadrootsIdentityEncryptedSecretKeySecurity::Unknown, + } + } +} + +#[cfg(feature = "nip49")] +impl From<RadrootsIdentityEncryptedSecretKeySecurity> for KeySecurity { + fn from(value: RadrootsIdentityEncryptedSecretKeySecurity) -> Self { + match value { + RadrootsIdentityEncryptedSecretKeySecurity::Weak => Self::Weak, + RadrootsIdentityEncryptedSecretKeySecurity::Medium => Self::Medium, + RadrootsIdentityEncryptedSecretKeySecurity::Unknown => Self::Unknown, + } + } +} + impl RadrootsIdentityId { pub fn from_public_key(public_key: nostr::PublicKey) -> Self { Self(public_key.to_hex()) @@ -221,6 +263,32 @@ impl RadrootsIdentity { self.secret_key_nsec() } + #[cfg(feature = "nip49")] + pub fn encrypt_secret_key_ncryptsec(&self, password: &str) -> Result<String, IdentityError> { + self.encrypt_secret_key_ncryptsec_with_options( + password, + RadrootsIdentityEncryptedSecretKeyOptions::default(), + ) + } + + #[cfg(feature = "nip49")] + pub fn encrypt_secret_key_ncryptsec_with_options( + &self, + password: &str, + options: RadrootsIdentityEncryptedSecretKeyOptions, + ) -> Result<String, IdentityError> { + let encrypted = EncryptedSecretKey::new( + self.keys.secret_key(), + password, + options.log_n, + options.key_security.into(), + ) + .map_err(|source| IdentityError::EncryptSecretKey(source.to_string()))?; + encrypted + .to_bech32() + .map_err(|source| IdentityError::EncryptSecretKey(source.to_string())) + } + pub fn secret_key_bytes(&self) -> [u8; SecretKey::LEN] { self.keys.secret_key().to_secret_bytes() } @@ -328,6 +396,19 @@ impl RadrootsIdentity { Ok(Self::new(Keys::parse(secret_key)?)) } + #[cfg(feature = "nip49")] + pub fn from_encrypted_secret_key_str( + secret_key: &str, + password: &str, + ) -> Result<Self, IdentityError> { + let encrypted = EncryptedSecretKey::from_bech32(secret_key) + .map_err(|source| IdentityError::InvalidEncryptedSecretKey(source.to_string()))?; + let secret_key = encrypted + .decrypt(password) + .map_err(|source| IdentityError::DecryptEncryptedSecretKey(source.to_string()))?; + Ok(Self::new(Keys::new(secret_key))) + } + #[cfg(feature = "std")] pub fn from_secret_key_bytes(secret_key: &[u8]) -> Result<Self, IdentityError> { if secret_key.len() != SecretKey::LEN { diff --git a/crates/identity/src/lib.rs b/crates/identity/src/lib.rs @@ -13,6 +13,10 @@ pub use identity::{ DEFAULT_IDENTITY_PATH, RadrootsIdentity, RadrootsIdentityFile, RadrootsIdentityId, RadrootsIdentityProfile, RadrootsIdentityPublic, RadrootsIdentitySecretKeyFormat, }; +#[cfg(feature = "nip49")] +pub use identity::{ + RadrootsIdentityEncryptedSecretKeyOptions, RadrootsIdentityEncryptedSecretKeySecurity, +}; pub use username::{ RADROOTS_USERNAME_MAX_LEN, RADROOTS_USERNAME_MIN_LEN, RADROOTS_USERNAME_REGEX, radroots_username_is_valid, radroots_username_normalize, diff --git a/crates/identity/tests/identity.rs b/crates/identity/tests/identity.rs @@ -3,6 +3,10 @@ use radroots_identity::{ DEFAULT_IDENTITY_PATH, IdentityError, RadrootsIdentity, RadrootsIdentityId, RadrootsIdentityProfile, RadrootsIdentityPublic, RadrootsIdentitySecretKeyFormat, }; +#[cfg(feature = "nip49")] +use radroots_identity::{ + RadrootsIdentityEncryptedSecretKeyOptions, RadrootsIdentityEncryptedSecretKeySecurity, +}; use std::path::PathBuf; fn profile_with_identifier(value: &str) -> RadrootsIdentityProfile { @@ -267,6 +271,65 @@ fn identity_accessor_paths_and_secret_formats() { assert_eq!(roundtrip_keys.public_key(), keys.public_key()); } +#[cfg(feature = "nip49")] +#[test] +fn encrypted_secret_key_round_trips_to_identity() { + let identity = RadrootsIdentity::generate(); + let encrypted = identity + .encrypt_secret_key_ncryptsec("fixture-password") + .unwrap(); + assert!(encrypted.starts_with("ncryptsec1")); + + let decrypted = + RadrootsIdentity::from_encrypted_secret_key_str(&encrypted, "fixture-password").unwrap(); + assert_eq!(decrypted.public_key(), identity.public_key()); +} + +#[cfg(feature = "nip49")] +#[test] +fn encrypted_secret_key_options_propagate_to_output() { + use nostr::nips::nip19::FromBech32; + use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity}; + + let identity = RadrootsIdentity::generate(); + let encrypted = identity + .encrypt_secret_key_ncryptsec_with_options( + "fixture-password", + RadrootsIdentityEncryptedSecretKeyOptions { + log_n: 15, + key_security: RadrootsIdentityEncryptedSecretKeySecurity::Medium, + }, + ) + .unwrap(); + let parsed = EncryptedSecretKey::from_bech32(&encrypted).unwrap(); + assert_eq!(parsed.log_n(), 15); + assert_eq!(parsed.key_security(), KeySecurity::Medium); +} + +#[cfg(feature = "nip49")] +#[test] +fn encrypted_secret_key_rejects_invalid_and_wrong_password_inputs() { + let identity = RadrootsIdentity::generate(); + let encrypted = identity + .encrypt_secret_key_ncryptsec("fixture-password") + .unwrap(); + + let invalid = + RadrootsIdentity::from_encrypted_secret_key_str("not-an-encrypted-secret", "password") + .unwrap_err(); + assert!(matches!( + invalid, + IdentityError::InvalidEncryptedSecretKey(_) + )); + + let wrong_password = + RadrootsIdentity::from_encrypted_secret_key_str(&encrypted, "wrong-password").unwrap_err(); + assert!(matches!( + wrong_password, + IdentityError::DecryptEncryptedSecretKey(_) + )); +} + #[test] fn parse_failures_cover_public_key_errors() { let err_empty = RadrootsIdentityId::parse(" ").unwrap_err();