commit 65de042fbec33cdcf4f51a513232d9dcc3fa25f8
parent c9c069d726ab62b39ae1c15e2a8c17a2120ec8c3
Author: triesap <tyson@radroots.org>
Date: Wed, 24 Dec 2025 16:15:44 +0000
identity: validate optional public key in identity file
- Add optional public_key field to identity JSON serialization
- Parse public key from npub or hex and reject empty values
- Verify provided public key matches secret key during load
- Add tests for npub parsing and mismatch detection
Diffstat:
3 files changed, 67 insertions(+), 0 deletions(-)
diff --git a/identity/src/error.rs b/identity/src/error.rs
@@ -1,5 +1,8 @@
use thiserror::Error;
+#[cfg(not(feature = "std"))]
+use alloc::string::String;
+
#[cfg(feature = "std")]
use radroots_runtime::RuntimeJsonError;
#[cfg(feature = "std")]
@@ -28,6 +31,12 @@ pub enum IdentityError {
#[error("invalid secret key: {0}")]
InvalidSecretKey(#[from] nostr::key::Error),
+ #[error("invalid public key: {0}")]
+ InvalidPublicKey(String),
+
+ #[error("public key does not match secret key")]
+ PublicKeyMismatch,
+
#[error("unsupported identity file format")]
InvalidIdentityFormat,
diff --git a/identity/src/identity.rs b/identity/src/identity.rs
@@ -35,6 +35,8 @@ pub struct RadrootsIdentityProfile {
pub struct RadrootsIdentityFile {
pub secret_key: String,
#[serde(skip_serializing_if = "Option::is_none")]
+ pub public_key: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
pub identifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<nostr::Event>,
@@ -157,6 +159,7 @@ impl RadrootsIdentity {
};
RadrootsIdentityFile {
secret_key,
+ public_key: Some(self.public_key_hex()),
identifier,
metadata,
application_handler,
@@ -224,6 +227,7 @@ 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())?;
let profile = RadrootsIdentityProfile {
identifier: file.identifier,
metadata: file.metadata,
@@ -272,6 +276,27 @@ fn parse_identity_bytes(bytes: &[u8]) -> Result<RadrootsIdentity, IdentityError>
RadrootsIdentity::from_secret_key_str(trimmed)
}
+fn validate_public_key(keys: &Keys, public_key: Option<&str>) -> Result<(), IdentityError> {
+ let Some(public_key) = public_key else {
+ return Ok(());
+ };
+ let parsed = parse_public_key(public_key)?;
+ if parsed != keys.public_key() {
+ return Err(IdentityError::PublicKeyMismatch);
+ }
+ Ok(())
+}
+
+fn parse_public_key(value: &str) -> Result<nostr::PublicKey, IdentityError> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ return Err(IdentityError::InvalidPublicKey(value.to_string()));
+ }
+ nostr::PublicKey::parse(trimmed)
+ .or_else(|_| nostr::PublicKey::from_hex(trimmed))
+ .map_err(|_| IdentityError::InvalidPublicKey(value.to_string()))
+}
+
fn infallible_to_string(value: Result<String, Infallible>) -> String {
match value {
Ok(value) => value,
diff --git a/identity/tests/identity.rs b/identity/tests/identity.rs
@@ -76,3 +76,36 @@ fn load_or_generate_missing_allowed_creates_json() {
let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap();
assert_eq!(loaded.public_key(), identity.public_key());
}
+
+#[test]
+fn load_from_json_file_public_key_npub() {
+ let keys = nostr::Keys::generate();
+ let identity = RadrootsIdentity::new(keys.clone());
+ let mut file = identity.to_file();
+ file.public_key = Some(identity.public_key_npub());
+ let json = serde_json::to_string(&file).unwrap();
+
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("identity.json");
+ std::fs::write(&path, json).unwrap();
+
+ let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap();
+ assert_eq!(loaded.public_key(), keys.public_key());
+}
+
+#[test]
+fn load_from_json_file_public_key_mismatch() {
+ let keys = nostr::Keys::generate();
+ let identity = RadrootsIdentity::new(keys);
+ let other_keys = nostr::Keys::generate();
+ let mut file = identity.to_file();
+ file.public_key = Some(other_keys.public_key().to_hex());
+ let json = serde_json::to_string(&file).unwrap();
+
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("identity.json");
+ std::fs::write(&path, json).unwrap();
+
+ let err = RadrootsIdentity::load_from_path_auto(&path).unwrap_err();
+ assert!(matches!(err, IdentityError::PublicKeyMismatch));
+}