lib

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

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:
MCargo.toml | 1+
Mcrates/identity/Cargo.toml | 12+++++++++---
Mcrates/identity/src/error.rs | 4++--
Mcrates/identity/src/identity.rs | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/identity/src/lib.rs | 4++--
Mcrates/identity/tests/identity.rs | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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); +}