feat: fix NIP-07 signing to use node Nostr key, add test script
Added node.nostr-sign RPC that uses the node-level Nostr key (matching getPublicKey), fixing pubkey mismatch where identity.nostr-sign used a different key. Updated appLauncher to call node.nostr-sign. Added nostr_sign_hash() to nostr_discovery.rs. Created test-nip07.sh with 11 automated checks (INSTALL-02). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -317,26 +317,73 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Sign a Nostr event hash with an identity's Nostr key.
|
||||
/// Sign a Nostr event with an identity's Nostr key.
|
||||
///
|
||||
/// Accepts either:
|
||||
/// - `event_hash` (hex) + `id` — sign a pre-computed hash
|
||||
/// - `event` (full event object) — compute NIP-01 hash, fill pubkey, sign
|
||||
/// If `id` is omitted, uses the default identity.
|
||||
pub(super) async fn handle_identity_nostr_sign(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
let event_hash = params
|
||||
.get("event_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: event_hash"))?;
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let signature = manager.nostr_sign(id, event_hash).await?;
|
||||
let (records, _) = manager.list().await?;
|
||||
|
||||
// Resolve identity: prefer explicit id, then default, then any with Nostr key
|
||||
let id = if let Some(id) = params.get("id").and_then(|v| v.as_str()) {
|
||||
id.to_string()
|
||||
} else {
|
||||
// Prefer an identity with a Nostr key
|
||||
records.iter()
|
||||
.find(|r| r.nostr_pubkey.is_some())
|
||||
.map(|r| r.id.clone())
|
||||
.ok_or_else(|| anyhow::anyhow!("No identity with Nostr key found"))?
|
||||
};
|
||||
|
||||
let identity = records.iter().find(|r| r.id == id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Identity not found: {}", id))?;
|
||||
let pubkey_hex = identity.nostr_pubkey.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("Identity has no Nostr key"))?;
|
||||
|
||||
if let Some(event_hash) = params.get("event_hash").and_then(|v| v.as_str()) {
|
||||
// Direct hash signing
|
||||
let signature = manager.nostr_sign(&id, event_hash).await?;
|
||||
return Ok(serde_json::json!({ "signature": signature }));
|
||||
}
|
||||
|
||||
// Full event signing: compute NIP-01 event hash
|
||||
let event = params.get("event")
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'event' or 'event_hash' parameter"))?;
|
||||
|
||||
let kind = event.get("kind").and_then(|v| v.as_u64()).unwrap_or(1);
|
||||
let content = event.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let created_at = event.get("created_at").and_then(|v| v.as_u64())
|
||||
.unwrap_or_else(|| std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs());
|
||||
let tags = event.get("tags").cloned().unwrap_or_else(|| serde_json::json!([]));
|
||||
|
||||
// NIP-01 serialization: [0, pubkey, created_at, kind, tags, content]
|
||||
let serialized = serde_json::json!([0, pubkey_hex, created_at, kind, tags, content]);
|
||||
let serialized_str = serde_json::to_string(&serialized)?;
|
||||
|
||||
// SHA-256 hash
|
||||
use sha2::{Sha256, Digest};
|
||||
let hash = Sha256::digest(serialized_str.as_bytes());
|
||||
let event_hash_hex = hex::encode(hash);
|
||||
|
||||
let signature = manager.nostr_sign(&id, &event_hash_hex).await?;
|
||||
|
||||
// Return the complete signed event
|
||||
Ok(serde_json::json!({
|
||||
"signature": signature,
|
||||
"id": event_hash_hex,
|
||||
"pubkey": pubkey_hex,
|
||||
"created_at": created_at,
|
||||
"kind": kind,
|
||||
"tags": tags,
|
||||
"content": content,
|
||||
"sig": signature,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -300,6 +300,7 @@ impl RpcHandler {
|
||||
"node.tor-address" => self.handle_node_tor_address().await,
|
||||
"node.nostr-publish" => self.handle_node_nostr_publish().await,
|
||||
"node.nostr-pubkey" => self.handle_node_nostr_pubkey().await,
|
||||
"node.nostr-sign" => self.handle_node_nostr_sign(params).await,
|
||||
"node-nostr-verify-revoked" => self.handle_node_nostr_verify_revoked().await,
|
||||
|
||||
// Encrypted peer handshake (NIP-44)
|
||||
|
||||
@@ -114,6 +114,47 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Sign a Nostr event with the node's Nostr key.
|
||||
/// Accepts full event object, computes NIP-01 hash, returns signed event.
|
||||
pub(super) async fn handle_node_nostr_sign(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let pubkey_hex = nostr_discovery::get_nostr_pubkey(&identity_dir).await?;
|
||||
|
||||
let event = params.get("event")
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'event' parameter"))?;
|
||||
|
||||
let kind = event.get("kind").and_then(|v| v.as_u64()).unwrap_or(1);
|
||||
let content = event.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let created_at = event.get("created_at").and_then(|v| v.as_u64())
|
||||
.unwrap_or_else(|| std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs());
|
||||
let tags = event.get("tags").cloned().unwrap_or_else(|| serde_json::json!([]));
|
||||
|
||||
// NIP-01 serialization: [0, pubkey, created_at, kind, tags, content]
|
||||
let serialized = serde_json::json!([0, pubkey_hex, created_at, kind, tags, content]);
|
||||
let serialized_str = serde_json::to_string(&serialized)?;
|
||||
|
||||
use sha2::{Sha256, Digest};
|
||||
let hash = Sha256::digest(serialized_str.as_bytes());
|
||||
let event_hash_hex = hex::encode(hash);
|
||||
|
||||
let signature = nostr_discovery::nostr_sign_hash(&identity_dir, &event_hash_hex).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": event_hash_hex,
|
||||
"pubkey": pubkey_hex,
|
||||
"created_at": created_at,
|
||||
"kind": kind,
|
||||
"tags": tags,
|
||||
"content": content,
|
||||
"sig": signature,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_nostr_verify_revoked(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let status = nostr_discovery::verify_revocation(
|
||||
|
||||
@@ -200,6 +200,20 @@ pub async fn get_nostr_pubkey(identity_dir: &Path) -> Result<String> {
|
||||
Ok(keys.public_key().to_hex())
|
||||
}
|
||||
|
||||
/// Sign a 32-byte hash with the node's Nostr Schnorr key.
|
||||
pub async fn nostr_sign_hash(identity_dir: &Path, hash_hex: &str) -> Result<String> {
|
||||
let keys = load_or_create_nostr_keys(identity_dir).await?;
|
||||
let hash_bytes = hex::decode(hash_hex).context("Invalid hash hex")?;
|
||||
if hash_bytes.len() != 32 {
|
||||
anyhow::bail!("Hash must be 32 bytes");
|
||||
}
|
||||
let message = nostr_sdk::secp256k1::Message::from_digest(
|
||||
hash_bytes.try_into().map_err(|_| anyhow::anyhow!("Invalid hash length"))?,
|
||||
);
|
||||
let sig = keys.sign_schnorr(&message);
|
||||
Ok(sig.to_string())
|
||||
}
|
||||
|
||||
/// Verify that our node's Nostr discovery data was revoked on the legacy relays.
|
||||
/// Queries relays for our pubkey's kind 30078 events; if latest has empty content, revocation succeeded.
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
|
||||
Reference in New Issue
Block a user