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