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:
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();