Skip to main content

am_core/
identity.rs

1use std::fs;
2use std::os::unix::fs::PermissionsExt;
3use std::path::PathBuf;
4
5use nostr_sdk::prelude::*;
6use serde::Serialize;
7
8use crate::config::identity_dir;
9use crate::error::{AmError, AmResult};
10
11#[derive(Debug, Serialize)]
12pub struct IdentityInfo {
13    pub name: String,
14    pub npub: String,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub nsec: Option<String>,
17    pub encrypted: bool,
18}
19
20fn identity_path(name: &str) -> AmResult<PathBuf> {
21    Ok(identity_dir()?.join(format!("{name}.nsec")))
22}
23
24pub fn generate(name: Option<&str>, passphrase: Option<&str>) -> AmResult<IdentityInfo> {
25    let keys = Keys::generate();
26    let name = name.unwrap_or("default").to_string();
27    store_keys(&name, &keys, passphrase)?;
28    Ok(identity_info(&name, &keys, false, passphrase.is_some()))
29}
30
31pub fn import(nsec: &str, name: Option<&str>, passphrase: Option<&str>) -> AmResult<IdentityInfo> {
32    let secret_key = SecretKey::from_bech32(nsec).map_err(|e| AmError::Crypto(e.to_string()))?;
33    let keys = Keys::new(secret_key);
34    let name = name.unwrap_or("default").to_string();
35    store_keys(&name, &keys, passphrase)?;
36    Ok(identity_info(&name, &keys, false, passphrase.is_some()))
37}
38
39pub fn show(
40    name: Option<&str>,
41    show_secret: bool,
42    passphrase: Option<&str>,
43) -> AmResult<IdentityInfo> {
44    let name = name.unwrap_or("default");
45    let encrypted = is_encrypted(name)?;
46    let keys = load_keys(name, passphrase)?;
47    Ok(identity_info(name, &keys, show_secret, encrypted))
48}
49
50pub fn list() -> AmResult<Vec<IdentityInfo>> {
51    let dir = identity_dir()?;
52    if !dir.exists() {
53        return Ok(vec![]);
54    }
55    let mut identities = Vec::new();
56    for entry in fs::read_dir(dir)? {
57        let entry = entry?;
58        let path = entry.path();
59        if path.extension().and_then(|e| e.to_str()) == Some("nsec") {
60            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
61                let content = fs::read_to_string(&path)?.trim().to_string();
62                let encrypted = content.starts_with("ncryptsec1");
63                if encrypted {
64                    // Can't load encrypted keys without passphrase; show limited info
65                    identities.push(IdentityInfo {
66                        name: stem.to_string(),
67                        npub: "(encrypted)".to_string(),
68                        nsec: None,
69                        encrypted: true,
70                    });
71                } else {
72                    let keys = load_keys(stem, None)?;
73                    identities.push(identity_info(stem, &keys, false, false));
74                }
75            }
76        }
77    }
78    identities.sort_by(|a, b| a.name.cmp(&b.name));
79    Ok(identities)
80}
81
82pub fn encrypt_existing(name: &str, passphrase: &str) -> AmResult<IdentityInfo> {
83    let path = identity_path(name)?;
84    if !path.exists() {
85        return Err(AmError::Config(format!("identity '{name}' not found")));
86    }
87    let content = fs::read_to_string(&path)?.trim().to_string();
88    if content.starts_with("ncryptsec1") {
89        return Err(AmError::Config(format!(
90            "identity '{name}' is already encrypted"
91        )));
92    }
93    let secret_key =
94        SecretKey::from_bech32(&content).map_err(|e| AmError::Crypto(e.to_string()))?;
95    let keys = Keys::new(secret_key.clone());
96    let encrypted = EncryptedSecretKey::new(&secret_key, passphrase, 16, KeySecurity::Medium)
97        .map_err(|e| AmError::Crypto(e.to_string()))?;
98    let ncryptsec = encrypted
99        .to_bech32()
100        .map_err(|e| AmError::Crypto(e.to_string()))?;
101    fs::write(&path, ncryptsec)?;
102    fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
103    Ok(identity_info(name, &keys, false, true))
104}
105
106pub fn decrypt_existing(name: &str, passphrase: &str) -> AmResult<IdentityInfo> {
107    let path = identity_path(name)?;
108    if !path.exists() {
109        return Err(AmError::Config(format!("identity '{name}' not found")));
110    }
111    let content = fs::read_to_string(&path)?.trim().to_string();
112    if !content.starts_with("ncryptsec1") {
113        return Err(AmError::Config(format!(
114            "identity '{name}' is not encrypted"
115        )));
116    }
117    let encrypted =
118        EncryptedSecretKey::from_bech32(&content).map_err(|e| AmError::Crypto(e.to_string()))?;
119    let secret_key = encrypted
120        .decrypt(passphrase)
121        .map_err(|e| AmError::Crypto(e.to_string()))?;
122    let keys = Keys::new(secret_key.clone());
123    let nsec = secret_key
124        .to_bech32()
125        .map_err(|e| AmError::Crypto(e.to_string()))?;
126    fs::write(&path, nsec)?;
127    fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
128    Ok(identity_info(name, &keys, false, false))
129}
130
131pub fn load_keys(name: &str, passphrase: Option<&str>) -> AmResult<Keys> {
132    let path = identity_path(name)?;
133    if !path.exists() {
134        return Err(AmError::Config(format!("identity '{name}' not found")));
135    }
136    let content = fs::read_to_string(&path)?.trim().to_string();
137
138    if content.starts_with("ncryptsec1") {
139        let passphrase = passphrase.ok_or_else(|| {
140            AmError::Crypto(format!(
141                "identity '{name}' is encrypted; provide --passphrase or set AM_PASSPHRASE"
142            ))
143        })?;
144        let encrypted = EncryptedSecretKey::from_bech32(&content)
145            .map_err(|e| AmError::Crypto(e.to_string()))?;
146        let secret_key = encrypted
147            .decrypt(passphrase)
148            .map_err(|e| AmError::Crypto(e.to_string()))?;
149        Ok(Keys::new(secret_key))
150    } else {
151        let secret_key =
152            SecretKey::from_bech32(&content).map_err(|e| AmError::Crypto(e.to_string()))?;
153        Ok(Keys::new(secret_key))
154    }
155}
156
157fn is_encrypted(name: &str) -> AmResult<bool> {
158    let path = identity_path(name)?;
159    if !path.exists() {
160        return Err(AmError::Config(format!("identity '{name}' not found")));
161    }
162    let content = fs::read_to_string(&path)?.trim().to_string();
163    Ok(content.starts_with("ncryptsec1"))
164}
165
166fn store_keys(name: &str, keys: &Keys, passphrase: Option<&str>) -> AmResult<()> {
167    crate::config::ensure_dirs()?;
168    let path = identity_path(name)?;
169    if path.exists() {
170        return Err(AmError::Config(format!("identity '{name}' already exists")));
171    }
172
173    let content = if let Some(pass) = passphrase {
174        let secret_key = keys.secret_key();
175        let encrypted = EncryptedSecretKey::new(secret_key, pass, 16, KeySecurity::Medium)
176            .map_err(|e| AmError::Crypto(e.to_string()))?;
177        encrypted
178            .to_bech32()
179            .map_err(|e| AmError::Crypto(e.to_string()))?
180    } else {
181        keys.secret_key()
182            .to_bech32()
183            .map_err(|e| AmError::Crypto(e.to_string()))?
184    };
185
186    fs::write(&path, content)?;
187    fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
188    Ok(())
189}
190
191fn identity_info(name: &str, keys: &Keys, show_secret: bool, encrypted: bool) -> IdentityInfo {
192    let nsec = if show_secret {
193        keys.secret_key().to_bech32().ok()
194    } else {
195        None
196    };
197    IdentityInfo {
198        name: name.to_string(),
199        npub: keys.public_key().to_bech32().unwrap_or_default(),
200        nsec,
201        encrypted,
202    }
203}