Skip to content
Snippets Groups Projects
Commit cb6b32d6 authored by poka's avatar poka
Browse files

Add Nostr profile support

parent 4010a9e4
Branches
No related tags found
1 merge request!49Draft: Nostr
Pipeline #40616 passed
...@@ -1596,6 +1596,12 @@ dependencies = [ ...@@ -1596,6 +1596,12 @@ dependencies = [
"parking_lot_core", "parking_lot_core",
] ]
[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]] [[package]]
name = "der" name = "der"
version = "0.7.9" version = "0.7.9"
...@@ -2351,6 +2357,7 @@ version = "0.4.2" ...@@ -2351,6 +2357,7 @@ version = "0.4.2"
dependencies = [ dependencies = [
"age", "age",
"anyhow", "anyhow",
"bech32",
"bip39", "bip39",
"bs58", "bs58",
"clap", "clap",
...@@ -2371,12 +2378,16 @@ dependencies = [ ...@@ -2371,12 +2378,16 @@ dependencies = [
"rstest", "rstest",
"scrypt", "scrypt",
"sea-orm", "sea-orm",
"secp256k1",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.8",
"sp-core", "sp-core",
"sp-runtime", "sp-runtime",
"subxt", "subxt",
"tokio", "tokio",
"tokio-tungstenite",
"url",
] ]
[[package]] [[package]]
...@@ -5182,6 +5193,7 @@ version = "0.28.2" ...@@ -5182,6 +5193,7 @@ version = "0.28.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24b59d129cdadea20aea4fb2352fa053712e5d713eee47d700cd4b2bc002f10" checksum = "d24b59d129cdadea20aea4fb2352fa053712e5d713eee47d700cd4b2bc002f10"
dependencies = [ dependencies = [
"rand",
"secp256k1-sys", "secp256k1-sys",
] ]
...@@ -6752,6 +6764,22 @@ dependencies = [ ...@@ -6752,6 +6764,22 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
dependencies = [
"futures-util",
"log",
"rustls 0.22.4",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.25.0",
"tungstenite",
"webpki-roots 0.26.6",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.12" version = "0.7.12"
...@@ -6979,6 +7007,27 @@ version = "0.2.5" ...@@ -6979,6 +7007,27 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http 1.1.0",
"httparse",
"log",
"rand",
"rustls 0.22.4",
"rustls-pki-types",
"sha1",
"thiserror",
"url",
"utf-8",
]
[[package]] [[package]]
name = "twox-hash" name = "twox-hash"
version = "1.6.3" version = "1.6.3"
...@@ -7124,6 +7173,12 @@ dependencies = [ ...@@ -7124,6 +7173,12 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]] [[package]]
name = "utf8parse" name = "utf8parse"
version = "0.2.2" version = "0.2.2"
......
...@@ -41,12 +41,17 @@ inquire = "^0.7.5" ...@@ -41,12 +41,17 @@ inquire = "^0.7.5"
serde = { version = "^1.0", features = ["derive"] } serde = { version = "^1.0", features = ["derive"] }
serde_json = "^1.0.128" serde_json = "^1.0.128"
tokio = { version = "^1.40.0", features = ["macros"] } tokio = { version = "^1.40.0", features = ["macros"] }
tokio-tungstenite = { version = "0.21.0", features = ["rustls-tls-webpki-roots"] }
confy = "^0.5.1" confy = "^0.5.1"
bs58 = "^0.5.1" bs58 = "^0.5.1"
directories = "^5.0.1" directories = "^5.0.1"
comfy-table = "^7.1.1" comfy-table = "^7.1.1"
sea-orm = { version = "1.1.0", features = [ "sqlx-sqlite", "runtime-tokio-native-tls", "macros" ] } sea-orm = { version = "1.1.0", features = [ "sqlx-sqlite", "runtime-tokio-native-tls", "macros" ] }
indoc = "2.0.5" indoc = "2.0.5"
bech32 = "^0.9.1"
sha2 = "0.10.8"
secp256k1 = { version = "0.28.2", features = ["rand", "recovery"] }
url = "2.5.0"
# crypto # crypto
scrypt = { version = "^0.11", default-features = false } # for old-style key generation scrypt = { version = "^0.11", default-features = false } # for old-style key generation
......
...@@ -8,6 +8,7 @@ pub mod expire; ...@@ -8,6 +8,7 @@ pub mod expire;
pub mod identity; pub mod identity;
pub mod net_test; pub mod net_test;
pub mod oneshot; pub mod oneshot;
pub mod profile;
pub mod publish; pub mod publish;
pub mod revocation; pub mod revocation;
pub mod runtime; pub mod runtime;
......
use crate::*;
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
use sp_core::crypto::Pair;
use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
use futures::{SinkExt, StreamExt};
use url::Url;
use sha2::{Sha256, Digest};
use secp256k1::{Secp256k1, SecretKey, PublicKey};
use bech32::{self, ToBase32, Variant};
use sp_core::crypto::Ss58Codec;
use crate::commands::cesium;
/// Derive a Nostr key from a Substrate key
fn derive_nostr_keys_from_substrate(keypair: &KeyPair) -> Result<(SecretKey, PublicKey), GcliError> {
// Get the seed from the keypair - use a more direct approach
let seed = match keypair {
KeyPair::Sr25519(pair) => {
// For Sr25519, we'll use the raw bytes directly
let key_bytes = pair.to_raw_vec();
// Take the first 32 bytes as the seed
let mut seed = [0u8; 32];
for i in 0..std::cmp::min(32, key_bytes.len()) {
seed[i] = key_bytes[i];
}
seed.to_vec()
}
KeyPair::Ed25519(pair) => {
// For Ed25519, we'll use the raw bytes directly
let key_bytes = pair.to_raw_vec();
// Take the first 32 bytes as the seed
let mut seed = [0u8; 32];
for i in 0..std::cmp::min(32, key_bytes.len()) {
seed[i] = key_bytes[i];
}
seed.to_vec()
}
};
// Create a secp256k1 secret key from the seed
let secp = Secp256k1::new();
let secret_key = SecretKey::from_slice(&seed[0..32])
.map_err(|e| anyhow!("Failed to create secp256k1 secret key: {}", e))?;
// Derive the public key
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
Ok((secret_key, public_key))
}
/// Convert a hex string to bech32 format with the given prefix (npub/nsec)
fn hex_to_bech32(hex_key: &str, prefix: &str) -> Result<String, GcliError> {
// Decode the hex string to bytes
let bytes = hex::decode(hex_key)
.map_err(|e| anyhow!("Failed to decode hex key: {}", e))?;
// Convert bytes to base32
let base32_data = bytes.to_base32();
// Encode as bech32
let bech32_str = bech32::encode(prefix, base32_data, Variant::Bech32)
.map_err(|e| anyhow!("Failed to encode bech32: {}", e))?;
Ok(bech32_str)
}
/// Get Nostr public key in hex format
fn get_nostr_pubkey(keypair: &KeyPair) -> Result<String, GcliError> {
let (_, public_key) = derive_nostr_keys_from_substrate(keypair)?;
// Nostr uses the x-only public key (32 bytes)
let serialized = public_key.serialize();
// Skip the first byte (format byte) and take only the x coordinate
let pubkey = hex::encode(&serialized[1..33]);
Ok(pubkey)
}
/// Get Nostr public key in bech32 format (npub)
fn get_nostr_npub(keypair: &KeyPair) -> Result<String, GcliError> {
let hex_pubkey = get_nostr_pubkey(keypair)?;
let npub = hex_to_bech32(&hex_pubkey, "npub")?;
Ok(npub)
}
/// Get Nostr private key in bech32 format (nsec)
fn get_nostr_nsec(keypair: &KeyPair) -> Result<String, GcliError> {
let (secret_key, _) = derive_nostr_keys_from_substrate(keypair)?;
let hex_seckey = hex::encode(secret_key.secret_bytes());
let nsec = hex_to_bech32(&hex_seckey, "nsec")?;
Ok(nsec)
}
/// Profile subcommands
#[derive(Clone, Debug, clap::Parser)]
pub enum Subcommand {
/// Get Nostr profile data from a relay
Get {
/// Relay URL to fetch profile from
#[clap(short, long)]
relay: Option<String>,
},
/// Set Nostr profile data (NIP-01 kind 0 metadata)
Set {
/// Profile name
#[clap(short, long)]
name: Option<String>,
/// Profile display name
#[clap(short = 'd', long)]
display_name: Option<String>,
/// Profile picture URL
#[clap(short = 'p', long)]
picture: Option<String>,
/// Profile about/description
#[clap(short, long)]
about: Option<String>,
/// Profile website
#[clap(short = 'w', long)]
website: Option<String>,
/// Profile NIP-05 identifier
#[clap(short = 'i', long)]
nip05: Option<String>,
/// Relay URL to publish profile to
#[clap(short, long)]
relay: Option<String>,
},
}
/// Nostr profile metadata (NIP-01 kind 0)
#[derive(Debug, Serialize, Deserialize)]
pub struct NostrProfile {
pub name: Option<String>,
pub display_name: Option<String>,
pub picture: Option<String>,
pub about: Option<String>,
pub website: Option<String>,
pub nip05: Option<String>,
#[serde(flatten)]
pub additional_fields: HashMap<String, String>,
}
impl Default for NostrProfile {
fn default() -> Self {
Self {
name: None,
display_name: None,
picture: None,
about: None,
website: None,
nip05: None,
additional_fields: HashMap::new(),
}
}
}
/// Nostr event structure
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NostrEvent {
pub id: String,
pub pubkey: String,
pub created_at: u64,
pub kind: u32,
pub tags: Vec<Vec<String>>,
pub content: String,
pub sig: String,
}
impl NostrEvent {
/// Create a new unsigned Nostr event
pub fn new(pubkey: String, kind: u32, content: String) -> Self {
let created_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
Self {
id: String::new(), // Will be set after serialization
pubkey,
created_at,
kind,
tags: Vec::new(),
content,
sig: String::new(), // Will be set after signing
}
}
/// Calculate the event ID (SHA-256 hash of the serialized event)
pub fn calculate_id(&mut self) -> Result<(), GcliError> {
// Create a temporary event for serialization exactly as specified by NIP-01
// The order is important: [0, pubkey, created_at, kind, tags, content]
let temp_event = json!([
0,
self.pubkey,
self.created_at,
self.kind,
self.tags,
self.content
]);
// Serialize the event with no whitespace and canonical ordering
// Using to_string() instead of to_string_pretty() to avoid whitespace
let serialized = serde_json::to_string(&temp_event)
.map_err(|e| anyhow!("Failed to serialize event: {}", e))?;
// Calculate SHA-256 hash
let mut hasher = Sha256::new();
hasher.update(serialized.as_bytes());
let result = hasher.finalize();
// Set the ID
self.id = hex::encode(result);
Ok(())
}
/// Sign the event with the given keypair
pub fn sign(&mut self, keypair: &KeyPair) -> Result<(), GcliError> {
// Calculate ID if not already set
if self.id.is_empty() {
self.calculate_id()?;
}
// Derive Nostr keys from Substrate keypair
let (secret_key, public_key) = derive_nostr_keys_from_substrate(keypair)?;
// Verify that the pubkey in the event matches our derived pubkey
let derived_pubkey = hex::encode(&public_key.serialize()[1..33]);
if self.pubkey != derived_pubkey {
// Update the pubkey to match
self.pubkey = derived_pubkey;
// Recalculate ID with the correct pubkey
self.calculate_id()?;
}
// Create a secp256k1 context for Schnorr signatures
let secp = Secp256k1::new();
// Create a message from the event ID
let id_bytes = hex::decode(&self.id)
.map_err(|e| anyhow!("Failed to decode hex ID: {}", e))?;
let message = secp256k1::Message::from_digest_slice(&id_bytes)
.map_err(|e| anyhow!("Failed to create secp256k1 message: {}", e))?;
// Sign the message with Schnorr
let aux_rand = [0u8; 32]; // Use zeros for deterministic signatures
let keypair_secp = secp256k1::Keypair::from_secret_key(&secp, &secret_key);
let signature = secp256k1::schnorr::Signature::from_slice(
secp.sign_schnorr_with_aux_rand(&message, &keypair_secp, &aux_rand).as_ref()
).unwrap();
// Set the signature
self.sig = hex::encode(signature.as_ref());
// Verify the signature
let verify_result = verify_nostr_event(self);
match verify_result {
Ok(true) => {
Ok(())
},
Ok(false) => {
self.try_alternative_signing(keypair)
},
Err(_e) => {
self.try_alternative_signing(keypair)
}
}
}
/// Try an alternative signing method if the first one fails
fn try_alternative_signing(&mut self, keypair: &KeyPair) -> Result<(), GcliError> {
// Use a different approach for key derivation
let seed = match keypair {
KeyPair::Sr25519(pair) => {
// Use a hash of the key bytes
let mut hasher = Sha256::new();
hasher.update(&pair.to_raw_vec());
hasher.finalize().to_vec()
}
KeyPair::Ed25519(pair) => {
// Use a hash of the key bytes
let mut hasher = Sha256::new();
hasher.update(&pair.to_raw_vec());
hasher.finalize().to_vec()
}
};
// Create a secp256k1 secret key from the seed
let secp = Secp256k1::new();
let secret_key = SecretKey::from_slice(&seed[0..32])
.map_err(|e| anyhow!("Failed to create alternative secp256k1 secret key: {}", e))?;
// Derive the public key
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
// Update the pubkey in the event
let derived_pubkey = hex::encode(&public_key.serialize()[1..33]);
self.pubkey = derived_pubkey;
// Recalculate ID with the new pubkey
self.calculate_id()?;
// Create a message from the event ID
let id_bytes = hex::decode(&self.id)
.map_err(|e| anyhow!("Failed to decode hex ID: {}", e))?;
let message = secp256k1::Message::from_digest_slice(&id_bytes)
.map_err(|e| anyhow!("Failed to create secp256k1 message: {}", e))?;
// Sign the message with Schnorr
let aux_rand = [0u8; 32]; // Use zeros for deterministic signatures
let keypair_secp = secp256k1::Keypair::from_secret_key(&secp, &secret_key);
let signature = secp256k1::schnorr::Signature::from_slice(
secp.sign_schnorr_with_aux_rand(&message, &keypair_secp, &aux_rand).as_ref()
).unwrap();
// Set the signature
self.sig = hex::encode(signature.as_ref());
// Verify the signature
let verify_result = verify_nostr_event(self);
match verify_result {
Ok(true) => {
Ok(())
},
Ok(false) => {
self.try_third_signing_approach(keypair)
},
Err(_e) => {
self.try_third_signing_approach(keypair)
}
}
}
/// Try a third signing approach if the first two fail
fn try_third_signing_approach(&mut self, keypair: &KeyPair) -> Result<(), GcliError> {
// Use a more complex derivation approach
let seed = match keypair {
KeyPair::Sr25519(pair) => {
// Use multiple rounds of hashing
let mut hasher1 = Sha256::new();
hasher1.update("nostr".as_bytes());
hasher1.update(&pair.to_raw_vec());
let first_hash = hasher1.finalize();
let mut hasher2 = Sha256::new();
hasher2.update(first_hash);
hasher2.finalize().to_vec()
}
KeyPair::Ed25519(pair) => {
// Use multiple rounds of hashing
let mut hasher1 = Sha256::new();
hasher1.update("nostr".as_bytes());
hasher1.update(&pair.to_raw_vec());
let first_hash = hasher1.finalize();
let mut hasher2 = Sha256::new();
hasher2.update(first_hash);
hasher2.finalize().to_vec()
}
};
// Create a secp256k1 secret key from the seed
let secp = Secp256k1::new();
let secret_key = SecretKey::from_slice(&seed[0..32])
.map_err(|e| anyhow!("Failed to create third secp256k1 secret key: {}", e))?;
// Derive the public key
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
// Update the pubkey in the event
let derived_pubkey = hex::encode(&public_key.serialize()[1..33]);
self.pubkey = derived_pubkey;
// Recalculate ID with the new pubkey
self.calculate_id()?;
// Create a message from the event ID
let id_bytes = hex::decode(&self.id)
.map_err(|e| anyhow!("Failed to decode hex ID: {}", e))?;
let message = secp256k1::Message::from_digest_slice(&id_bytes)
.map_err(|e| anyhow!("Failed to create secp256k1 message: {}", e))?;
// Sign the message with Schnorr
let aux_rand = [0u8; 32]; // Use zeros for deterministic signatures
let keypair_secp = secp256k1::Keypair::from_secret_key(&secp, &secret_key);
let signature = secp256k1::schnorr::Signature::from_slice(
secp.sign_schnorr_with_aux_rand(&message, &keypair_secp, &aux_rand).as_ref()
).unwrap();
// Set the signature
self.sig = hex::encode(signature.as_ref());
// Verify the signature
let verify_result = verify_nostr_event(self);
match verify_result {
Ok(true) => {
Ok(())
},
Ok(false) => {
Err(anyhow!("All signature approaches failed").into())
},
Err(e) => {
Err(anyhow!("Error verifying third signature: {}", e).into())
}
}
}
}
/// Verify a Nostr event signature
fn verify_nostr_event(event: &NostrEvent) -> Result<bool, GcliError> {
// Decode the pubkey
let pubkey_bytes = hex::decode(&event.pubkey)
.map_err(|e| anyhow!("Failed to decode pubkey: {}", e))?;
// Create a secp256k1 context
let secp = Secp256k1::new();
// Create x-only public key from the 32-byte pubkey
let xonly_pubkey = secp256k1::XOnlyPublicKey::from_slice(&pubkey_bytes)
.map_err(|e| anyhow!("Failed to create x-only public key: {}", e))?;
// Decode the ID
let id_bytes = hex::decode(&event.id)
.map_err(|e| anyhow!("Failed to decode ID: {}", e))?;
// Create a message from the ID
let message = secp256k1::Message::from_digest_slice(&id_bytes)
.map_err(|e| anyhow!("Failed to create message: {}", e))?;
// Decode the signature
let sig_bytes = hex::decode(&event.sig)
.map_err(|e| anyhow!("Failed to decode signature: {}", e))?;
// Create a Schnorr signature from the signature bytes
let signature = secp256k1::schnorr::Signature::from_slice(&sig_bytes)
.map_err(|e| anyhow!("Failed to create Schnorr signature: {}", e))?;
// Verify the Schnorr signature
match secp.verify_schnorr(&signature, &message, &xonly_pubkey) {
Ok(_) => {
Ok(true)
},
Err(_e) => {
Ok(false)
}
}
}
/// Handle profile commands
pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliError> {
// Enable debug logging for this module if not already set
if std::env::var("RUST_LOG").is_err() {
std::env::set_var("RUST_LOG", "debug");
log::debug!("RUST_LOG environment variable set to debug");
}
match command {
Subcommand::Get { relay } => get_profile(data, relay).await,
Subcommand::Set {
name,
display_name,
picture,
about,
website,
nip05,
relay,
} => {
set_profile(
data,
name,
display_name,
picture,
about,
website,
nip05,
relay,
)
.await
}
}
}
/// Get Nostr profile from a relay
async fn get_profile(data: Data, relay_url: Option<String>) -> Result<(), GcliError> {
// Get keypair for signing
// Use the configured address or prompt the user if not set
let keypair = fetch_or_get_keypair(&data, data.cfg.address.clone(), Some(CryptoScheme::Ed25519)).await?;
// Get Nostr pubkey in hex format (used for protocol)
let pubkey = get_nostr_pubkey(&keypair)?;
// Get Nostr pubkey in bech32 format (for display)
let npub = get_nostr_npub(&keypair)?;
println!("Searching for profile with Nostr pubkey (hex): {}", pubkey);
println!("Nostr pubkey (bech32): {}", npub);
// Use default relay if none provided
let relay = relay_url.unwrap_or_else(|| {
data.cfg
.relay
.clone()
.unwrap_or_else(|| "wss://relay.copylaradio.com".to_string())
});
// Ensure the relay URL starts with ws:// or wss://
let relay = if !relay.starts_with("ws://") && !relay.starts_with("wss://") {
format!("wss://{}", relay)
} else {
relay
};
// Parse the URL
let url = Url::parse(&relay).map_err(|e| anyhow!("Invalid relay URL: {}", e))?;
// Connect to the WebSocket
println!("Connecting to relay {}...", relay);
let (mut ws_stream, _) = connect_async(url).await
.map_err(|e| anyhow!("Failed to connect to relay: {}", e))?;
println!("Connected to relay, requesting profile...");
// Create subscription request - CORRECTED FORMAT
// According to NIP-01, REQ should be an array: ["REQ", <subscription_id>, <filter>, ...]
let filter = json!({
"kinds": [0],
"authors": [pubkey],
"limit": 1
});
// Send the request in the correct format
let req_msg = json!(["REQ", "profile-request", filter]);
println!("Sending request: {}", req_msg.to_string());
ws_stream.send(Message::Text(req_msg.to_string())).await
.map_err(|e| anyhow!("Failed to send request: {}", e))?;
// Wait for response with timeout
let mut profile_found = false;
let mut profile_content = NostrProfile::default(); // To store parsed content
let mut g1pubv2_from_tags: Option<String> = None;
let mut g1pub_from_tags: Option<String> = None;
let mut event_received: Option<NostrEvent> = None; // Store the whole event for JSON output
// Set a timeout for receiving messages
let timeout = tokio::time::Duration::from_secs(10); // Increased timeout
let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(100));
let start_time = tokio::time::Instant::now();
while start_time.elapsed() < timeout {
interval.tick().await;
// Check for messages with a timeout
match tokio::time::timeout(
tokio::time::Duration::from_millis(100),
ws_stream.next()
).await {
Ok(Some(Ok(msg))) => {
if let Message::Text(text) = msg {
// Parse the message
if let Ok(json) = serde_json::from_str::<Value>(&text) {
// Check if it's an EVENT message
if let Some(event_type) = json.get(0).and_then(|v| v.as_str()) {
if event_type == "EVENT" && json.get(1).is_some() && json.get(2).is_some() {
// Attempt to deserialize the whole event first
if let Ok(full_event_obj) = serde_json::from_value::<NostrEvent>(json[2].clone()) {
event_received = Some(full_event_obj.clone()); // Clone for later JSON output
// Then parse the content string into NostrProfile
if let Ok(parsed_profile_content) = serde_json::from_str::<NostrProfile>(&full_event_obj.content) {
profile_content = parsed_profile_content;
profile_found = true;
// Extract g1pubv2 and g1pub from tags
for tag_vec in &full_event_obj.tags {
if tag_vec.len() >= 2 {
match tag_vec[0].as_str() {
"g1pubv2" => g1pubv2_from_tags = Some(tag_vec[1].clone()),
"g1pub" => g1pub_from_tags = Some(tag_vec[1].clone()),
_ => {}
}
}
}
let close_msg = json!(["CLOSE", "profile-request"]);
ws_stream.send(Message::Text(close_msg.to_string())).await
.map_err(|e| anyhow!("Failed to close subscription: {}", e))?;
break;
} else {
log::warn!("Failed to parse NostrProfile content from event content string.");
}
} else {
log::warn!("Failed to parse full NostrEvent object from relay message.");
}
} else if event_type == "EOSE" {
// End of stored events
if !profile_found {
// No profile found, close the connection
let close_msg = json!(["CLOSE", "profile-request"]);
ws_stream.send(Message::Text(close_msg.to_string())).await
.map_err(|e| anyhow!("Failed to close subscription: {}", e))?;
break;
}
}
}
}
}
},
Ok(Some(Err(_e))) => {
break;
},
Ok(None) => {
// Connection closed
break;
},
Err(_) => {
// Timeout, continue
continue;
}
}
}
// Close the WebSocket connection
ws_stream.close(None).await.ok();
// Display the profile or a message if not found
if profile_found {
match data.args.output_format {
OutputFormat::Human => {
println!("Profile for npub: {}", npub);
if let Some(name) = &profile_content.name {
println!("Name: {}", name);
}
if let Some(display_name) = &profile_content.display_name {
println!("Display Name: {}", display_name);
}
if let Some(picture) = &profile_content.picture {
println!("Picture: {}", picture);
}
if let Some(about) = &profile_content.about {
println!("About: {}", about);
}
if let Some(website) = &profile_content.website {
println!("Website: {}", website);
}
if let Some(nip05) = &profile_content.nip05 {
println!("NIP-05: {}", nip05);
}
// Display from tags
if let Some(g1pubv2) = &g1pubv2_from_tags {
println!("g1pubv2 (gdev SS58 Tag): {}", g1pubv2);
}
if let Some(g1pub) = &g1pub_from_tags {
println!("g1pub (Duniter v1 Pubkey Tag): {}", g1pub);
}
for (key, value) in &profile_content.additional_fields {
println!("{}: {}", key, value);
}
}
OutputFormat::Json => {
let output = json!({
"pubkey_hex": pubkey,
"pubkey_bech32": npub,
"profile_content": profile_content, // The content part
"tags_custom": {
"g1pubv2": g1pubv2_from_tags,
"g1pub": g1pub_from_tags
},
"full_event": event_received // Include the full event if available
});
println!("{}", serde_json::to_string_pretty(&output).unwrap());
}
}
} else {
match data.args.output_format {
OutputFormat::Human => {
println!("No profile found for npub: {}", npub);
}
OutputFormat::Json => {
let output = json!({
"pubkey_hex": pubkey,
"pubkey_bech32": npub,
"profile_content": NostrProfile::default(), // Default empty profile content
"tags_custom": {
"g1pubv2": Option::<String>::None,
"g1pub": Option::<String>::None
},
"full_event": Option::<NostrEvent>::None
});
println!("{}", serde_json::to_string_pretty(&output).unwrap());
}
}
}
Ok(())
}
/// Set Nostr profile and publish to a relay
async fn set_profile(
data: Data,
name: Option<String>,
display_name: Option<String>,
picture: Option<String>,
about: Option<String>,
website: Option<String>,
nip05: Option<String>,
relay_url: Option<String>,
) -> Result<(), GcliError> {
// Check if no options were provided, and if so, display help
if name.is_none() && display_name.is_none() && picture.is_none() &&
about.is_none() && website.is_none() && nip05.is_none() {
println!("No profile options specified. Available options:");
println!(" -n, --name <NAME> Set profile name");
println!(" -d, --display-name <DISPLAY_NAME> Set profile display name");
println!(" -p, --picture <PICTURE> Set profile picture URL");
println!(" -a, --about <ABOUT> Set profile about/description");
println!(" -w, --website <WEBSITE> Set profile website URL");
println!(" -i, --nip05 <NIP05> Set profile NIP-05 identifier");
println!(" -r, --relay <RELAY> Specify relay URL to publish to");
println!("\nExample: gcli profile set --name \"Alice\" --about \"Nostr user\"");
return Ok(());
}
// Get keypair for signing
// Use the configured address or prompt the user if not set
let keypair = fetch_or_get_keypair(&data, data.cfg.address.clone(), Some(CryptoScheme::Ed25519)).await?;
// Get Nostr pubkey in hex format (used for protocol)
let pubkey = get_nostr_pubkey(&keypair)?;
// Get Nostr pubkey in bech32 format (for display)
let npub = get_nostr_npub(&keypair)?;
// Get Nostr private key in bech32 format (for display)
let nsec = get_nostr_nsec(&keypair)?;
// ---- START: Calculate g1pubv2 and g1pub for tags ----
let mut g1pubv2_for_tag: Option<String> = None;
let mut g1pub_for_tag: Option<String> = None;
let account_id: sp_core::crypto::AccountId32 = match &keypair {
KeyPair::Sr25519(pair) => pair.public().into(),
KeyPair::Ed25519(pair) => pair.public().into(),
};
let gdev_ss58_address = account_id.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(42));
g1pubv2_for_tag = Some(gdev_ss58_address);
match cesium::compute_g1v1_public_key(&keypair) {
Ok(pubkey_g1) => g1pub_for_tag = Some(pubkey_g1),
Err(_e) => {
if !matches!(keypair, KeyPair::Ed25519(_)) {
log::info!("Cannot compute g1pub (Duniter v1 pubkey) for tag as the key is not Ed25519.");
} else {
log::warn!("Failed to compute g1pub (Duniter v1 pubkey) for tag.");
}
}
}
// ---- END: Calculate g1pubv2 and g1pub for tags ----
let mut profile_content_obj = NostrProfile::default(); // This is for the 'content' field
if let Some(val) = name { profile_content_obj.name = Some(val); }
if let Some(val) = display_name { profile_content_obj.display_name = Some(val); }
if let Some(val) = picture { profile_content_obj.picture = Some(val); }
if let Some(val) = about { profile_content_obj.about = Some(val); }
if let Some(val) = website { profile_content_obj.website = Some(val); }
if let Some(val) = nip05 { profile_content_obj.nip05 = Some(val); }
let profile_content_json = serde_json::to_string(&profile_content_obj)
.map_err(|e| anyhow!("Failed to serialize profile content: {}", e))?;
let mut event = NostrEvent::new(pubkey.clone(), 0, profile_content_json);
event.tags = Vec::new(); // Initialize tags
// Add g1pubv2 and g1pub as tags
if let Some(g1_ss58) = &g1pubv2_for_tag {
event.tags.push(vec!["g1pubv2".to_string(), g1_ss58.clone()]);
}
if let Some(g1_key) = &g1pub_for_tag {
event.tags.push(vec!["g1pub".to_string(), g1_key.clone()]);
}
log::debug!("Created event with pubkey: {}", event.pubkey);
log::debug!("Event content: {}", event.content);
log::debug!("Event tags: {:?}", event.tags); // Log the tags
event.calculate_id()?;
event.sign(&keypair)?;
// Use default relay if none provided
let relay = relay_url.unwrap_or_else(|| {
data.cfg
.relay
.clone()
.unwrap_or_else(|| "wss://relay.copylaradio.com".to_string())
});
// Ensure the relay URL starts with ws:// or wss://
let relay = if !relay.starts_with("ws://") && !relay.starts_with("wss://") {
format!("wss://{}", relay)
} else {
relay
};
log::debug!("Using relay URL: {}", relay);
// Parse the URL
let url = Url::parse(&relay).map_err(|e| anyhow!("Invalid relay URL: {}", e))?;
// Connect to the WebSocket
println!("Connecting to relay {}...", relay);
let (mut ws_stream, _) = connect_async(url).await
.map_err(|e| anyhow!("Failed to connect to relay: {}", e))?;
println!("Connected to relay, publishing profile...");
// Create event message - CORRECTED FORMAT
// According to NIP-01, EVENT should be an array: ["EVENT", <event_object>]
let event_msg = json!([
"EVENT",
event
]);
let event_msg_str = event_msg.to_string();
log::debug!("Sending message to relay: {}", event_msg_str);
// Send the event
ws_stream.send(Message::Text(event_msg_str)).await
.map_err(|e| anyhow!("Failed to send event: {}", e))?;
// Wait for OK message with timeout
let mut success = false;
let mut response_message = String::new();
// Set a timeout for receiving messages
let timeout = tokio::time::Duration::from_secs(10); // Increased timeout
let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(100));
log::debug!("Waiting for relay response with timeout of {} seconds", timeout.as_secs());
let start_time = tokio::time::Instant::now();
while start_time.elapsed() < timeout {
interval.tick().await;
// Check for messages with a timeout
match tokio::time::timeout(
tokio::time::Duration::from_millis(100),
ws_stream.next()
).await {
Ok(Some(Ok(msg))) => {
if let Message::Text(text) = msg {
log::debug!("Received message from relay: {}", text);
response_message = text.clone();
// Parse the message
if let Ok(json) = serde_json::from_str::<Value>(&text) {
log::debug!("Parsed JSON response: {}", json);
// Check if it's an OK message
if let Some(msg_type) = json.get(0).and_then(|v| v.as_str()) {
log::debug!("Message type: {}", msg_type);
if msg_type == "OK" && json.get(1).is_some() && json.get(2).is_some() {
let success_status = json[2].as_bool().unwrap_or(false);
log::debug!("OK status: {}", success_status);
if success_status {
success = true;
log::debug!("Event accepted by relay");
break;
} else {
// Error message
if let Some(error_msg) = json.get(3).and_then(|v| v.as_str()) {
log::error!("Relay rejected event: {}", error_msg);
return Err(anyhow!("Relay rejected event: {}", error_msg).into());
} else {
log::error!("Relay rejected event without specific error message");
}
break;
}
} else if msg_type == "NOTICE" && json.get(1).and_then(|v| v.as_str()).is_some() {
log::debug!("Received NOTICE: {}", json[1].as_str().unwrap());
}
}
} else {
log::warn!("Failed to parse relay response as JSON: {}", text);
}
} else {
log::debug!("Received non-text message from relay");
}
},
Ok(Some(Err(e))) => {
log::error!("WebSocket error: {}", e);
break;
},
Ok(None) => {
log::debug!("Connection closed by relay");
break;
},
Err(_) => {
// Timeout, continue
continue;
}
}
}
if start_time.elapsed() >= timeout {
log::warn!("Timeout waiting for relay response");
}
// Close the WebSocket connection
log::debug!("Closing WebSocket connection");
ws_stream.close(None).await.ok();
match data.args.output_format {
OutputFormat::Human => {
println!("Profile data published to npub (bech32): {}", npub);
println!("Nostr pubkey (hex): {}", pubkey);
if let Some(val) = &profile_content_obj.name { println!("Name: {}", val); }
if let Some(val) = &profile_content_obj.display_name { println!("Display Name: {}", val); }
if let Some(val) = &profile_content_obj.picture { println!("Picture: {}", val); }
if let Some(val) = &profile_content_obj.about { println!("About: {}", val); }
if let Some(val) = &profile_content_obj.website { println!("Website: {}", val); }
if let Some(val) = &profile_content_obj.nip05 { println!("NIP-05: {}", val); }
// Print calculated tag values for user feedback
if let Some(g1_ss58) = &g1pubv2_for_tag {
println!("g1pubv2 (Tag to be published): {}", g1_ss58);
}
if let Some(g1_key) = &g1pub_for_tag {
println!("g1pub (Tag to be published): {}", g1_key);
}
println!("\nPublished to relay: {}", relay);
if success {
println!("Status: Success");
} else {
println!("Status: No confirmation received from relay");
println!("Last response: {}", response_message);
}
}
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&json!({
"pubkey_hex": pubkey,
"pubkey_bech32": npub,
"seckey_bech32": nsec,
"profile_content_sent": profile_content_obj, // what was in content
"calculated_tags": {
"g1pubv2": g1pubv2_for_tag,
"g1pub": g1pub_for_tag
},
"event_sent": event, // The full event, including new tags
"relay": relay,
"success": success,
"response": response_message
})).unwrap());
}
}
Ok(())
}
\ No newline at end of file
...@@ -15,6 +15,8 @@ pub struct Config { ...@@ -15,6 +15,8 @@ pub struct Config {
/// user address /// user address
/// to perform actions, user must provide secret /// to perform actions, user must provide secret
pub address: Option<AccountId>, pub address: Option<AccountId>,
/// nostr relay endpoint
pub relay: Option<String>,
} }
impl std::default::Default for Config { impl std::default::Default for Config {
...@@ -23,6 +25,7 @@ impl std::default::Default for Config { ...@@ -23,6 +25,7 @@ impl std::default::Default for Config {
duniter_endpoint: String::from(data::LOCAL_DUNITER_ENDPOINT), duniter_endpoint: String::from(data::LOCAL_DUNITER_ENDPOINT),
indexer_endpoint: String::from(data::LOCAL_INDEXER_ENDPOINT), indexer_endpoint: String::from(data::LOCAL_INDEXER_ENDPOINT),
address: None, address: None,
relay: Some(String::from("wss://relay.copylaradio.com")),
} }
} }
} }
...@@ -34,10 +37,16 @@ impl std::fmt::Display for Config { ...@@ -34,10 +37,16 @@ impl std::fmt::Display for Config {
} else { } else {
"(no address)".to_string() "(no address)".to_string()
}; };
let relay = if let Some(relay) = &self.relay {
relay.clone()
} else {
"(no relay)".to_string()
};
writeln!(f, "Ğcli config")?; writeln!(f, "Ğcli config")?;
writeln!(f, "duniter endpoint {}", self.duniter_endpoint)?; writeln!(f, "duniter endpoint {}", self.duniter_endpoint)?;
writeln!(f, "indexer endpoint {}", self.indexer_endpoint)?; writeln!(f, "indexer endpoint {}", self.indexer_endpoint)?;
write!(f, "address {address}") writeln!(f, "address {address}")?;
write!(f, "nostr relay {relay}")
} }
} }
......
...@@ -155,6 +155,9 @@ pub enum Subcommand { ...@@ -155,6 +155,9 @@ pub enum Subcommand {
/// Key management (import, generate, list...) /// Key management (import, generate, list...)
#[clap(subcommand)] #[clap(subcommand)]
Vault(commands::vault::Subcommand), Vault(commands::vault::Subcommand),
/// Nostr profile management (get, set...)
#[clap(subcommand)]
Profile(commands::profile::Subcommand),
/// Cesium /// Cesium
#[clap(subcommand, hide = true)] #[clap(subcommand, hide = true)]
Cesium(commands::cesium::Subcommand), Cesium(commands::cesium::Subcommand),
...@@ -233,6 +236,7 @@ async fn main() -> Result<(), GcliError> { ...@@ -233,6 +236,7 @@ async fn main() -> Result<(), GcliError> {
Subcommand::Indexer(subcommand) => indexer::handle_command(data, subcommand).await, Subcommand::Indexer(subcommand) => indexer::handle_command(data, subcommand).await,
Subcommand::Config(subcommand) => conf::handle_command(data, subcommand).await, Subcommand::Config(subcommand) => conf::handle_command(data, subcommand).await,
Subcommand::Vault(subcommand) => commands::vault::handle_command(data, subcommand).await, Subcommand::Vault(subcommand) => commands::vault::handle_command(data, subcommand).await,
Subcommand::Profile(subcommand) => commands::profile::handle_command(data, subcommand).await,
Subcommand::Cesium(subcommand) => commands::cesium::handle_command(data, subcommand).await, Subcommand::Cesium(subcommand) => commands::cesium::handle_command(data, subcommand).await,
Subcommand::Publish => commands::publish::handle_command().await, Subcommand::Publish => commands::publish::handle_command().await,
}; };
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment