Skip to main content

git_remote_htree/nostr_client/
identity.rs

1use anyhow::{Context, Result};
2use nostr_sdk::prelude::{Keys, PublicKey, SecretKey, ToBech32};
3use tracing::{debug, info};
4
5/// A stored key with optional petname
6#[derive(Debug, Clone)]
7pub struct StoredKey {
8    /// Secret key in hex format, when this identity can sign
9    pub secret_hex: Option<String>,
10    /// Public key in hex format
11    pub pubkey_hex: String,
12    /// Optional petname (e.g., "default", "work")
13    pub petname: Option<String>,
14}
15
16impl StoredKey {
17    /// Create from secret key hex, deriving pubkey
18    pub fn from_secret_hex(secret_hex: &str, petname: Option<String>) -> Result<Self> {
19        use secp256k1::{Secp256k1, SecretKey as SecpSecretKey};
20
21        let sk_bytes = hex::decode(secret_hex).context("Invalid hex in secret key")?;
22        let sk = SecpSecretKey::from_slice(&sk_bytes).context("Invalid secret key")?;
23        let secp = Secp256k1::new();
24        let pk = sk.x_only_public_key(&secp).0;
25        let pubkey_hex = hex::encode(pk.serialize());
26
27        Ok(Self {
28            secret_hex: Some(secret_hex.to_string()),
29            pubkey_hex,
30            petname,
31        })
32    }
33
34    /// Create from nsec bech32 format
35    pub fn from_nsec(nsec: &str, petname: Option<String>) -> Result<Self> {
36        let secret_key =
37            SecretKey::parse(nsec).map_err(|e| anyhow::anyhow!("Invalid nsec format: {}", e))?;
38        let secret_hex = hex::encode(secret_key.to_secret_bytes());
39        Self::from_secret_hex(&secret_hex, petname)
40    }
41
42    /// Create from pubkey hex without a signing key
43    pub fn from_pubkey_hex(pubkey_hex: &str, petname: Option<String>) -> Result<Self> {
44        let pubkey = PublicKey::from_hex(pubkey_hex)
45            .map_err(|e| anyhow::anyhow!("Invalid pubkey hex: {}", e))?;
46
47        Ok(Self {
48            secret_hex: None,
49            pubkey_hex: hex::encode(pubkey.to_bytes()),
50            petname,
51        })
52    }
53
54    /// Create from npub bech32 format without a signing key
55    pub fn from_npub(npub: &str, petname: Option<String>) -> Result<Self> {
56        let pubkey =
57            PublicKey::parse(npub).map_err(|e| anyhow::anyhow!("Invalid npub format: {}", e))?;
58
59        Ok(Self {
60            secret_hex: None,
61            pubkey_hex: hex::encode(pubkey.to_bytes()),
62            petname,
63        })
64    }
65}
66
67#[derive(Clone, Copy)]
68enum IdentityFileKind {
69    Keys,
70    Aliases,
71}
72
73fn ensure_aliases_file_hint() {
74    let aliases_path = hashtree_config::get_aliases_path();
75    if aliases_path.exists() {
76        return;
77    }
78
79    let Some(parent) = aliases_path.parent() else {
80        return;
81    };
82
83    if !parent.exists() {
84        return;
85    }
86
87    let template = concat!(
88        "# Public read-only aliases for repos you clone or fetch.\n",
89        "# Format: npub1... alias\n",
90        "# Example:\n",
91        "# npub1xdhnr9mrv47kkrn95k6cwecearydeh8e895990n3acntwvmgk2dsdeeycm sirius\n",
92    );
93
94    let _ = std::fs::OpenOptions::new()
95        .write(true)
96        .create_new(true)
97        .open(&aliases_path)
98        .and_then(|mut file| std::io::Write::write_all(&mut file, template.as_bytes()));
99}
100
101fn parse_identity_entry(
102    raw: &str,
103    petname: Option<String>,
104    kind: IdentityFileKind,
105) -> Option<StoredKey> {
106    let key = match kind {
107        IdentityFileKind::Keys => {
108            if raw.starts_with("nsec1") {
109                StoredKey::from_nsec(raw, petname)
110            } else if raw.starts_with("npub1") {
111                StoredKey::from_npub(raw, petname)
112            } else if raw.len() == 64 {
113                StoredKey::from_secret_hex(raw, petname)
114            } else {
115                return None;
116            }
117        }
118        IdentityFileKind::Aliases => {
119            if raw.starts_with("npub1") {
120                StoredKey::from_npub(raw, petname)
121            } else if raw.len() == 64 {
122                StoredKey::from_pubkey_hex(raw, petname)
123            } else {
124                return None;
125            }
126        }
127    };
128
129    key.ok()
130}
131
132fn load_identities_from_path(path: &std::path::Path, kind: IdentityFileKind) -> Vec<StoredKey> {
133    let mut keys = Vec::new();
134
135    if let Ok(content) = std::fs::read_to_string(path) {
136        for entry in hashtree_config::parse_keys_file(&content) {
137            if let Some(key) = parse_identity_entry(&entry.secret, entry.alias, kind) {
138                debug!(
139                    "Loaded identity: pubkey={}, petname={:?}, has_secret={}",
140                    key.pubkey_hex,
141                    key.petname,
142                    key.secret_hex.is_some()
143                );
144                keys.push(key);
145            }
146        }
147    }
148
149    keys
150}
151
152pub(super) fn resolve_self_identity(keys: &[StoredKey]) -> Option<(String, Option<String>)> {
153    keys.iter()
154        .find(|k| k.petname.as_deref() == Some("self") && k.secret_hex.is_some())
155        .or_else(|| {
156            keys.iter()
157                .find(|k| k.petname.as_deref() == Some("default") && k.secret_hex.is_some())
158        })
159        .or_else(|| keys.iter().find(|k| k.secret_hex.is_some()))
160        .map(|key| (key.pubkey_hex.clone(), key.secret_hex.clone()))
161}
162
163/// Load all keys from config files
164pub fn load_keys() -> Vec<StoredKey> {
165    ensure_aliases_file_hint();
166
167    let mut keys =
168        load_identities_from_path(&hashtree_config::get_keys_path(), IdentityFileKind::Keys);
169    keys.extend(load_identities_from_path(
170        &hashtree_config::get_aliases_path(),
171        IdentityFileKind::Aliases,
172    ));
173
174    keys
175}
176
177/// Resolve an identifier to (pubkey_hex, secret_hex)
178/// Identifier can be:
179/// - "self" (uses default key, auto-generates if needed)
180/// - petname (e.g., "work", "default")
181/// - pubkey hex (64 chars)
182/// - npub bech32
183pub fn resolve_identity(identifier: &str) -> Result<(String, Option<String>)> {
184    let keys = load_keys();
185
186    if identifier == "self" {
187        if let Some(resolved) = resolve_self_identity(&keys) {
188            return Ok(resolved);
189        }
190        let new_key = generate_and_save_key("self")?;
191        info!("Generated new identity: npub1{}", &new_key.pubkey_hex[..12]);
192        return Ok((new_key.pubkey_hex, new_key.secret_hex));
193    }
194
195    for key in &keys {
196        if key.petname.as_deref() == Some(identifier) {
197            return Ok((key.pubkey_hex.clone(), key.secret_hex.clone()));
198        }
199    }
200
201    if identifier.starts_with("npub1") {
202        let pk = PublicKey::parse(identifier)
203            .map_err(|e| anyhow::anyhow!("Invalid npub format: {}", e))?;
204        let pubkey_hex = hex::encode(pk.to_bytes());
205
206        let secret = keys
207            .iter()
208            .find(|k| k.pubkey_hex == pubkey_hex)
209            .and_then(|k| k.secret_hex.clone());
210
211        return Ok((pubkey_hex, secret));
212    }
213
214    if identifier.len() == 64 && hex::decode(identifier).is_ok() {
215        let secret = keys
216            .iter()
217            .find(|k| k.pubkey_hex == identifier)
218            .and_then(|k| k.secret_hex.clone());
219
220        return Ok((identifier.to_string(), secret));
221    }
222
223    anyhow::bail!(
224        "Unknown identity '{}'. Add it to ~/.hashtree/aliases (preferred) or ~/.hashtree/keys, or use a pubkey/npub.",
225        identifier
226    )
227}
228
229/// Generate a new key and save it to ~/.hashtree/keys with the given petname
230fn generate_and_save_key(petname: &str) -> Result<StoredKey> {
231    use std::fs::{self, OpenOptions};
232    use std::io::Write;
233
234    let keys = Keys::generate();
235    let secret_hex = hex::encode(keys.secret_key().to_secret_bytes());
236    let pubkey_hex = hex::encode(keys.public_key().to_bytes());
237
238    let keys_path = hashtree_config::get_keys_path();
239    if let Some(parent) = keys_path.parent() {
240        fs::create_dir_all(parent)?;
241    }
242    ensure_aliases_file_hint();
243
244    let mut file = OpenOptions::new()
245        .create(true)
246        .append(true)
247        .open(&keys_path)?;
248
249    let nsec = keys
250        .secret_key()
251        .to_bech32()
252        .map_err(|e| anyhow::anyhow!("Failed to encode nsec: {}", e))?;
253    writeln!(file, "{} {}", nsec, petname)?;
254
255    info!(
256        "Saved new key to {:?} with petname '{}'",
257        keys_path, petname
258    );
259
260    Ok(StoredKey {
261        secret_hex: Some(secret_hex),
262        pubkey_hex,
263        petname: Some(petname.to_string()),
264    })
265}