bp_core/
identity.rs

1//! User identity: Ed25519 keypair persisted to disk.
2//!
3//! The keypair can be stored in two formats:
4//! - **Plaintext** (`identity.key`) — raw protobuf bytes, no passphrase.
5//! - **Encrypted** (`identity.key.enc`) — JSON envelope produced by Argon2id
6//!   key-derivation + ChaCha20-Poly1305 symmetric encryption.  Used when the
7//!   user supplies `--passphrase` at `bp login`.
8//!
9//! The encrypted format is automatically detected at load time.  The
10//! plaintext format remains supported for backwards compatibility and for
11//! headless/CI environments.
12
13use 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
28// ── Encryption constants ──────────────────────────────────────────────────────
29
30const ARGON2_SALT_LEN: usize = 16; // 128-bit salt for Argon2id
31const CHACHA_NONCE_LEN: usize = 12; // 96-bit nonce for ChaCha20-Poly1305
32const CHACHA_KEY_LEN: usize = 32; // 256-bit key for ChaCha20-Poly1305
33
34/// On-disk representation of a passphrase-protected keypair.
35///
36/// Written as pretty-printed JSON to `~/.local/share/billpouch/identity.key.enc`.
37#[derive(Debug, Serialize, Deserialize)]
38pub struct EncryptedKeyFile {
39    /// Format version — currently `1`.
40    pub version: u8,
41    /// Hex-encoded 16-byte Argon2id salt.
42    pub salt: String,
43    /// Hex-encoded 12-byte ChaCha20-Poly1305 nonce.
44    pub nonce: String,
45    /// Hex-encoded ChaCha20-Poly1305 ciphertext (includes the 16-byte auth tag).
46    pub ciphertext: String,
47}
48
49/// Derive a 32-byte ChaCha20 key from `passphrase` using Argon2id.
50fn 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
58/// Encrypt raw protobuf-encoded keypair bytes with `passphrase`.
59fn 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
80/// Decrypt raw protobuf-encoded keypair bytes from an [`EncryptedKeyFile`].
81///
82/// Returns [`BpError::Identity`] if the passphrase is wrong or the file is
83/// corrupted.
84fn 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
100/// Human-readable fingerprint of a public key (hex-encoded SHA-256, first 16 bytes).
101pub fn fingerprint(keypair: &Keypair) -> String {
102    fingerprint_pubkey(&keypair.public())
103}
104
105/// Fingerprint of a public key (hex-encoded SHA-256[0..8]).
106///
107/// Used to verify invite token signatures without a pre-existing trust anchor.
108pub 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/// Persistent user metadata stored alongside the keypair.
115///
116/// Serialised as JSON to `~/.local/share/billpouch/profile.json`.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct UserProfile {
119    /// Hex fingerprint derived from this identity's public key (16 hex chars).
120    pub fingerprint: String,
121    /// Optional human-readable alias chosen at `bp login`.
122    pub alias: Option<String>,
123    /// UTC timestamp when the identity was first generated.
124    pub created_at: chrono::DateTime<chrono::Utc>,
125}
126
127/// In-memory identity: the loaded keypair and all derived or persisted metadata.
128///
129/// Constructed either by [`Identity::generate`] (first login) or
130/// [`Identity::load`] (subsequent daemon starts).
131///
132/// `Identity` does **not** implement `Debug` because `libp2p::identity::Keypair`
133/// does not expose a `Debug` impl (the bytes are considered secret).
134#[derive(Clone)]
135pub struct Identity {
136    /// The Ed25519 keypair.  Treat as secret — never log or serialise directly.
137    pub keypair: Keypair,
138    /// libp2p peer identifier derived deterministically from the public key.
139    pub peer_id: libp2p::PeerId,
140    /// Hex-encoded SHA-256(pubkey)[0..8] — 16 characters, immutable for this identity.
141    pub fingerprint: String,
142    /// Persisted profile (alias, creation timestamp, etc.).
143    pub profile: UserProfile,
144}
145
146impl Identity {
147    /// Generate a brand-new Ed25519 identity and persist it.
148    ///
149    /// If `passphrase` is `Some`, the keypair is encrypted with Argon2id +
150    /// ChaCha20-Poly1305 and written to `identity.key.enc`.  If `None`, the
151    /// raw protobuf bytes are written to `identity.key` (backwards-compatible).
152    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        // Persist keypair — encrypted or plaintext.
165        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        // Persist profile.
179        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    /// Load existing identity from disk.
191    ///
192    /// - If `identity.key.enc` exists, `passphrase` is **required**.  Passing
193    ///   `None` returns [`BpError::Identity`] with a descriptive message.
194    /// - If `identity.key` exists (plaintext), the `passphrase` argument is
195    ///   ignored.
196    /// - If neither file exists, returns [`BpError::NotAuthenticated`].
197    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            // Encrypted identity — passphrase is mandatory.
203            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            // Plaintext identity (legacy / no-passphrase).
214            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    /// Remove identity from disk (logout).  Removes both plaintext and
247    /// encrypted key files if present, plus the profile.
248    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    /// Returns `true` if any identity (plaintext or encrypted) is stored on disk.
265    pub fn exists() -> BpResult<bool> {
266        Ok(config::identity_path()?.exists() || config::encrypted_identity_path()?.exists())
267    }
268
269    /// Derive 32 bytes of secret material from the keypair.
270    ///
271    /// Used as the master input for per-file Content Encryption Key (CEK)
272    /// derivation.  The output is deterministic for a given keypair but must
273    /// **never** be stored or logged — treat it like the private key itself.
274    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    /// Export this identity to a portable JSON file at `dest`.
284    ///
285    /// The on-disk key files are copied verbatim: passphrase-protected
286    /// identities export the encrypted form (no passphrase needed here);
287    /// plaintext identities hex-encode the raw protobuf bytes.
288    /// The output file is safe to transfer to another machine; use
289    /// [`Identity::import_from_file`] on the receiving end.
290    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    /// Import an identity from a portable export file created by
330    /// [`Identity::export_to_file`].
331    ///
332    /// Installs the keypair and profile to the XDG data directory.
333    /// Returns the imported [`UserProfile`] on success.
334    ///
335    /// If an identity already exists and `overwrite` is `false`, returns
336    /// [`BpError::Config`] with a message asking the user to `bp logout` first.
337    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        // Remove existing files before overwriting.
360        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// ── Portable identity export ──────────────────────────────────────────────────
390
391/// Key data carried inside an [`ExportedIdentity`].
392#[derive(Debug, Serialize, Deserialize)]
393#[serde(tag = "format", rename_all = "lowercase")]
394pub enum ExportedKeyData {
395    /// Raw protobuf-encoded keypair, hex-encoded (no passphrase).
396    Plaintext { key_hex: String },
397    /// Argon2id + ChaCha20-Poly1305 encrypted keypair (passphrase still needed to use).
398    Encrypted(EncryptedKeyFile),
399}
400
401/// Portable JSON bundle that carries a complete BillPouch identity.
402///
403/// Produced by [`Identity::export_to_file`]; consumed by
404/// [`Identity::import_from_file`].  Safe to store on USB, email, etc. —
405/// the encrypted variant requires the original passphrase to use, and the
406/// plaintext variant should be treated like a private key file.
407#[derive(Debug, Serialize, Deserialize)]
408pub struct ExportedIdentity {
409    /// Format version — currently `1`.
410    pub version: u8,
411    /// The keypair (plaintext or encrypted).
412    pub key: ExportedKeyData,
413    /// User profile (alias, fingerprint, creation timestamp).
414    pub profile: UserProfile,
415    /// UTC timestamp when this bundle was created.
416    pub exported_at: chrono::DateTime<chrono::Utc>,
417}