diff --git a/Cargo.lock b/Cargo.lock index 4299623faad69e00142959ce20afd58650451cfd..15ebd51ea40ecfe4498d14ffc7f91702585641d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,7 +54,7 @@ checksum = "edeef7d7b199195a2d7d7a8155d2d04aee736e60c5c7bdd7097d115369a8817d" dependencies = [ "age-core", "base64 0.21.7", - "bech32", + "bech32 0.9.1", "chacha20poly1305", "cookie-factory", "hmac 0.12.1", @@ -806,6 +806,12 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bech32" +version = "0.10.0-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98f7eed2b2781a6f0b5c903471d48e15f56fb4e1165df8a9a2337fd1a59d45ea" + [[package]] name = "beef" version = "0.5.2" @@ -851,6 +857,20 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "bitcoin" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c85783c2fe40083ea54a33aa2f0ba58831d90fcd190f5bdc47e74e84d2a96ae" +dependencies = [ + "bech32 0.10.0-beta", + "bitcoin-internals", + "bitcoin_hashes 0.13.0", + "hex-conservative", + "hex_lit", + "secp256k1", +] + [[package]] name = "bitcoin-internals" version = "0.2.0" @@ -1596,6 +1616,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + [[package]] name = "der" version = "0.7.9" @@ -2351,7 +2377,10 @@ version = "0.4.2" dependencies = [ "age", "anyhow", + "base64 0.21.7", + "bech32 0.9.1", "bip39", + "bitcoin", "bs58", "clap", "clap_complete", @@ -2371,12 +2400,16 @@ dependencies = [ "rstest", "scrypt", "sea-orm", + "secp256k1", "serde", "serde_json", + "sha2 0.10.8", "sp-core", "sp-runtime", "subxt", "tokio", + "tokio-tungstenite", + "url", ] [[package]] @@ -2618,6 +2651,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + [[package]] name = "hkdf" version = "0.12.4" @@ -5182,6 +5221,8 @@ version = "0.28.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d24b59d129cdadea20aea4fb2352fa053712e5d713eee47d700cd4b2bc002f10" dependencies = [ + "bitcoin_hashes 0.13.0", + "rand", "secp256k1-sys", ] @@ -6752,6 +6793,22 @@ dependencies = [ "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]] name = "tokio-util" version = "0.7.12" @@ -6979,6 +7036,27 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "twox-hash" version = "1.6.3" @@ -7124,6 +7202,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8parse" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 2e952bc9668c455c9fe0582ad789672a5734250c..cef19f6105f8c0473498ab230c9d2f9b82126965 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,8 @@ subxt = { git = 'https://github.com/duniter/subxt', branch = 'subxt-v0.37.0-duni "native", "jsonrpsee", ] } +base64 = "0.21.0" +bitcoin = "0.31.1" # ou une version compatible # substrate primitives dependencies sp-core = { git = "https://github.com/duniter/duniter-polkadot-sdk.git", branch = "duniter-substrate-v1.14.0" } @@ -41,12 +43,17 @@ inquire = "^0.7.5" serde = { version = "^1.0", features = ["derive"] } serde_json = "^1.0.128" tokio = { version = "^1.40.0", features = ["macros"] } +tokio-tungstenite = { version = "0.21.0", features = ["rustls-tls-webpki-roots"] } confy = "^0.5.1" bs58 = "^0.5.1" directories = "^5.0.1" comfy-table = "^7.1.1" sea-orm = { version = "1.1.0", features = [ "sqlx-sqlite", "runtime-tokio-native-tls", "macros" ] } 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 scrypt = { version = "^0.11", default-features = false } # for old-style key generation diff --git a/examples/profile.examples.md b/examples/profile.examples.md new file mode 100644 index 0000000000000000000000000000000000000000..ec2a23c56351194b4ea200da6d6d9ee4b6580fd1 --- /dev/null +++ b/examples/profile.examples.md @@ -0,0 +1,193 @@ +# Exemples d'utilisation de la commande `gcli profile` + +## Notes importantes +- L'option `-o json` doit toujours être placée au début de la commande, juste après `gcli` +- L'option `--no-password` doit être placée avant la sous-commande `profile` +- Le relais par défaut est `wss://relay.copylaradio.com` +- Les clés sont stockées dans un coffre-fort local + +## Exemple complet : Gestion du profil de TOTO + +### 1. Import de la clé G1v1 +```bash +# Import de la clé G1v1 de TOTO +gcli -o json --no-password vault import -S g1v1 --g1v1-id "toto" --g1v1-password "toto" --no-password -n TOTO +``` + +### 2. Configuration du profil +```bash +# Définition du profil avec informations personnelles +gcli -o json --no-password profile set \ + --name "TOTO" \ + --display-name "TOTO le Super" \ + --about "Développeur passionné de cryptographie" \ + --picture "https://example.com/toto.jpg" \ + --website "https://toto.dev" \ + --github "toto-dev" \ + --twitter "toto_crypto" \ + --mastodon "mastodon.social/@toto" \ + --ipfs-gw "https://ipfs.io" \ + --ipns-vault "/ipns/QmNostrVaultKey" \ + --zencard "zencard_address" \ + --tw-feed "tw_feed_key" +``` + +### 3. Vérification du profil +```bash +# Vérification que le profil a bien été publié +gcli -o json --no-password profile get +``` + +### 4. Export des clés jumelles +```bash +# Export des clés IPFS +gcli -o json --no-password profile export --format ipfs + +# Export des clés Nostr +gcli -o json --no-password profile export --format nostr + +# Export des clés Bitcoin +gcli -o json --no-password profile export --format bitcoin + +# Export des clés Monero +gcli -o json --no-password profile export --format monero + +# Export des clés Duniter v1 +gcli -o json --no-password profile export --format g1v1 + +# Export des clés Duniter v2 +gcli -o json --no-password profile export --format g1v2 +``` + +### 5. Suppression du profil (si nécessaire) +```bash +# Suppression du profil +gcli -o json --no-password profile remove +``` + +### Champs supplémentaires créés par make_NOSTRCARD.sh + +Le script `make_NOSTRCARD.sh` ajoute automatiquement plusieurs champs spéciaux au profil Nostr : + +1. **Clés jumelles** (twin_keys) : + - `ipns` : Clé IPNS pour le stockage personnel + - `bitcoin` : Adresse Bitcoin jumelle + - `monero` : Adresse Monero jumelle + +2. **Tags NIP-39** pour les identités externes : + - `g1pubv2` : Clé publique Duniter v2 (format SS58) + - `g1pub` : Clé publique Duniter v1 + - `ipfs_gw` : URL de la passerelle IPFS + - `ipns_vault` : Clé IPNS du coffre-fort + - `zencard` : Adresse ZenCard + - `tw_feed` : Clé IPNS du flux + +3. **Métadonnées de localisation** : + - `ZUMAP` : Coordonnées géographiques (latitude/longitude) + - `GPS` : Données GPS formatées + +4. **Informations de sécurité** : + - `TODATE` : Horodatage de création + - `LANG` : Langue préférée + - `HEX` : Clé publique en format hexadécimal + - `NPUB` : Clé publique en format bech32 + +5. **QR Codes générés** : + - `IPNS.QR.png` : QR code pour l'accès au coffre-fort IPNS + - `G1PUBNOSTR.QR.png` : QR code de l'identité G1 + - `scan_${MOATS}.png` : Photo de profil scannée + +Ces champs sont automatiquement ajoutés lors de la création du profil via `make_NOSTRCARD.sh` et sont visibles dans la sortie JSON de la commande `profile get`. + +## 1. Récupérer un profil (`get`) + +```bash +# Récupérer le profil depuis le relais par défaut +gcli profile get + +# Récupérer le profil depuis un relais spécifique +gcli profile get --relay wss://relay.example.com + +# Récupérer le profil au format JSON +gcli -o json profile get + +# Récupérer le profil sans mot de passe +gcli --no-password profile get +``` + +## 2. Définir un profil (`set`) + +```bash +# Définir le nom et la description +gcli profile set --name "Alice" --about "Nostr user" + +# Définir l'image de profil et la bannière +gcli profile set --picture "https://example.com/avatar.jpg" --banner "https://example.com/banner.jpg" + +# Définir les identités sociales +gcli profile set --github "alice" --twitter "alice" --mastodon "mastodon.social/@alice" + +# Définir les clés jumelles +gcli profile set --ipfs-gw "https://ipfs.io" --ipns-vault "key123" --zencard "address123" + +# Publier sur un relais spécifique +gcli profile set --name "Alice" --relay wss://relay.example.com + +# Publier sans mot de passe +gcli --no-password profile set --name "Alice" + +# Publier au format JSON +gcli -o json profile set --name "Alice" +``` + +## 3. Supprimer un profil (`remove`) + +```bash +# Supprimer le profil du relais par défaut +gcli profile remove + +# Supprimer le profil de plusieurs relais +gcli profile remove --relays "wss://relay1.com,wss://relay2.com" + +# Supprimer sans mot de passe +gcli --no-password profile remove + +# Supprimer au format JSON +gcli -o json profile remove +``` + +## 4. Exporter les clés (`export`) + +```bash +# Exporter les clés Nostr +gcli profile export --format nostr + +# Exporter les clés IPFS +gcli profile export --format ipfs + +# Exporter les clés Bitcoin +gcli profile export --format bitcoin + +# Exporter les clés Monero +gcli profile export --format monero + +# Exporter les clés Duniter v1 +gcli profile export --format g1v1 + +# Exporter les clés Duniter v2 +gcli profile export --format g1v2 + +# Exporter sans mot de passe +gcli --no-password profile export --format nostr + +# Exporter au format JSON +gcli -o json profile export --format nostr + +# Exporter sans mot de passe au format JSON +gcli -o json --no-password profile export --format nostr +``` + +## Notes supplémentaires +- Pour la commande `set`, si aucune option n'est fournie, l'aide s'affiche +- Pour la commande `export`, si le format n'est pas reconnu, la liste des formats disponibles s'affiche +- Les formats d'export disponibles sont : `ipfs`, `nostr`, `bitcoin`, `monero`, `g1v1`, `g1v2` \ No newline at end of file diff --git a/examples/vault.examples.md b/examples/vault.examples.md new file mode 100644 index 0000000000000000000000000000000000000000..dec5d16a970101995aee5b9d5dd71966fe8888bc --- /dev/null +++ b/examples/vault.examples.md @@ -0,0 +1,137 @@ +# Exemples d'utilisation de la commande `vault` + +La commande `vault` permet de gérer les clés et les comptes dans le coffre-fort. Voici les différentes sous-commandes disponibles : + +## Lister les comptes + +### Lister tous les comptes +```bash +# Format humain (par défaut) +gcli vault list all + +# Afficher les clés publiques G1v1 pour les clés ed25519 +gcli vault list all --show-g1v1 +``` + +### Lister uniquement les comptes de base +```bash +# Format humain (par défaut) +gcli vault list base + +# Afficher les clés publiques G1v1 pour les clés ed25519 +gcli vault list base --show-g1v1 +``` + +### Lister les comptes liés à une adresse spécifique +```bash +# Par adresse SS58 +gcli vault list for -a 5GV2PGDLRJ5e5EiupV4myDUzf9WzTddSExQcjQmLfwprKC8r + +# Par nom de compte +gcli vault list for -v MonCompte + +# Afficher les clés publiques G1v1 +gcli vault list for -v MonCompte --show-g1v1 +``` + +## Importer une clé + +### Import en mode interactif +```bash +# Format Substrate (par défaut) +gcli vault import + +# Format Seed +gcli vault import -S seed + +# Format G1v1 +gcli vault import -S g1v1 +``` + +### Import en mode non-interactif +```bash +# Format Substrate avec URI +gcli vault import -u "bottom drive obey lake curtain smoke basket hold race lonely fit walk//0" + +# Format G1v1 avec ID et mot de passe +gcli vault import -S g1v1 --g1v1-id "mon_id" --g1v1-password "mon_mot_de_passe" + +# Sans mot de passe +gcli vault import -u "bottom drive obey lake curtain smoke basket hold race lonely fit walk" --no-password + +# Avec un nom personnalisé +gcli vault import -u "bottom drive obey lake curtain smoke basket hold race lonely fit walk" -n "MonCompte" +``` + +## Dériver un compte + +### Dérivation en mode interactif +```bash +# Par adresse SS58 +gcli vault derive -a 5GV2PGDLRJ5e5EiupV4myDUzf9WzTddSExQcjQmLfwprKC8r + +# Par nom de compte +gcli vault derive -v MonCompte +``` + +### Dérivation en mode non-interactif +```bash +# Par adresse SS58 avec chemin de dérivation +gcli vault derive -a 5GV2PGDLRJ5e5EiupV4myDUzf9WzTddSExQcjQmLfwprKC8r -d "//0" + +# Par nom de compte avec chemin de dérivation +gcli vault derive -v MonCompte -d "//0" + +# Sans mot de passe +gcli vault derive -v MonCompte -d "//0" --no-password + +# Avec un nom personnalisé +gcli vault derive -v MonCompte -d "//0" -n "MonCompteDerive" +``` + +## Renommer un compte +```bash +gcli vault rename 5GV2PGDLRJ5e5EiupV4myDUzf9WzTddSExQcjQmLfwprKC8r +``` + +## Supprimer un compte +```bash +# Par adresse SS58 +gcli vault remove -a 5GV2PGDLRJ5e5EiupV4myDUzf9WzTddSExQcjQmLfwprKC8r + +# Par nom de compte +gcli vault remove -v MonCompte +``` + +## Inspecter un compte +```bash +# Par adresse SS58 +gcli vault inspect -a 5GV2PGDLRJ5e5EiupV4myDUzf9WzTddSExQcjQmLfwprKC8r + +# Par nom de compte +gcli vault inspect -v MonCompte +``` + +## Utiliser un compte +```bash +# Par adresse SS58 +gcli vault use -a 5GV2PGDLRJ5e5EiupV4myDUzf9WzTddSExQcjQmLfwprKC8r + +# Par nom de compte +gcli vault use -v MonCompte +``` + +## Générer une mnémonique +```bash +gcli vault generate +``` + +## Voir l'emplacement du coffre-fort +```bash +gcli vault where +``` + +## Migrer les anciens fichiers de clés +```bash +gcli vault migrate +``` \ No newline at end of file diff --git a/src/commands.rs b/src/commands.rs index 15f96bacd9e9eef0bd51f4edc3ddcb6d864e88b5..a1e0dc895b41dadb61d5af272e9fcbfdb658a793 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -8,6 +8,7 @@ pub mod expire; pub mod identity; pub mod net_test; pub mod oneshot; +pub mod profile; pub mod publish; pub mod revocation; pub mod runtime; diff --git a/src/commands/profile.rs b/src/commands/profile.rs new file mode 100644 index 0000000000000000000000000000000000000000..37970132515a8a945362f6eb31fb772dbf0098e1 --- /dev/null +++ b/src/commands/profile.rs @@ -0,0 +1,1788 @@ +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 sp_core::ByteArray; +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; +use bs58; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; + +/// 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() + } + }; + + // Derive a seed for secp256k1 by hashing the Ed25519 seed with SHA256, like keygen.py + let secp = Secp256k1::new(); + let mut hasher = Sha256::new(); + hasher.update(&seed); + let secp256k1_seed = hasher.finalize().to_vec(); + + let secret_key = SecretKey::from_slice(&secp256k1_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 banner URL + #[clap(short = 'b', long)] + banner: 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>, + + /// GitHub username + #[clap(long)] + github: Option<String>, + + /// Twitter username + #[clap(long)] + twitter: Option<String>, + + /// Mastodon identity (format: instance/@username) + #[clap(long)] + mastodon: Option<String>, + + /// Telegram user ID + #[clap(long)] + telegram: Option<String>, + + /// IPFS Gateway URL + #[clap(long)] + ipfs_gw: Option<String>, + + /// NOSTR Card IPNS vault key + #[clap(long)] + ipns_vault: Option<String>, + + /// ZenCard wallet address + #[clap(long)] + zencard: Option<String>, + + /// TW Feed IPNS key + #[clap(long)] + tw_feed: Option<String>, + + /// Relay URL to publish profile to + #[clap(short, long)] + relay: Option<String>, + }, + /// Remove Nostr profile and associated events + Remove { + /// List of relay URLs to remove profile from (comma-separated) + #[clap(short, long)] + relays: Option<String>, + }, + /// Export keys in different formats + Export { + /// Export format (ipfs, nostr, bitcoin, monero) + #[clap(short, long)] + format: 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 banner: Option<String>, + pub about: Option<String>, + pub website: Option<String>, + pub nip05: Option<String>, + pub bot: Option<bool>, + pub twin_keys: Option<TwinKeys>, + #[serde(flatten)] + pub additional_fields: HashMap<String, String>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TwinKeys { + pub ipns: Option<String>, + pub bitcoin: Option<String>, + pub monero: Option<String>, +} + +impl Default for NostrProfile { + fn default() -> Self { + Self { + name: None, + display_name: None, + picture: None, + banner: None, + about: None, + website: None, + nip05: None, + bot: Some(false), + twin_keys: Some(TwinKeys { + ipns: None, + bitcoin: None, + monero: 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) => { + // For Ed25519, take the first 32 bytes (the seed/private key part in raw format) + // and hash it with SHA256, like keygen.py does with its 'seed'. + let raw_bytes = pair.to_raw_vec(); + let ed25519_seed = &raw_bytes[..32]; + let mut hasher = sha2::Sha256::new(); + hasher.update(ed25519_seed); + 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, + banner, + about, + website, + nip05, + github, + twitter, + mastodon, + telegram, + ipfs_gw, + ipns_vault, + zencard, + tw_feed, + relay, + } => { + set_profile( + data, + name, + display_name, + picture, + banner, + about, + website, + nip05, + github, + twitter, + mastodon, + telegram, + ipfs_gw, + ipns_vault, + zencard, + tw_feed, + relay, + ) + .await + } + Subcommand::Remove { relays } => remove_profile(data, relays).await, + Subcommand::Export { format } => export_keys(data, format).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 = if data.args.no_password { + // If no_password is set, try to get the keypair without prompting + match fetch_or_get_keypair(&data, data.cfg.address.clone(), Some(CryptoScheme::Ed25519)).await { + Ok(pair) => pair, + Err(e) => { + if data.args.output_format == OutputFormat::Json { + return Err(anyhow!("Failed to get keypair without password: {}", e).into()); + } else { + return Err(anyhow!("Failed to get keypair without password. Try removing --no-password option.").into()); + } + } + } + } else { + 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)?; + + if data.args.output_format == OutputFormat::Human { + 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 + if data.args.output_format == OutputFormat::Human { + println!("Connecting to relay {}...", relay); + } + let (mut ws_stream, _) = connect_async(url).await + .map_err(|e| anyhow!("Failed to connect to relay: {}", e))?; + + if data.args.output_format == OutputFormat::Human { + 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]); + + if data.args.output_format == OutputFormat::Human { + 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 => { + // Create a clean JSON structure + let output = json!({ + "vault": { + "address": data.cfg.address.clone(), + "g1v1_pub_key": match &keypair { + KeyPair::Ed25519(_pair) => { + match cesium::compute_g1v1_public_key(&keypair) { + Ok(pubkey) => Some(pubkey), + Err(_) => None + } + }, + _ => None + }, + "crypto_scheme": match &keypair { + KeyPair::Sr25519(_) => Some("Sr25519"), + KeyPair::Ed25519(_) => Some("Ed25519"), + } + }, + "pubkey_hex": pubkey, + "pubkey_bech32": npub, + "profile_content": { + "name": profile_content.name, + "display_name": profile_content.display_name, + "picture": profile_content.picture, + "banner": profile_content.banner, + "about": profile_content.about, + "website": profile_content.website, + "nip05": profile_content.nip05, + "bot": profile_content.bot, + "twin_keys": profile_content.twin_keys + }, + "tags_custom": { + "g1pubv2": g1pubv2_from_tags, + "g1pub": g1pub_from_tags + }, + "full_event": { + "id": event_received.as_ref().map(|e| &e.id), + "pubkey": event_received.as_ref().map(|e| &e.pubkey), + "created_at": event_received.as_ref().map(|e| e.created_at), + "kind": event_received.as_ref().map(|e| e.kind), + "tags": event_received.as_ref().map(|e| &e.tags), + "content": event_received.as_ref().map(|e| &e.content), + "sig": event_received.as_ref().map(|e| &e.sig) + } + }); + 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!({ + "vault": { + "address": data.cfg.address.clone(), + "g1v1_pub_key": match &keypair { + KeyPair::Ed25519(_pair) => { + match cesium::compute_g1v1_public_key(&keypair) { + Ok(pubkey) => Some(pubkey), + Err(_) => None + } + }, + _ => None + }, + "crypto_scheme": match &keypair { + KeyPair::Sr25519(_) => Some("Sr25519"), + KeyPair::Ed25519(_) => Some("Ed25519"), + } + }, + "pubkey_hex": pubkey, + "pubkey_bech32": npub, + "profile_content": { + "name": null, + "display_name": null, + "picture": null, + "banner": null, + "about": null, + "website": null, + "nip05": null, + "bot": null, + "twin_keys": null + }, + "tags_custom": { + "g1pubv2": null, + "g1pub": null + }, + "full_event": null + }); + 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>, + banner: Option<String>, + about: Option<String>, + website: Option<String>, + nip05: Option<String>, + github: Option<String>, + twitter: Option<String>, + mastodon: Option<String>, + telegram: Option<String>, + ipfs_gw: Option<String>, + ipns_vault: Option<String>, + zencard: Option<String>, + tw_feed: 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() && banner.is_none() && + about.is_none() && website.is_none() && nip05.is_none() && github.is_none() && + twitter.is_none() && mastodon.is_none() && telegram.is_none() { + if data.args.output_format == OutputFormat::Human { + 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!(" -b, --banner <BANNER> Set profile banner 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!(" --github <USERNAME> Set GitHub username"); + println!(" --twitter <USERNAME> Set Twitter username"); + println!(" --mastodon <IDENTITY> Set Mastodon identity (instance/@username)"); + println!(" --telegram <USER_ID> Set Telegram user ID"); + println!(" --ipfs-gw <URL> Set IPFS Gateway URL"); + println!(" --ipns-vault <KEY> Set NOSTR Card IPNS vault key"); + println!(" --zencard <ADDRESS> Set ZenCard wallet address"); + println!(" --tw-feed <KEY> Set TW Feed IPNS key"); + println!(" -r, --relay <RELAY> Specify relay URL to publish to"); + println!("\nExample: gcli profile set --name \"Alice\" --about \"Nostr user\""); + } else { + println!("{}", serde_json::to_string_pretty(&json!({ + "error": "No profile options specified", + "available_options": { + "name": "Set profile name", + "display_name": "Set profile display name", + "picture": "Set profile picture URL", + "banner": "Set profile banner URL", + "about": "Set profile about/description", + "website": "Set profile website URL", + "nip05": "Set profile NIP-05 identifier", + "github": "Set GitHub username", + "twitter": "Set Twitter username", + "mastodon": "Set Mastodon identity (instance/@username)", + "telegram": "Set Telegram user ID", + "ipfs_gw": "Set IPFS Gateway URL", + "ipns_vault": "Set NOSTR Card IPNS vault key", + "zencard": "Set ZenCard wallet address", + "tw_feed": "Set TW Feed IPNS key", + "relay": "Specify relay URL to publish to" + } + })).unwrap()); + } + return Ok(()); + } + + // Get keypair for signing + let keypair = if data.args.no_password { + // If no_password is set, try to get the keypair without prompting + match fetch_or_get_keypair(&data, data.cfg.address.clone(), Some(CryptoScheme::Ed25519)).await { + Ok(pair) => pair, + Err(e) => { + if data.args.output_format == OutputFormat::Json { + return Err(anyhow!("Failed to get keypair without password: {}", e).into()); + } else { + return Err(anyhow!("Failed to get keypair without password. Try removing --no-password option.").into()); + } + } + } + } else { + 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)?; + + // Calculate g1pubv2 (gdev SS58) and g1pub (Duniter v1) + let mut g1pub_for_tag: Option<String> = None; + + // Calculate g1pubv2 (SS58 address) + let account_id: sp_core::crypto::AccountId32 = match &keypair { + KeyPair::Sr25519(pair) => pair.public().into(), + KeyPair::Ed25519(pair) => pair.public().into(), + }; + let g1pubv2_for_tag: Option<String> = Some(account_id.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(42))); + + // Calculate g1pub (Duniter v1 pubkey) + 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) as the key is not Ed25519."); + } else { + log::warn!("Failed to compute g1pub (Duniter v1 pubkey): {}", e); + } + } + } + + // Create profile content + let mut profile_content_obj = NostrProfile::default(); + 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) = banner { profile_content_obj.banner = 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); } + profile_content_obj.bot = Some(false); + + // Generate twin keys + let ipns_key = { + // First get the public key bytes + let pub_bytes = match &keypair { + KeyPair::Sr25519(pair) => pair.public().to_raw_vec(), + KeyPair::Ed25519(pair) => pair.public().to_raw_vec(), + }; + + // Create protobuf format for IPFS public key + let mut protobuf = Vec::new(); + protobuf.extend_from_slice(&[0x08, 0x01]); // Ed25519 key type + protobuf.extend_from_slice(&[0x12, 0x20]); // Length prefix + protobuf.extend_from_slice(&pub_bytes); + + // Add multihash prefix + let mut peer_id_bytes = Vec::new(); + peer_id_bytes.extend_from_slice(&[0x00, 0x24]); // Multihash identity prefix + peer_id_bytes.extend_from_slice(&protobuf); + + // Encode as base58 + format!("12D3Koo{}", bs58::encode(&peer_id_bytes).into_string()) + }; + + // Bitcoin address (using the same derivation as in keygen.py) + let bitcoin_address = match &keypair { + KeyPair::Sr25519(pair) => { + let mut hasher = sha2::Sha256::new(); + hasher.update(&pair.to_raw_vec()); + let seed = hasher.finalize(); + let secp = secp256k1::Secp256k1::new(); + let secret_key = secp256k1::SecretKey::from_slice(&seed[0..32]) + .map_err(|e| anyhow!("Failed to create Bitcoin secret key: {}", e))?; + let public_key = secp256k1::PublicKey::from_secret_key(&secp, &secret_key); + hex::encode(&public_key.serialize()) + }, + KeyPair::Ed25519(pair) => { + // For Ed25519, take the first 32 bytes (the seed/private key part in raw format) + // and hash it with SHA256, like keygen.py does with its 'seed'. + let raw_bytes = pair.to_raw_vec(); + let ed25519_seed = &raw_bytes[..32]; + let mut hasher = sha2::Sha256::new(); + hasher.update(ed25519_seed); + let seed = hasher.finalize(); + let secp = secp256k1::Secp256k1::new(); + let secret_key = secp256k1::SecretKey::from_slice(&seed[0..32]) + .map_err(|e| anyhow!("Failed to create Bitcoin secret key: {}", e))?; + let public_key = secp256k1::PublicKey::from_secret_key(&secp, &secret_key); + hex::encode(&public_key.serialize()) + } + }; + + // Monero address (using the same derivation as in keygen.py) + let monero_address = match &keypair { + KeyPair::Sr25519(pair) => { + let mut hasher = sha2::Sha256::new(); + hasher.update(&pair.to_raw_vec()); + let seed = hasher.finalize(); + hex::encode(&seed[0..32]) + }, + KeyPair::Ed25519(pair) => { + let mut hasher = sha2::Sha256::new(); + hasher.update(&pair.to_raw_vec()); + let seed = hasher.finalize(); + hex::encode(&seed[0..32]) + } + }; + + 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(); + + // Add twin keys as tags with proper formats + event.tags.push(vec!["twin_key".to_string(), "ipns".to_string(), ipns_key]); + event.tags.push(vec!["twin_key".to_string(), "bitcoin".to_string(), bitcoin_address]); + event.tags.push(vec!["twin_key".to_string(), "monero".to_string(), monero_address]); + + // Add NIP-39 external identity tags + if let Some(gh) = &github { + event.tags.push(vec!["i".to_string(), format!("github:{}", gh), "".to_string()]); + } + if let Some(tw) = &twitter { + event.tags.push(vec!["i".to_string(), format!("twitter:{}", tw), "".to_string()]); + } + if let Some(mstd) = &mastodon { + event.tags.push(vec!["i".to_string(), format!("mastodon:{}", mstd), "".to_string()]); + } + if let Some(tg) = &telegram { + event.tags.push(vec!["i".to_string(), format!("telegram:{}", tg), "".to_string()]); + } + + // Add g1pubv2 and g1pub tags + if let Some(g1_ss58) = &g1pubv2_for_tag { + event.tags.push(vec!["g1pubv2".to_string(), g1_ss58.clone()]); + // Also add as an external identity for compatibility + event.tags.push(vec!["i".to_string(), format!("g1pubv2:{}", g1_ss58), "".to_string()]); + } + if let Some(g1_key) = &g1pub_for_tag { + event.tags.push(vec!["g1pub".to_string(), g1_key.clone()]); + // Also add as an external identity for compatibility + event.tags.push(vec!["i".to_string(), format!("g1pub:{}", g1_key), "".to_string()]); + } + + // Add additional custom tags + if let Some(gw) = &ipfs_gw { + event.tags.push(vec!["i".to_string(), format!("ipfs_gw:{}", gw), "".to_string()]); + } + if let Some(vault) = &ipns_vault { + event.tags.push(vec!["i".to_string(), format!("ipns_vault:{}", vault), "".to_string()]); + } + if let Some(card) = &zencard { + event.tags.push(vec!["i".to_string(), format!("zencard:{}", card), "".to_string()]); + } + if let Some(feed) = &tw_feed { + event.tags.push(vec!["i".to_string(), format!("tw_feed:{}", feed), "".to_string()]); + } + + 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 + if data.args.output_format == OutputFormat::Human { + println!("Connecting to relay {}...", relay); + } + let (mut ws_stream, _) = connect_async(url).await + .map_err(|e| anyhow!("Failed to connect to relay: {}", e))?; + + if data.args.output_format == OutputFormat::Human { + 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!("\ng1pubv2 (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!({ + "vault": { + "address": data.cfg.address.clone(), + "g1v1_pub_key": match &keypair { + KeyPair::Ed25519(_pair) => { + match cesium::compute_g1v1_public_key(&keypair) { + Ok(pubkey) => Some(pubkey), + Err(_) => None + } + }, + _ => None + }, + "crypto_scheme": match &keypair { + KeyPair::Sr25519(_) => Some("Sr25519"), + KeyPair::Ed25519(_) => Some("Ed25519"), + } + }, + "pubkey_hex": pubkey, + "pubkey_bech32": npub, + "seckey_bech32": nsec, + "profile_content_sent": profile_content_obj, + "calculated_tags": { + "g1pubv2": g1pubv2_for_tag, + "g1pub": g1pub_for_tag, + "twin_keys": { + "ipns": profile_content_obj.twin_keys.as_ref().and_then(|tk| tk.ipns.clone()), + "bitcoin": profile_content_obj.twin_keys.as_ref().and_then(|tk| tk.bitcoin.clone()), + "monero": profile_content_obj.twin_keys.as_ref().and_then(|tk| tk.monero.clone()) + } + }, + "event_sent": event, + "relay": relay, + "success": success, + "response": response_message + })).unwrap()); + } + } + + Ok(()) +} + +/// Remove Nostr profile and associated events from relays +async fn remove_profile(data: Data, relay_urls: Option<String>) -> Result<(), GcliError> { + // Get keypair for signing + let keypair = if data.args.no_password { + // If no_password is set, try to get the keypair without prompting + match fetch_or_get_keypair(&data, data.cfg.address.clone(), Some(CryptoScheme::Ed25519)).await { + Ok(pair) => pair, + Err(e) => { + if data.args.output_format == OutputFormat::Json { + return Err(anyhow!("Failed to get keypair without password: {}", e).into()); + } else { + return Err(anyhow!("Failed to get keypair without password. Try removing --no-password option.").into()); + } + } + } + } else { + fetch_or_get_keypair(&data, data.cfg.address.clone(), Some(CryptoScheme::Ed25519)).await? + }; + + // Get Nostr pubkey in hex format + let pubkey = get_nostr_pubkey(&keypair)?; + + // Get Nostr pubkey in bech32 format (for display) + let npub = get_nostr_npub(&keypair)?; + + // Get list of relays + let relays = relay_urls + .map(|urls| urls.split(',').map(String::from).collect::<Vec<_>>()) + .unwrap_or_else(|| vec![data.cfg + .relay + .clone() + .unwrap_or_else(|| "wss://relay.copylaradio.com".to_string())]); + + if data.args.output_format == OutputFormat::Human { + println!("Removing profile with Nostr pubkey (hex): {}", pubkey); + println!("Nostr pubkey (bech32): {}", npub); + } + + let mut results = Vec::new(); + + for relay in &relays { + if data.args.output_format == OutputFormat::Human { + println!("Sending deletion requests to {}", relay); + } + + // 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.clone() + }; + + // Parse the URL + let url = Url::parse(&relay).map_err(|e| anyhow!("Invalid relay URL: {}", e))?; + + // Connect to the WebSocket + let (mut ws_stream, _) = connect_async(url).await + .map_err(|e| anyhow!("Failed to connect to relay: {}", e))?; + + // Create deletion event for relay list (kind 10002) + let mut deletion_event = NostrEvent::new( + pubkey.clone(), + 5, // NIP-09 deletion event kind + String::new() + ); + deletion_event.tags = vec![ + vec!["e".to_string(), pubkey.clone()], // Delete relay list + vec!["p".to_string(), pubkey.clone()] // Delete all events by this pubkey + ]; + + // Sign the event + deletion_event.sign(&keypair)?; + + // Create event message + let event_msg = json!(["EVENT", deletion_event]); + + // Send the deletion event + ws_stream.send(Message::Text(event_msg.to_string())).await + .map_err(|e| anyhow!("Failed to send deletion event: {}", e))?; + + // Wait for confirmation with timeout + let timeout = tokio::time::Duration::from_secs(5); + let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(100)); + let start_time = tokio::time::Instant::now(); + let mut success = false; + let mut response_message = String::new(); + + while start_time.elapsed() < timeout { + interval.tick().await; + + match tokio::time::timeout( + tokio::time::Duration::from_millis(100), + ws_stream.next() + ).await { + Ok(Some(Ok(msg))) => { + if let Message::Text(text) = msg { + response_message = text.clone(); + if let Ok(json) = serde_json::from_str::<Value>(&text) { + if let Some(msg_type) = json.get(0).and_then(|v| v.as_str()) { + if msg_type == "OK" { + success = true; + if data.args.output_format == OutputFormat::Human { + println!("Deletion request confirmed by relay"); + } + break; + } + } + } + } + }, + Ok(Some(Err(e))) => { + if data.args.output_format == OutputFormat::Human { + println!("Error receiving confirmation: {}", e); + } + break; + }, + Ok(None) => { + if data.args.output_format == OutputFormat::Human { + println!("Connection closed by relay"); + } + break; + }, + Err(_) => continue, + } + } + + results.push(json!({ + "relay": relay, + "success": success, + "response": response_message + })); + + // Close the WebSocket connection + ws_stream.close(None).await.ok(); + } + + if data.args.output_format == OutputFormat::Human { + println!("Deletion requests sent to all specified relays"); + } else { + println!("{}", serde_json::to_string_pretty(&json!({ + "pubkey_hex": pubkey, + "pubkey_bech32": npub, + "results": results + })).unwrap()); + } + + Ok(()) +} + +/// Export keys in different formats +async fn export_keys(data: Data, format: String) -> Result<(), GcliError> { + // Get keypair for deriving other keys + let keypair = if data.args.no_password { + // If no_password is set, try to get the keypair without prompting + match fetch_or_get_keypair(&data, data.cfg.address.clone(), Some(CryptoScheme::Ed25519)).await { + Ok(pair) => pair, + Err(e) => { + if data.args.output_format == OutputFormat::Json { + return Err(anyhow!("Failed to get keypair without password: {}", e).into()); + } else { + return Err(anyhow!("Failed to get keypair without password. Try removing --no-password option.").into()); + } + } + } + } else { + fetch_or_get_keypair(&data, data.cfg.address.clone(), Some(CryptoScheme::Ed25519)).await? + }; + + match format.to_lowercase().as_str() { + "g1v1" => { + // Calculate g1pub (Duniter v1 pubkey) + let g1pub = match cesium::compute_g1v1_public_key(&keypair) { + Ok(pubkey) => pubkey, + Err(e) => { + if !matches!(keypair, KeyPair::Ed25519(_)) { + return Err(anyhow!("Cannot compute g1pub (Duniter v1 pubkey) as the key is not Ed25519.").into()); + } else { + return Err(anyhow!("Failed to compute g1pub (Duniter v1 pubkey): {}", e).into()); + } + } + }; + + // Get raw key bytes and convert to pubsec format + let (secret_pubsec, _) = match &keypair { + KeyPair::Ed25519(pair) => { + // Get the raw bytes + let raw_bytes = pair.to_raw_vec(); + + // In Ed25519, the first 32 bytes are the private key + let private_key = &raw_bytes[..32]; + + // The next 32 bytes are the public key + let public_key = &raw_bytes[32..]; + + // Create the seed from private key + let mut hasher = sha2::Sha512::new(); + hasher.update(private_key); + let hash = hasher.finalize(); + let seed = &hash[..32]; + + // Format according to duniterpy's SigningKey format + let mut signing_key = Vec::new(); + signing_key.extend_from_slice(&[0x01]); // Version + signing_key.extend_from_slice(seed); // Seed + signing_key.extend_from_slice(public_key); // Public key + + (bs58::encode(&signing_key).into_string(), raw_bytes) + }, + _ => return Err(anyhow!("G1v1 format only supports Ed25519 keys").into()), + }; + + match data.args.output_format { + OutputFormat::Human => { + println!("Duniter v1 Keys:"); + println!("Public Key: {}", g1pub); + println!("Private Key: {}", secret_pubsec); + }, + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&json!({ + "format": "g1v1", + "public_key": g1pub, + "private_key": secret_pubsec, + "type": "PubSec", + "version": "1" + })).unwrap()); + } + } + }, + "g1v2" => { + // Calculate g1pubv2 (SS58 address) + let account_id: sp_core::crypto::AccountId32 = match &keypair { + KeyPair::Sr25519(pair) => pair.public().into(), + KeyPair::Ed25519(pair) => pair.public().into(), + }; + let g1pubv2 = account_id.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(42)); + + // Get raw key bytes for secret + let secret = match &keypair { + KeyPair::Sr25519(pair) => hex::encode(pair.to_raw_vec()), + KeyPair::Ed25519(pair) => hex::encode(pair.to_raw_vec()), + }; + + match data.args.output_format { + OutputFormat::Human => { + println!("Duniter v2 (gdev) Keys:"); + println!("SS58 Address: {}", g1pubv2); + println!("Private Key: {}", secret); + }, + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&json!({ + "format": "g1v2", + "ss58_address": g1pubv2, + "private_key": secret + })).unwrap()); + } + } + }, + "ipfs" => { + // Get public key bytes + let pub_bytes = match &keypair { + KeyPair::Sr25519(pair) => pair.public().to_raw(), + KeyPair::Ed25519(pair) => pair.public().to_raw(), + }; + + // Create protobuf format for IPFS key + let mut protobuf = Vec::new(); + protobuf.extend_from_slice(&[0x08, 0x01]); // Ed25519 key type + protobuf.extend_from_slice(&[0x12, 0x20]); // Length prefix + protobuf.extend_from_slice(&pub_bytes); + + // Add multihash prefix for PeerID + let mut peer_id_bytes = Vec::new(); + peer_id_bytes.extend_from_slice(&[0x00, 0x24]); // Multihash identity prefix + peer_id_bytes.extend_from_slice(&protobuf); + + // Create private key protobuf + let mut priv_protobuf = Vec::new(); + priv_protobuf.extend_from_slice(&[0x08, 0x01]); // Ed25519 key type + priv_protobuf.extend_from_slice(&[0x12, 0x40]); // Length prefix + match &keypair { + KeyPair::Sr25519(pair) => priv_protobuf.extend_from_slice(&pair.to_raw_vec()), + KeyPair::Ed25519(pair) => priv_protobuf.extend_from_slice(&pair.to_raw_vec()), + } + + let peer_id = format!("12D3Koo{}", bs58::encode(&peer_id_bytes).into_string()); + let private_key = BASE64.encode(&priv_protobuf); + + match data.args.output_format { + OutputFormat::Human => { + println!("IPFS Keys:"); + println!("PeerID: {}", peer_id); + println!("Private Key (base64): {}", private_key); + }, + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&json!({ + "format": "ipfs", + "peer_id": peer_id, + "private_key": private_key + })).unwrap()); + } + } + }, + "nostr" => { + // Get Nostr keys + let pubkey = get_nostr_pubkey(&keypair)?; + let npub = get_nostr_npub(&keypair)?; + let nsec = get_nostr_nsec(&keypair)?; + + match data.args.output_format { + OutputFormat::Human => { + println!("Nostr Keys:"); + println!("Public Key (hex): {}", pubkey); + println!("Public Key (npub): {}", npub); + println!("Private Key (nsec): {}", nsec); + }, + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&json!({ + "format": "nostr", + "public_key_hex": pubkey, + "public_key_bech32": npub, + "private_key_bech32": nsec + })).unwrap()); + } + } + }, + "bitcoin" => { + // Generate Bitcoin keys using seed from keypair + let seed = match &keypair { + KeyPair::Sr25519(pair) => { + let mut hasher = sha2::Sha256::new(); + hasher.update(&pair.to_raw_vec()); + hasher.finalize() + }, + KeyPair::Ed25519(pair) => { + // For Ed25519, take the first 32 bytes (the seed/private key part in raw format) + // and hash it with SHA256, like keygen.py does with its 'seed'. + let raw_bytes = pair.to_raw_vec(); + let ed25519_seed = &raw_bytes[..32]; + let mut hasher = sha2::Sha256::new(); + hasher.update(ed25519_seed); + hasher.finalize() + } + }; + + let secp = secp256k1::Secp256k1::new(); + let secret_key = secp256k1::SecretKey::from_slice(&seed[0..32]) + .map_err(|e| anyhow!("Failed to create Bitcoin secret key: {}", e))?; + let _public_key = secp256k1::PublicKey::from_secret_key(&secp, &secret_key); + + // Derive the corresponding public key + let public_key_secp = secp256k1::PublicKey::from_secret_key(&secp, &secret_key); + + // Use the 'bitcoin' and 'bitcoin-addr' crates for proper formatting + use bitcoin::{PrivateKey, Network, Address}; + // use bitcoin_addr::AddressType; // Removed + + // Create a PrivateKey object (for mainnet or testnet, let's use Testnet for example) + // You might want to make the network configurable + let private_key_btc = PrivateKey::new(secret_key, Network::Bitcoin); + + // Get the WIF format private key + let private_key_wif = private_key_btc.to_wif(); + + // Get the public key hex (compressed format is standard) + let public_key_hex = hex::encode(public_key_secp.serialize()); + + // Derive the P2PKH address (most common) + // Note: This assumes P2PKH. Other address types (P2SH, Bech32) are possible. + // keygen.py seems to derive a simple address from compressed pubkey. + // Convert secp256k1::PublicKey to bitcoin::PublicKey (using compressed format) + let public_key_btc = bitcoin::PublicKey::from_slice(&public_key_secp.serialize()).map_err(|e| anyhow!("Failed to create bitcoin::PublicKey: {}", e))?; + + // Derive the P2PKH address using the bitcoin::PublicKey + let address = Address::p2pkh(&public_key_btc, Network::Bitcoin).to_string(); + + match data.args.output_format { + OutputFormat::Human => { + println!("Bitcoin Keys:"); + println!("Private Key (WIF): {}", private_key_wif); + println!("Public Key (hex): {}", public_key_hex); + println!("Address: {}", address); + }, + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&json!({ + "format": "bitcoin", + "private_key_wif": private_key_wif, + "public_key_hex": public_key_hex, + "address_p2pkh": address + })).unwrap()); + } + } + }, + "monero" => { + // Generate Monero keys using seed from keypair + let seed = match &keypair { + KeyPair::Sr25519(pair) => { + let mut hasher = sha2::Sha256::new(); + hasher.update(&pair.to_raw_vec()); + hasher.finalize() + }, + KeyPair::Ed25519(pair) => { + let mut hasher = sha2::Sha256::new(); + hasher.update(&pair.to_raw_vec()); + hasher.finalize() + } + }; + + let private_spend_key = hex::encode(&seed[0..32]); + + match data.args.output_format { + OutputFormat::Human => { + println!("Monero Keys:"); + println!("Private Spend Key: {}", private_spend_key); + }, + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&json!({ + "format": "monero", + "private_spend_key": private_spend_key + })).unwrap()); + } + } + }, + _ => { + let available_formats = vec!["ipfs", "nostr", "bitcoin", "monero", "g1v1", "g1v2"]; + match data.args.output_format { + OutputFormat::Human => { + println!("Available export formats:"); + println!(" ipfs - IPFS PeerID and private key"); + println!(" nostr - Nostr npub/nsec keys"); + println!(" bitcoin - Bitcoin WIF and address"); + println!(" monero - Monero keys"); + println!(" g1v1 - Duniter v1 public/private keys"); + println!(" g1v2 - Duniter v2 (gdev) SS58/private keys"); + }, + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&json!({ + "available_formats": available_formats + })).unwrap()); + } + } + return Ok(()); + } + } + + Ok(()) +} \ No newline at end of file diff --git a/src/commands/vault.rs b/src/commands/vault.rs index b46606e1d2171dbe2d16b5ee98d0658b57ae38e1..57b352e0b1d311ad97bf4860770bb84375fe8e2c 100644 --- a/src/commands/vault.rs +++ b/src/commands/vault.rs @@ -15,6 +15,8 @@ use std::cell::RefCell; use std::io::{Read, Write}; use std::path::PathBuf; use std::rc::Rc; +use serde::Serialize; +use std::fmt; /// vault subcommands #[derive(Clone, Debug, clap::Parser)] @@ -221,25 +223,73 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE let all_account_tree_node_hierarchies = vault_account::fetch_all_base_account_tree_node_hierarchies(db).await?; - let table = display::compute_vault_accounts_table_with_g1v1( - &all_account_tree_node_hierarchies, - show_g1v1, - )?; - - println!("available SS58 Addresses:"); - println!("{table}"); + match data.args.output_format { + OutputFormat::Json => { + let accounts: Vec<_> = all_account_tree_node_hierarchies + .iter() + .map(|node| { + let node = node.borrow(); + VaultAccountJson { + address: node.account.address.to_string(), + crypto_scheme: node.account.crypto_scheme.map(|s| s.to_string()), + path: node.account.path.clone(), + name: node.account.name.clone(), + g1v1_public_key: if show_g1v1 && node.account.crypto_scheme == Some(CryptoScheme::Ed25519.into()) { + Some(cesium::compute_g1v1_public_key_from_ed25519_account_id(&node.account.address.0)) + } else { + None + } + } + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&accounts)?); + } + OutputFormat::Human => { + let table = display::compute_vault_accounts_table_with_g1v1( + &all_account_tree_node_hierarchies, + show_g1v1, + )?; + + println!("available SS58 Addresses:"); + println!("{table}"); + } + } } ListChoice::Base { show_g1v1 } => { let base_account_tree_nodes = vault_account::fetch_only_base_account_tree_nodes(db).await?; - let table = display::compute_vault_accounts_table_with_g1v1( - &base_account_tree_nodes, - show_g1v1, - )?; - - println!("available <Base> SS58 Addresses:"); - println!("{table}"); + match data.args.output_format { + OutputFormat::Json => { + let accounts: Vec<_> = base_account_tree_nodes + .iter() + .map(|node| { + let node = node.borrow(); + VaultAccountJson { + address: node.account.address.to_string(), + crypto_scheme: node.account.crypto_scheme.map(|s| s.to_string()), + path: node.account.path.clone(), + name: node.account.name.clone(), + g1v1_public_key: if show_g1v1 && node.account.crypto_scheme == Some(CryptoScheme::Ed25519.into()) { + Some(cesium::compute_g1v1_public_key_from_ed25519_account_id(&node.account.address.0)) + } else { + None + } + } + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&accounts)?); + } + OutputFormat::Human => { + let table = display::compute_vault_accounts_table_with_g1v1( + &base_account_tree_nodes, + show_g1v1, + )?; + + println!("available <Base> SS58 Addresses:"); + println!("{table}"); + } + } } ListChoice::For { address_or_vault_name, @@ -251,16 +301,40 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE let base_account_tree_node = vault_account::get_base_account_tree_node(&account_tree_node); - let table = display::compute_vault_accounts_table_with_g1v1( - &[base_account_tree_node], - show_g1v1, - )?; - - println!( - "available SS58 Addresses linked to {}:", - account_tree_node.borrow().account - ); - println!("{table}"); + match data.args.output_format { + OutputFormat::Json => { + let accounts: Vec<_> = vec![base_account_tree_node] + .iter() + .map(|node| { + let node = node.borrow(); + VaultAccountJson { + address: node.account.address.to_string(), + crypto_scheme: node.account.crypto_scheme.map(|s| s.to_string()), + path: node.account.path.clone(), + name: node.account.name.clone(), + g1v1_public_key: if show_g1v1 && node.account.crypto_scheme == Some(CryptoScheme::Ed25519.into()) { + Some(cesium::compute_g1v1_public_key_from_ed25519_account_id(&node.account.address.0)) + } else { + None + } + } + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&accounts)?); + } + OutputFormat::Human => { + let table = display::compute_vault_accounts_table_with_g1v1( + &[base_account_tree_node], + show_g1v1, + )?; + + println!( + "available SS58 Addresses linked to {}:", + account_tree_node.borrow().account + ); + println!("{table}"); + } + } } }, Subcommand::ListFiles => { @@ -376,6 +450,7 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE provided_password.as_ref(), Some(crypto_scheme), name, + &data, ) .await?; @@ -665,6 +740,7 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE Some(&vault_data_from_file.password), Some(CryptoScheme::Sr25519), None, + &data, ) .await; @@ -947,6 +1023,7 @@ pub async fn create_base_account_for_vault_data_to_import<C>( password_opt: Option<&String>, crypto_scheme: Option<CryptoScheme>, name_opt: Option<String>, + data: &Data, ) -> Result<vault_account::Model, GcliError> where C: ConnectionTrait, @@ -959,50 +1036,57 @@ where vault_account::find_by_id(db_tx, &DbAccountId::from(address_to_import.clone())).await? { // Existing account - if existing_vault_account.is_base_account() { - println!("You are trying to add {address_to_import} as a <Base> account while it already exists as a <Base> account."); - println!(); - println!("Do you want to:"); - println!("1. keep the existing <Base> account and cancel import"); - println!("2. overwrite existing <Base> account with the new encrypted key (children will be re-parented)"); + let result = if data.args.no_password { + println!("Account already exists. Overwriting due to --no-password flag."); + "2".to_string() } else { - // Existing derivation account - let account_tree_node_hierarchy = - vault_account::fetch_base_account_tree_node_hierarchy_unwrapped( - db_tx, + if existing_vault_account.is_base_account() { + println!("You are trying to add {} as a <Base> account while it already exists as a <Base> account.", address_to_import); + println!(); + println!("Do you want to:"); + println!("1. keep the existing <Base> account and cancel import"); + println!("2. overwrite existing <Base> account with the new encrypted key (children will be re-parented)"); + } else { + // Existing derivation account + let account_tree_node_hierarchy = + vault_account::fetch_base_account_tree_node_hierarchy_unwrapped( + db_tx, + &address_to_import, + ) + .await?; + let account_tree_node_for_address = vault_account::get_account_tree_node_for_address( + &account_tree_node_hierarchy, &address_to_import, - ) - .await?; - let account_tree_node_for_address = vault_account::get_account_tree_node_for_address( - &account_tree_node_hierarchy, - &address_to_import, - ); - - let base_parent_hierarchy_account_tree_node = - vault_account::get_base_parent_hierarchy_account_tree_node( - &account_tree_node_for_address, ); - let parent_hierarchy_table = - display::compute_vault_accounts_table(&[base_parent_hierarchy_account_tree_node])?; + let base_parent_hierarchy_account_tree_node = + vault_account::get_base_parent_hierarchy_account_tree_node( + &account_tree_node_for_address, + ); - println!("You are trying to add {address_to_import} as a <Base> account"); - println!( - "but it is already present as `{}` derivation of {} account.", - existing_vault_account.path.clone().unwrap(), - existing_vault_account.parent.clone().unwrap() - ); - println!(); - println!("Its parent hierarchy is this:"); - println!("{parent_hierarchy_table}"); - println!(); - println!("Do you want to:"); - println!("1. keep the existing derivation and cancel import"); - println!("2. delete the derivation account and replace it with the new <Base> account (children will be re-parented)"); - } + let parent_hierarchy_table = + display::compute_vault_accounts_table(&[base_parent_hierarchy_account_tree_node])?; - let result = inputs::select_action("Your choice?", vec!["1", "2"])?; - match result { + println!("You are trying to add {} as a <Base> account", address_to_import); + println!( + "but it is already present as `{}` derivation of '{}' account.", + existing_vault_account.path.clone().unwrap(), + existing_vault_account.parent.clone().unwrap() + ); + println!(); + println!("Its parent hierarchy is this:"); + println!("{parent_hierarchy_table}"); + println!(); + println!("Do you want to:"); + println!("1. keep the existing derivation and cancel import"); + println!("2. delete the derivation account and replace it with the new <Base> account (children will be re-parented)"); + } + + let result = inputs::select_action("Your choice?", vec!["1", "2"])?; + result.to_string() + }; + + match result.as_str() { "2" => { let password = match password_opt { Some(password) => password.clone(), @@ -1259,7 +1343,12 @@ pub async fn try_fetch_key_pair( println!("(Vault: {})", account_tree_node.borrow().account); - let password = inputs::prompt_password()?; + let password = if data.args.no_password { + "".to_string() + } else { + inputs::prompt_password()? + }; + let secret_suri = vault_account::compute_suri_account_tree_node(&account_tree_node, password)?; @@ -1336,6 +1425,21 @@ pub fn try_fetch_vault_data_from_file( } } +#[derive(Serialize)] +struct VaultAccountJson { + address: String, + crypto_scheme: Option<String>, + path: Option<String>, + name: Option<String>, + g1v1_public_key: Option<String>, +} + +impl From<serde_json::Error> for GcliError { + fn from(err: serde_json::Error) -> Self { + GcliError::Input(err.to_string()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/conf.rs b/src/conf.rs index 509e0c5bb9dfe36fb572a65eb6f0fff823b28546..8809c3983485ade55bf15e71c564fd2d70a55644 100644 --- a/src/conf.rs +++ b/src/conf.rs @@ -15,6 +15,8 @@ pub struct Config { /// user address /// to perform actions, user must provide secret pub address: Option<AccountId>, + /// nostr relay endpoint + pub relay: Option<String>, } impl std::default::Default for Config { @@ -23,6 +25,7 @@ impl std::default::Default for Config { duniter_endpoint: String::from(data::LOCAL_DUNITER_ENDPOINT), indexer_endpoint: String::from(data::LOCAL_INDEXER_ENDPOINT), address: None, + relay: Some(String::from("wss://relay.copylaradio.com")), } } } @@ -34,10 +37,16 @@ impl std::fmt::Display for Config { } else { "(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, "duniter endpoint {}", self.duniter_endpoint)?; writeln!(f, "indexer endpoint {}", self.indexer_endpoint)?; - write!(f, "address {address}") + writeln!(f, "address {address}")?; + write!(f, "nostr relay {relay}") } } diff --git a/src/entities/vault_account.rs b/src/entities/vault_account.rs index 60d663d9cd13c408168ffe93eccb709662ed5b30..8151185756e457c9255908349c13f4bc5b0ae828 100644 --- a/src/entities/vault_account.rs +++ b/src/entities/vault_account.rs @@ -22,6 +22,7 @@ use std::future::Future; use std::pin::Pin; use std::rc::Rc; use std::str::FromStr; +use std::fmt; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "vault_account")] @@ -224,8 +225,19 @@ impl From<DbAccountId> for AccountId { rename_all = "PascalCase" )] pub enum DbCryptoScheme { - Ed25519, + #[sea_orm(string_value = "sr25519")] Sr25519, + #[sea_orm(string_value = "Ed25519")] + Ed25519, +} + +impl fmt::Display for DbCryptoScheme { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DbCryptoScheme::Sr25519 => write!(f, "sr25519"), + DbCryptoScheme::Ed25519 => write!(f, "Ed25519"), + } + } } impl From<crate::keys::CryptoScheme> for DbCryptoScheme { diff --git a/src/indexer.rs b/src/indexer.rs index aa76c1f418f5a95afa642ae9a7d309c2850dba18..93373a745125d9a7bd8a51e71cc945247965bf55 100644 --- a/src/indexer.rs +++ b/src/indexer.rs @@ -191,59 +191,97 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE let i_latest_h = convert_hash_bytea(i_latest_block.hash); let i_latest_n = i_latest_block.height; - fn color(x: bool) -> Color { - match x { - true => Color::Green, - false => Color::Red, + match data.args.output_format { + OutputFormat::Json => { + let output = serde_json::json!({ + "duniter": { + "url": d_url, + "genesis_hash": d_gen_hash, + "finalized_block": { + "number": d_finalized_n, + "hash": d_finalized_h.to_string() + }, + "latest_block": { + "number": d_latest_n, + "hash": d_latest_h.to_string() + } + }, + "indexer": { + "url": i_url, + "genesis_hash": i_gen_hash, + "finalized_block": { + "number": i_finalized_n, + "hash": i_finalized_h.map(|h| h.to_string()) + }, + "latest_block": { + "number": i_latest_n, + "hash": i_latest_h.to_string() + } + }, + "status": { + "genesis_hash_match": d_gen_hash == i_gen_hash, + "finalized_block_match": i_finalized_h.map_or(false, |h| h == d_finalized_h), + "latest_block_match": i_latest_n == d_latest_n as i64 && i_latest_h == d_latest_h + } + }); + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + }, + OutputFormat::Human => { + fn color(x: bool) -> Color { + match x { + true => Color::Green, + false => Color::Red, + } + } + + let mut table = Table::new(); + table + .load_preset(presets::UTF8_FULL) + .apply_modifier(modifiers::UTF8_ROUND_CORNERS) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_width(120) + .set_header(vec!["Variable", "Duniter", "Indexer"]) + .add_row(vec!["URL", d_url, i_url]) + .add_row(vec![ + Cell::new("genesis hash"), + Cell::new(d_gen_hash), + Cell::new(i_gen_hash).fg(color(d_gen_hash == i_gen_hash)), + ]) + .add_row(vec![ + Cell::new("finalized block number"), + Cell::new(d_finalized_n), + match i_finalized_n { + None => Cell::new("not indexed").fg(Color::Yellow), + Some(n) => { + // if it exists, it must be the same + assert_eq!(n, d_finalized_n as i64); + Cell::new("") + } + }, + ]) + .add_row(vec![ + Cell::new("finalized block hash"), + Cell::new(d_finalized_h), + match i_finalized_h { + // number already tells it is not indexed + None => Cell::new(""), + Some(h) => Cell::new(h).fg(color(h == d_finalized_h)), + }, + ]) + .add_row(vec![ + Cell::new("latest block number"), + Cell::new(d_latest_n), + Cell::new(i_latest_n).fg(color(i_latest_n == d_latest_n as i64)), + ]) + .add_row(vec![ + Cell::new("latest block hash"), + Cell::new(d_latest_h), + Cell::new(i_latest_h).fg(color(i_latest_h == d_latest_h)), + ]); + + println!("{table}"); } } - - let mut table = Table::new(); - table - .load_preset(presets::UTF8_FULL) - .apply_modifier(modifiers::UTF8_ROUND_CORNERS) - .set_content_arrangement(ContentArrangement::Dynamic) - .set_width(120) - .set_header(vec!["Variable", "Duniter", "Indexer"]) - .add_row(vec!["URL", d_url, i_url]) - .add_row(vec![ - Cell::new("genesis hash"), - Cell::new(d_gen_hash), - Cell::new(i_gen_hash).fg(color(d_gen_hash == i_gen_hash)), - ]) - .add_row(vec![ - Cell::new("finalized block number"), - Cell::new(d_finalized_n), - match i_finalized_n { - None => Cell::new("not indexed").fg(Color::Yellow), - Some(n) => { - // if it exists, it must be the same - assert_eq!(n, d_finalized_n as i64); - Cell::new("") - } - }, - ]) - .add_row(vec![ - Cell::new("finalized block hash"), - Cell::new(d_finalized_h), - match i_finalized_h { - // number already tells it is not indexed - None => Cell::new(""), - Some(h) => Cell::new(h).fg(color(h == d_finalized_h)), - }, - ]) - .add_row(vec![ - Cell::new("latest block number"), - Cell::new(d_latest_n), - Cell::new(i_latest_n).fg(color(i_latest_n == d_latest_n as i64)), - ]) - .add_row(vec![ - Cell::new("latest block hash"), - Cell::new(d_latest_h), - Cell::new(i_latest_h).fg(color(i_latest_h == d_latest_h)), - ]); - - println!("{table}"); } }; diff --git a/src/main.rs b/src/main.rs index 8e417d6002ad0fefa59da870cc48aa307b4f7513..2f8405885f944d70b95a800850ca28e89d9401d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -77,6 +77,9 @@ pub struct Args { /// Output format (human, json, ...) #[clap(short = 'o', long, default_value = OutputFormat::Human)] output_format: OutputFormat, + /// Do not prompt for password + #[clap(long)] + no_password: bool, } // TODO derive the fromstr implementation @@ -155,6 +158,9 @@ pub enum Subcommand { /// Key management (import, generate, list...) #[clap(subcommand)] Vault(commands::vault::Subcommand), + /// Nostr profile management (get, set...) + #[clap(subcommand)] + Profile(commands::profile::Subcommand), /// Cesium #[clap(subcommand, hide = true)] Cesium(commands::cesium::Subcommand), @@ -233,6 +239,7 @@ async fn main() -> Result<(), GcliError> { Subcommand::Indexer(subcommand) => indexer::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::Profile(subcommand) => commands::profile::handle_command(data, subcommand).await, Subcommand::Cesium(subcommand) => commands::cesium::handle_command(data, subcommand).await, Subcommand::Publish => commands::publish::handle_command().await, }; diff --git a/src/utils.rs b/src/utils.rs index 68ec0118e858a2cbae818b132772aa81cc2bacea..bcff9842f5447165388e78f3f7661e6432febdcb 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -116,6 +116,8 @@ pub enum GcliError { /// error coming from io #[allow(dead_code)] IoError(std::io::Error), + /// error coming from config + Config(String), } impl std::fmt::Display for GcliError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {