1use crate::{
14 config,
15 error::{BpError, BpResult},
16};
17use argon2::Argon2;
18use blake3;
19use chacha20poly1305::{
20 aead::{Aead, KeyInit},
21 ChaCha20Poly1305, Key, Nonce,
22};
23use libp2p::identity::Keypair;
24use rand::RngCore;
25use serde::{Deserialize, Serialize};
26use sha2::{Digest, Sha256};
27
28const ARGON2_SALT_LEN: usize = 16; const CHACHA_NONCE_LEN: usize = 12; const CHACHA_KEY_LEN: usize = 32; #[derive(Debug, Serialize, Deserialize)]
38pub struct EncryptedKeyFile {
39 pub version: u8,
41 pub salt: String,
43 pub nonce: String,
45 pub ciphertext: String,
47}
48
49fn derive_key(passphrase: &str, salt: &[u8]) -> BpResult<[u8; CHACHA_KEY_LEN]> {
51 let mut key = [0u8; CHACHA_KEY_LEN];
52 Argon2::default()
53 .hash_password_into(passphrase.as_bytes(), salt, &mut key)
54 .map_err(|e| BpError::Identity(format!("KDF error: {e}")))?;
55 Ok(key)
56}
57
58fn encrypt_keypair_bytes(raw: &[u8], passphrase: &str) -> BpResult<EncryptedKeyFile> {
60 let mut salt = [0u8; ARGON2_SALT_LEN];
61 let mut nonce_bytes = [0u8; CHACHA_NONCE_LEN];
62 rand::thread_rng().fill_bytes(&mut salt);
63 rand::thread_rng().fill_bytes(&mut nonce_bytes);
64
65 let key = derive_key(passphrase, &salt)?;
66 let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
67 let nonce = Nonce::from_slice(&nonce_bytes);
68 let ciphertext = cipher
69 .encrypt(nonce, raw)
70 .map_err(|e| BpError::Identity(format!("Encryption error: {e}")))?;
71
72 Ok(EncryptedKeyFile {
73 version: 1,
74 salt: hex::encode(salt),
75 nonce: hex::encode(nonce_bytes),
76 ciphertext: hex::encode(ciphertext),
77 })
78}
79
80fn decrypt_keypair_bytes(enc: &EncryptedKeyFile, passphrase: &str) -> BpResult<Vec<u8>> {
85 let salt =
86 hex::decode(&enc.salt).map_err(|e| BpError::Identity(format!("Invalid salt: {e}")))?;
87 let nonce_bytes =
88 hex::decode(&enc.nonce).map_err(|e| BpError::Identity(format!("Invalid nonce: {e}")))?;
89 let ciphertext = hex::decode(&enc.ciphertext)
90 .map_err(|e| BpError::Identity(format!("Invalid ciphertext: {e}")))?;
91
92 let key = derive_key(passphrase, &salt)?;
93 let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
94 let nonce = Nonce::from_slice(&nonce_bytes);
95 cipher
96 .decrypt(nonce, ciphertext.as_ref())
97 .map_err(|_| BpError::Identity("Wrong passphrase or corrupted identity file".into()))
98}
99
100pub fn fingerprint(keypair: &Keypair) -> String {
102 fingerprint_pubkey(&keypair.public())
103}
104
105pub fn fingerprint_pubkey(pubkey: &libp2p::identity::PublicKey) -> String {
109 let pub_bytes = pubkey.encode_protobuf();
110 let hash = Sha256::digest(&pub_bytes);
111 hex::encode(&hash[..8])
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct UserProfile {
119 pub fingerprint: String,
121 pub alias: Option<String>,
123 pub created_at: chrono::DateTime<chrono::Utc>,
125}
126
127#[derive(Clone)]
135pub struct Identity {
136 pub keypair: Keypair,
138 pub peer_id: libp2p::PeerId,
140 pub fingerprint: String,
142 pub profile: UserProfile,
144}
145
146impl Identity {
147 pub fn generate(alias: Option<String>, passphrase: Option<&str>) -> BpResult<Self> {
153 config::ensure_dirs()?;
154 let keypair = Keypair::generate_ed25519();
155 let peer_id = libp2p::PeerId::from_public_key(&keypair.public());
156 let fp = fingerprint(&keypair);
157
158 let profile = UserProfile {
159 fingerprint: fp.clone(),
160 alias,
161 created_at: chrono::Utc::now(),
162 };
163
164 let key_bytes = keypair
166 .to_protobuf_encoding()
167 .map_err(|e| BpError::Identity(e.to_string()))?;
168 if let Some(pass) = passphrase {
169 let enc = encrypt_keypair_bytes(&key_bytes, pass)?;
170 let json = serde_json::to_string_pretty(&enc)?;
171 std::fs::write(config::encrypted_identity_path()?, json).map_err(BpError::Io)?;
172 tracing::info!("New identity created (passphrase-protected) — fingerprint: {fp}");
173 } else {
174 std::fs::write(config::identity_path()?, &key_bytes).map_err(BpError::Io)?;
175 tracing::info!("New identity created — fingerprint: {fp}");
176 }
177
178 let json = serde_json::to_string_pretty(&profile)?;
180 std::fs::write(config::profile_path()?, json).map_err(BpError::Io)?;
181
182 Ok(Self {
183 keypair,
184 peer_id,
185 fingerprint: fp,
186 profile,
187 })
188 }
189
190 pub fn load(passphrase: Option<&str>) -> BpResult<Self> {
198 let enc_path = config::encrypted_identity_path()?;
199 let plain_path = config::identity_path()?;
200
201 let key_bytes = if enc_path.exists() {
202 let pass = passphrase.ok_or_else(|| {
204 BpError::Identity(
205 "Identity is passphrase-protected — provide --passphrase or set BP_PASSPHRASE"
206 .into(),
207 )
208 })?;
209 let json = std::fs::read_to_string(&enc_path).map_err(BpError::Io)?;
210 let enc: EncryptedKeyFile = serde_json::from_str(&json)?;
211 decrypt_keypair_bytes(&enc, pass)?
212 } else if plain_path.exists() {
213 std::fs::read(&plain_path).map_err(BpError::Io)?
215 } else {
216 return Err(BpError::NotAuthenticated);
217 };
218
219 let keypair = Keypair::from_protobuf_encoding(&key_bytes)
220 .map_err(|e| BpError::Identity(e.to_string()))?;
221 let peer_id = libp2p::PeerId::from_public_key(&keypair.public());
222 let fp = fingerprint(&keypair);
223
224 let profile: UserProfile = {
225 let profile_path = config::profile_path()?;
226 if profile_path.exists() {
227 let json = std::fs::read_to_string(&profile_path).map_err(BpError::Io)?;
228 serde_json::from_str(&json)?
229 } else {
230 UserProfile {
231 fingerprint: fp.clone(),
232 alias: None,
233 created_at: chrono::Utc::now(),
234 }
235 }
236 };
237
238 Ok(Self {
239 keypair,
240 peer_id,
241 fingerprint: fp,
242 profile,
243 })
244 }
245
246 pub fn remove() -> BpResult<()> {
249 let key_path = config::identity_path()?;
250 if key_path.exists() {
251 std::fs::remove_file(&key_path).map_err(BpError::Io)?;
252 }
253 let enc_path = config::encrypted_identity_path()?;
254 if enc_path.exists() {
255 std::fs::remove_file(&enc_path).map_err(BpError::Io)?;
256 }
257 let profile_path = config::profile_path()?;
258 if profile_path.exists() {
259 std::fs::remove_file(&profile_path).map_err(BpError::Io)?;
260 }
261 Ok(())
262 }
263
264 pub fn exists() -> BpResult<bool> {
266 Ok(config::identity_path()?.exists() || config::encrypted_identity_path()?.exists())
267 }
268
269 pub fn secret_material(&self) -> [u8; 32] {
275 let raw = self.keypair.to_protobuf_encoding().unwrap_or_default();
276 *blake3::Hasher::new()
277 .update(b"billpouch/secret-material/v1")
278 .update(&raw)
279 .finalize()
280 .as_bytes()
281 }
282
283 pub fn export_to_file(dest: &std::path::Path) -> BpResult<()> {
291 let enc_path = config::encrypted_identity_path()?;
292 let plain_path = config::identity_path()?;
293
294 let key = if enc_path.exists() {
295 let json = std::fs::read_to_string(&enc_path).map_err(BpError::Io)?;
296 let enc: EncryptedKeyFile = serde_json::from_str(&json)?;
297 ExportedKeyData::Encrypted(enc)
298 } else if plain_path.exists() {
299 let bytes = std::fs::read(&plain_path).map_err(BpError::Io)?;
300 ExportedKeyData::Plaintext {
301 key_hex: hex::encode(bytes),
302 }
303 } else {
304 return Err(BpError::NotAuthenticated);
305 };
306
307 let profile_path = config::profile_path()?;
308 let profile: UserProfile = if profile_path.exists() {
309 let json = std::fs::read_to_string(&profile_path).map_err(BpError::Io)?;
310 serde_json::from_str(&json)?
311 } else {
312 return Err(BpError::Config(
313 "Profile not found — run `bp login` first".into(),
314 ));
315 };
316
317 let export = ExportedIdentity {
318 version: 1,
319 key,
320 profile,
321 exported_at: chrono::Utc::now(),
322 };
323
324 let json = serde_json::to_string_pretty(&export)?;
325 std::fs::write(dest, json).map_err(BpError::Io)?;
326 Ok(())
327 }
328
329 pub fn import_from_file(src: &std::path::Path, overwrite: bool) -> BpResult<UserProfile> {
338 if !overwrite && Identity::exists()? {
339 return Err(BpError::Config(
340 "An identity already exists on this machine. \
341 Run `bp logout` first, or use --force to overwrite."
342 .into(),
343 ));
344 }
345
346 config::ensure_dirs()?;
347
348 let json = std::fs::read_to_string(src).map_err(BpError::Io)?;
349 let export: ExportedIdentity = serde_json::from_str(&json)
350 .map_err(|e| BpError::Config(format!("Invalid identity export file: {e}")))?;
351
352 if export.version != 1 {
353 return Err(BpError::Config(format!(
354 "Unsupported identity export version: {}",
355 export.version
356 )));
357 }
358
359 if overwrite {
361 let _ = Identity::remove();
362 }
363
364 match export.key {
365 ExportedKeyData::Encrypted(enc) => {
366 let json = serde_json::to_string_pretty(&enc)?;
367 std::fs::write(config::encrypted_identity_path()?, json).map_err(BpError::Io)?;
368 }
369 ExportedKeyData::Plaintext { key_hex } => {
370 let bytes = hex::decode(&key_hex)
371 .map_err(|e| BpError::Config(format!("Invalid key hex: {e}")))?;
372 std::fs::write(config::identity_path()?, bytes).map_err(BpError::Io)?;
373 }
374 }
375
376 let profile_json = serde_json::to_string_pretty(&export.profile)?;
377 std::fs::write(config::profile_path()?, profile_json).map_err(BpError::Io)?;
378
379 tracing::info!(
380 fingerprint = %export.profile.fingerprint,
381 "Identity imported from {}",
382 src.display()
383 );
384
385 Ok(export.profile)
386 }
387}
388
389#[derive(Debug, Serialize, Deserialize)]
393#[serde(tag = "format", rename_all = "lowercase")]
394pub enum ExportedKeyData {
395 Plaintext { key_hex: String },
397 Encrypted(EncryptedKeyFile),
399}
400
401#[derive(Debug, Serialize, Deserialize)]
408pub struct ExportedIdentity {
409 pub version: u8,
411 pub key: ExportedKeyData,
413 pub profile: UserProfile,
415 pub exported_at: chrono::DateTime<chrono::Utc>,
417}