commit 3ff5f6ad0a06c45b3849a0220d3b23702a888657
parent 64e259b2df3717f05fb69b695aaf5acd4eb450b5
Author: triesap <tyson@radroots.org>
Date: Fri, 20 Feb 2026 16:33:11 +0000
identity: add public `radroots-identity` model and feature gated secrets
Diffstat:
6 files changed, 234 insertions(+), 24 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
@@ -99,6 +99,7 @@ ts-rs = { version = "11.1" }
typeshare = { version = "1" }
url = { version = "2" }
uuid = { version = "1.16.0", features = ["v4", "v7"] }
+zeroize = { version = "1" }
uniffi = { version = "=0.29.4" }
uniffi_build = { version = "=0.29.4" }
wasm-bindgen = { version = "0.2" }
diff --git a/crates/identity/Cargo.toml b/crates/identity/Cargo.toml
@@ -8,19 +8,25 @@ license.workspace = true
build = "build.rs"
[features]
-default = ["std"]
-std = ["dep:radroots-runtime"]
+default = ["std", "profile", "json-file"]
+std = []
+profile = ["dep:radroots-events"]
+json-file = ["std", "dep:radroots-runtime"]
+secrecy = ["dep:secrecy"]
+zeroize = ["dep:zeroize"]
ts-rs = ["dep:ts-rs"]
[dependencies]
radroots-runtime = { workspace = true, optional = true }
-radroots-events = { workspace = true, default-features = false, features = ["serde"] }
+radroots-events = { workspace = true, optional = true, default-features = false, features = ["serde"] }
nostr = { workspace = true }
+secrecy = { workspace = true, optional = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
ts-rs = { workspace = true, optional = true }
+zeroize = { workspace = true, optional = true }
[dev-dependencies]
tempfile = { workspace = true }
diff --git a/crates/identity/src/error.rs b/crates/identity/src/error.rs
@@ -3,7 +3,7 @@ use thiserror::Error;
#[cfg(not(feature = "std"))]
use alloc::string::String;
-#[cfg(feature = "std")]
+#[cfg(all(feature = "std", feature = "json-file"))]
use radroots_runtime::RuntimeJsonError;
#[cfg(feature = "std")]
use std::{io, path::PathBuf};
@@ -40,7 +40,7 @@ pub enum IdentityError {
#[error("unsupported identity file format")]
InvalidIdentityFormat,
- #[cfg(feature = "std")]
+ #[cfg(all(feature = "std", feature = "json-file"))]
#[error(transparent)]
Store(#[from] RuntimeJsonError),
}
diff --git a/crates/identity/src/identity.rs b/crates/identity/src/identity.rs
@@ -1,27 +1,40 @@
use crate::error::IdentityError;
use core::convert::Infallible;
+use core::fmt;
use nostr::{Keys, SecretKey};
-use radroots_events::profile::RadrootsProfile;
use serde::{Deserialize, Serialize};
+#[cfg(feature = "profile")]
+use radroots_events::profile::RadrootsProfile;
#[cfg(not(feature = "std"))]
use alloc::string::String;
-#[cfg(feature = "std")]
+#[cfg(all(feature = "std", feature = "json-file"))]
use radroots_runtime::JsonFile;
#[cfg(feature = "std")]
-use std::{
- fs,
- path::{Path, PathBuf},
-};
+use std::{fs, path::Path};
+#[cfg(all(feature = "std", feature = "json-file"))]
+use std::path::PathBuf;
pub const DEFAULT_IDENTITY_PATH: &str = "identity.json";
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct RadrootsIdentityId(String);
+
#[derive(Debug, Clone)]
pub struct RadrootsIdentity {
keys: Keys,
profile: Option<RadrootsIdentityProfile>,
}
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RadrootsIdentityPublic {
+ pub id: RadrootsIdentityId,
+ pub public_key_hex: String,
+ pub public_key_npub: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub profile: Option<RadrootsIdentityProfile>,
+}
+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RadrootsIdentityProfile {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -30,6 +43,7 @@ pub struct RadrootsIdentityProfile {
pub metadata: Option<nostr::Event>,
#[serde(skip_serializing_if = "Option::is_none")]
pub application_handler: Option<nostr::Event>,
+ #[cfg(feature = "profile")]
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<RadrootsProfile>,
}
@@ -45,6 +59,7 @@ pub struct RadrootsIdentityFile {
pub metadata: Option<nostr::Event>,
#[serde(skip_serializing_if = "Option::is_none")]
pub application_handler: Option<nostr::Event>,
+ #[cfg(feature = "profile")]
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<RadrootsProfile>,
}
@@ -55,12 +70,85 @@ pub enum RadrootsIdentitySecretKeyFormat {
Nsec,
}
+impl RadrootsIdentityId {
+ pub fn from_public_key(public_key: nostr::PublicKey) -> Self {
+ Self(public_key.to_hex())
+ }
+
+ pub fn parse(value: &str) -> Result<Self, IdentityError> {
+ let public_key = parse_public_key(value)?;
+ Ok(Self::from_public_key(public_key))
+ }
+
+ pub fn as_str(&self) -> &str {
+ self.0.as_str()
+ }
+
+ pub fn into_string(self) -> String {
+ self.0
+ }
+}
+
+impl From<nostr::PublicKey> for RadrootsIdentityId {
+ fn from(value: nostr::PublicKey) -> Self {
+ Self::from_public_key(value)
+ }
+}
+
+impl TryFrom<&str> for RadrootsIdentityId {
+ type Error = IdentityError;
+
+ fn try_from(value: &str) -> Result<Self, Self::Error> {
+ Self::parse(value)
+ }
+}
+
+impl AsRef<str> for RadrootsIdentityId {
+ fn as_ref(&self) -> &str {
+ self.as_str()
+ }
+}
+
+impl fmt::Display for RadrootsIdentityId {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.0.as_str())
+ }
+}
+
+impl RadrootsIdentityPublic {
+ pub fn new(public_key: nostr::PublicKey) -> Self {
+ let id = RadrootsIdentityId::from_public_key(public_key);
+ use nostr::nips::nip19::ToBech32;
+ let public_key_npub = infallible_to_string(public_key.to_bech32());
+ Self {
+ id,
+ public_key_hex: public_key.to_hex(),
+ public_key_npub,
+ profile: None,
+ }
+ }
+
+ pub fn with_profile(mut self, profile: RadrootsIdentityProfile) -> Self {
+ self.profile = if profile.is_empty() {
+ None
+ } else {
+ Some(profile)
+ };
+ self
+ }
+}
+
impl RadrootsIdentityProfile {
pub fn is_empty(&self) -> bool {
+ #[cfg(feature = "profile")]
+ let profile_empty = self.profile.is_none();
+ #[cfg(not(feature = "profile"))]
+ let profile_empty = true;
+
self.identifier.is_none()
&& self.metadata.is_none()
&& self.application_handler.is_none()
- && self.profile.is_none()
+ && profile_empty
}
}
@@ -103,6 +191,10 @@ impl RadrootsIdentity {
self.keys.public_key()
}
+ pub fn id(&self) -> RadrootsIdentityId {
+ RadrootsIdentityId::from_public_key(self.keys.public_key())
+ }
+
pub fn public_key_hex(&self) -> String {
self.keys.public_key().to_hex()
}
@@ -133,6 +225,17 @@ impl RadrootsIdentity {
self.keys.secret_key().to_secret_bytes()
}
+ #[cfg(feature = "secrecy")]
+ pub fn secret_key_hex_secret(&self) -> secrecy::SecretString {
+ use secrecy::SecretString;
+ SecretString::new(self.secret_key_hex().into())
+ }
+
+ #[cfg(feature = "zeroize")]
+ pub fn secret_key_bytes_zeroizing(&self) -> zeroize::Zeroizing<[u8; SecretKey::LEN]> {
+ zeroize::Zeroizing::new(self.secret_key_bytes())
+ }
+
pub fn profile(&self) -> Option<&RadrootsIdentityProfile> {
self.profile.as_ref()
}
@@ -153,6 +256,14 @@ impl RadrootsIdentity {
self.profile = None;
}
+ pub fn to_public(&self) -> RadrootsIdentityPublic {
+ let mut public = RadrootsIdentityPublic::new(self.keys.public_key());
+ if let Some(profile) = &self.profile {
+ public.profile = Some(profile.clone());
+ }
+ public
+ }
+
pub fn to_file(&self) -> RadrootsIdentityFile {
self.to_file_with_secret_format(RadrootsIdentitySecretKeyFormat::Hex)
}
@@ -165,6 +276,7 @@ impl RadrootsIdentity {
RadrootsIdentitySecretKeyFormat::Hex => self.secret_key_hex(),
RadrootsIdentitySecretKeyFormat::Nsec => self.secret_key_nsec(),
};
+ #[cfg(feature = "profile")]
let (identifier, metadata, application_handler, profile) = match &self.profile {
Some(profile) => (
profile.identifier.clone(),
@@ -174,13 +286,35 @@ impl RadrootsIdentity {
),
None => (None, None, None, None),
};
- RadrootsIdentityFile {
- secret_key,
- public_key: Some(self.public_key_hex()),
- identifier,
- metadata,
- application_handler,
- profile,
+ #[cfg(not(feature = "profile"))]
+ let (identifier, metadata, application_handler) = match &self.profile {
+ Some(profile) => (
+ profile.identifier.clone(),
+ profile.metadata.clone(),
+ profile.application_handler.clone(),
+ ),
+ None => (None, None, None),
+ };
+ #[cfg(feature = "profile")]
+ {
+ return RadrootsIdentityFile {
+ secret_key,
+ public_key: Some(self.public_key_hex()),
+ identifier,
+ metadata,
+ application_handler,
+ profile,
+ };
+ }
+ #[cfg(not(feature = "profile"))]
+ {
+ RadrootsIdentityFile {
+ secret_key,
+ public_key: Some(self.public_key_hex()),
+ identifier,
+ metadata,
+ application_handler,
+ }
}
}
@@ -210,7 +344,7 @@ impl RadrootsIdentity {
parse_identity_bytes(&bytes)
}
- #[cfg(feature = "std")]
+ #[cfg(all(feature = "std", feature = "json-file"))]
pub fn load_or_generate<P: AsRef<Path>>(
path: Option<P>,
allow_generate: bool,
@@ -229,7 +363,7 @@ impl RadrootsIdentity {
Ok(identity)
}
- #[cfg(feature = "std")]
+ #[cfg(all(feature = "std", feature = "json-file"))]
pub fn save_json(&self, path: impl AsRef<Path>) -> Result<(), IdentityError> {
let payload = self.to_file();
let mut store = JsonFile::load_or_create_with(path.as_ref(), || payload.clone())?;
@@ -246,12 +380,19 @@ impl TryFrom<RadrootsIdentityFile> for RadrootsIdentity {
fn try_from(file: RadrootsIdentityFile) -> Result<Self, Self::Error> {
let keys = Keys::parse(&file.secret_key)?;
validate_public_key(&keys, file.public_key.as_deref())?;
+ #[cfg(feature = "profile")]
let profile = RadrootsIdentityProfile {
identifier: file.identifier,
metadata: file.metadata,
application_handler: file.application_handler,
profile: file.profile,
};
+ #[cfg(not(feature = "profile"))]
+ let profile = RadrootsIdentityProfile {
+ identifier: file.identifier,
+ metadata: file.metadata,
+ application_handler: file.application_handler,
+ };
if profile.is_empty() {
Ok(Self::new(keys))
} else {
diff --git a/crates/identity/src/lib.rs b/crates/identity/src/lib.rs
@@ -10,8 +10,8 @@ pub mod username;
pub use error::IdentityError;
pub use identity::{
- DEFAULT_IDENTITY_PATH, RadrootsIdentity, RadrootsIdentityFile, RadrootsIdentityProfile,
- RadrootsIdentitySecretKeyFormat,
+ DEFAULT_IDENTITY_PATH, RadrootsIdentity, RadrootsIdentityFile, RadrootsIdentityId,
+ RadrootsIdentityProfile, RadrootsIdentityPublic, RadrootsIdentitySecretKeyFormat,
};
pub use username::{
RADROOTS_USERNAME_MAX_LEN, RADROOTS_USERNAME_MIN_LEN, RADROOTS_USERNAME_REGEX,
diff --git a/crates/identity/tests/identity.rs b/crates/identity/tests/identity.rs
@@ -1,5 +1,7 @@
use radroots_events::profile::RadrootsProfile;
-use radroots_identity::{IdentityError, RadrootsIdentity, RadrootsIdentityProfile};
+use radroots_identity::{
+ IdentityError, RadrootsIdentity, RadrootsIdentityId, RadrootsIdentityProfile,
+};
#[test]
fn load_from_json_file_hex() {
@@ -143,3 +145,63 @@ fn load_from_json_file_public_key_mismatch() {
let err = RadrootsIdentity::load_from_path_auto(&path).unwrap_err();
assert!(matches!(err, IdentityError::PublicKeyMismatch));
}
+
+#[test]
+fn identity_id_matches_public_key_hex() {
+ let keys = nostr::Keys::generate();
+ let identity = RadrootsIdentity::new(keys.clone());
+
+ let id = identity.id();
+ assert_eq!(id.as_str(), keys.public_key().to_hex());
+}
+
+#[test]
+fn identity_id_parses_hex_and_npub() {
+ use nostr::nips::nip19::ToBech32;
+
+ let keys = nostr::Keys::generate();
+ let public_key = keys.public_key();
+ let hex = public_key.to_hex();
+ let npub = public_key.to_bech32().unwrap();
+
+ let from_hex = RadrootsIdentityId::parse(hex.as_str()).unwrap();
+ let from_npub = RadrootsIdentityId::parse(npub.as_str()).unwrap();
+ assert_eq!(from_hex.as_str(), hex);
+ assert_eq!(from_npub.as_str(), hex);
+}
+
+#[test]
+fn to_public_projection_excludes_secret_key_fields() {
+ let keys = nostr::Keys::generate();
+ let identity = RadrootsIdentity::new(keys.clone());
+ let public = identity.to_public();
+
+ assert_eq!(public.id.as_str(), keys.public_key().to_hex());
+ assert_eq!(public.public_key_hex, keys.public_key().to_hex());
+ assert!(public.profile.is_none());
+
+ let json = serde_json::to_string(&public).unwrap();
+ assert!(!json.contains("secret_key"));
+ assert!(!json.contains(&identity.secret_key_hex()));
+}
+
+#[cfg(feature = "secrecy")]
+#[test]
+fn secret_key_hex_secret_returns_secret_string() {
+ use secrecy::ExposeSecret;
+
+ let keys = nostr::Keys::generate();
+ let identity = RadrootsIdentity::new(keys);
+ let secret = identity.secret_key_hex_secret();
+ assert_eq!(secret.expose_secret(), &identity.secret_key_hex());
+}
+
+#[cfg(feature = "zeroize")]
+#[test]
+fn secret_key_zeroizing_bytes_matches_raw_secret() {
+ let keys = nostr::Keys::generate();
+ let identity = RadrootsIdentity::new(keys);
+ let raw = identity.secret_key_bytes();
+ let protected = identity.secret_key_bytes_zeroizing();
+ assert_eq!(&*protected, &raw);
+}