1use crate::{
48 config,
49 error::{BpError, BpResult},
50 identity::{fingerprint_pubkey, Identity},
51 storage::manifest::NetworkMetaKey,
52};
53use argon2::Argon2;
54use chacha20poly1305::{
55 aead::{Aead, KeyInit},
56 ChaCha20Poly1305, Key, Nonce,
57};
58use rand::RngCore;
59use serde::{Deserialize, Serialize};
60
61const ARGON2_SALT_LEN: usize = 16;
62const CHACHA_NONCE_LEN: usize = 12;
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct InvitePayload {
71 pub version: u8,
73 pub network_id: String,
75 pub network_meta_key_hex: String,
79 pub inviter_fingerprint: String,
81 pub inviter_pubkey_hex: String,
86 pub invitee_fingerprint: Option<String>,
89 pub expires_at: u64,
91 pub nonce_hex: String,
93}
94
95pub fn create_invite(
109 identity: &Identity,
110 network_id: &str,
111 invitee_fingerprint: Option<String>,
112 ttl_hours: u64,
113 invite_password: &str,
114) -> BpResult<String> {
115 let nmk = NetworkMetaKey::load(network_id)?.ok_or_else(|| {
117 BpError::Config(format!(
118 "No network key for '{network_id}' — join this network first"
119 ))
120 })?;
121
122 let mut nonce_bytes = [0u8; 16];
124 rand::thread_rng().fill_bytes(&mut nonce_bytes);
125
126 let expires_at =
127 (chrono::Utc::now() + chrono::Duration::hours(ttl_hours as i64)).timestamp() as u64;
128
129 let payload = InvitePayload {
130 version: 1,
131 network_id: network_id.to_string(),
132 network_meta_key_hex: hex::encode(nmk.0),
133 inviter_fingerprint: identity.fingerprint.clone(),
134 inviter_pubkey_hex: hex::encode(identity.keypair.public().encode_protobuf()),
135 invitee_fingerprint,
136 expires_at,
137 nonce_hex: hex::encode(nonce_bytes),
138 };
139
140 let payload_json = serde_json::to_vec(&payload).map_err(BpError::Serde)?;
141
142 let signature = identity
144 .keypair
145 .sign(&payload_json)
146 .map_err(|e| BpError::Identity(format!("Signing failed: {e}")))?;
147
148 let mut plaintext: Vec<u8> = Vec::with_capacity(4 + payload_json.len() + signature.len());
150 plaintext.extend_from_slice(&(payload_json.len() as u32).to_le_bytes());
151 plaintext.extend_from_slice(&payload_json);
152 plaintext.extend_from_slice(&signature);
153
154 let mut salt = [0u8; ARGON2_SALT_LEN];
156 let mut enc_nonce = [0u8; CHACHA_NONCE_LEN];
157 rand::thread_rng().fill_bytes(&mut salt);
158 rand::thread_rng().fill_bytes(&mut enc_nonce);
159
160 let key = derive_key(invite_password, &salt)?;
161 let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
162 let ciphertext = cipher
163 .encrypt(Nonce::from_slice(&enc_nonce), plaintext.as_ref())
164 .map_err(|e| BpError::Identity(format!("Invite encryption failed: {e}")))?;
165
166 let mut blob_bytes = Vec::with_capacity(ARGON2_SALT_LEN + CHACHA_NONCE_LEN + ciphertext.len());
168 blob_bytes.extend_from_slice(&salt);
169 blob_bytes.extend_from_slice(&enc_nonce);
170 blob_bytes.extend_from_slice(&ciphertext);
171
172 Ok(hex::encode(blob_bytes))
173}
174
175pub fn redeem_invite(blob: &str, invite_password: &str) -> BpResult<InvitePayload> {
188 let blob_bytes = hex::decode(blob)
189 .map_err(|e| BpError::Config(format!("Invalid invite blob (not hex): {e}")))?;
190
191 let min_len = ARGON2_SALT_LEN + CHACHA_NONCE_LEN + 16; if blob_bytes.len() < min_len {
193 return Err(BpError::Config("Invite blob is too short".into()));
194 }
195
196 let salt: [u8; ARGON2_SALT_LEN] = blob_bytes[..ARGON2_SALT_LEN].try_into().unwrap();
197 let enc_nonce: [u8; CHACHA_NONCE_LEN] = blob_bytes
198 [ARGON2_SALT_LEN..ARGON2_SALT_LEN + CHACHA_NONCE_LEN]
199 .try_into()
200 .unwrap();
201 let ciphertext = &blob_bytes[ARGON2_SALT_LEN + CHACHA_NONCE_LEN..];
202
203 let key = derive_key(invite_password, &salt)?;
204 let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
205 let plaintext = cipher
206 .decrypt(Nonce::from_slice(&enc_nonce), ciphertext)
207 .map_err(|_| BpError::Identity("Wrong invite password or corrupted token".into()))?;
208
209 if plaintext.len() < 4 {
211 return Err(BpError::Config("Invite plaintext too short".into()));
212 }
213 let payload_len = u32::from_le_bytes(plaintext[..4].try_into().unwrap()) as usize;
214 if plaintext.len() < 4 + payload_len {
215 return Err(BpError::Config("Invite payload truncated".into()));
216 }
217 let payload_json = &plaintext[4..4 + payload_len];
218 let signature = &plaintext[4 + payload_len..];
219
220 let payload: InvitePayload = serde_json::from_slice(payload_json).map_err(BpError::Serde)?;
222
223 let pubkey_bytes = hex::decode(&payload.inviter_pubkey_hex)
224 .map_err(|e| BpError::Identity(format!("Invalid inviter pubkey hex: {e}")))?;
225 let pubkey = libp2p::identity::PublicKey::try_decode_protobuf(&pubkey_bytes)
226 .map_err(|e| BpError::Identity(format!("Cannot parse inviter pubkey: {e}")))?;
227
228 if !pubkey.verify(payload_json, signature) {
229 return Err(BpError::Identity(
230 "Invite signature verification failed — token may be forged".into(),
231 ));
232 }
233
234 let expected_fp = fingerprint_pubkey(&pubkey);
236 if expected_fp != payload.inviter_fingerprint {
237 return Err(BpError::Identity(
238 "Invite fingerprint mismatch — token may be tampered".into(),
239 ));
240 }
241
242 let now = chrono::Utc::now().timestamp() as u64;
244 if now > payload.expires_at {
245 return Err(BpError::Identity(format!(
246 "Invite token expired at unix timestamp {}",
247 payload.expires_at
248 )));
249 }
250
251 Ok(payload)
252}
253
254pub fn save_invite_key(payload: &InvitePayload) -> BpResult<()> {
259 config::ensure_dirs()?;
260 let key_bytes = hex::decode(&payload.network_meta_key_hex)
261 .map_err(|e| BpError::Config(format!("Invalid network key in invite: {e}")))?;
262 if key_bytes.len() != 32 {
263 return Err(BpError::Config(
264 "Network key in invite has wrong length".into(),
265 ));
266 }
267 let mut arr = [0u8; 32];
268 arr.copy_from_slice(&key_bytes);
269 let nmk = NetworkMetaKey(arr);
270 nmk.save(&payload.network_id)?;
271 tracing::info!(
272 network = %payload.network_id,
273 inviter = %payload.inviter_fingerprint,
274 "NetworkMetaKey saved from invite"
275 );
276 Ok(())
277}
278
279fn derive_key(password: &str, salt: &[u8]) -> BpResult<[u8; 32]> {
282 let mut key = [0u8; 32];
283 Argon2::default()
284 .hash_password_into(password.as_bytes(), salt, &mut key)
285 .map_err(|e| BpError::Identity(format!("KDF error: {e}")))?;
286 Ok(key)
287}
288
289#[cfg(test)]
292mod tests {
293 use super::*;
294 use crate::identity::{Identity, UserProfile};
295 use crate::storage::manifest::NetworkMetaKey;
296
297 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
300
301 fn make_identity_and_key() -> (Identity, NetworkMetaKey) {
302 let nmk = NetworkMetaKey([0xABu8; 32]);
304 let keypair = libp2p::identity::Keypair::generate_ed25519();
306 let peer_id = libp2p::PeerId::from_public_key(&keypair.public());
307 let fp = crate::identity::fingerprint(&keypair);
308 let profile = UserProfile {
309 fingerprint: fp.clone(),
310 alias: Some("tester".into()),
311 created_at: chrono::Utc::now(),
312 };
313 let identity = Identity {
314 keypair,
315 peer_id,
316 fingerprint: fp,
317 profile,
318 };
319 (identity, nmk)
320 }
321
322 fn with_temp_nmk<F: FnOnce(&Identity, &str)>(f: F) {
325 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
326 let dir =
327 std::env::temp_dir().join(format!("bp_invite_test_{}", uuid::Uuid::new_v4().simple()));
328 std::fs::create_dir_all(&dir).unwrap();
329 let orig_home = std::env::var("HOME").ok();
330
331 std::env::set_var("HOME", &dir);
333 std::env::set_var("XDG_DATA_HOME", dir.join(".local/share").to_str().unwrap());
334
335 let network_id = "test-net";
336 let (identity, nmk) = make_identity_and_key();
337 nmk.save(network_id).unwrap();
338
339 f(&identity, network_id);
340
341 match orig_home {
343 Some(h) => std::env::set_var("HOME", h),
344 None => std::env::remove_var("HOME"),
345 }
346 std::env::remove_var("XDG_DATA_HOME");
347 let _ = std::fs::remove_dir_all(&dir);
348 }
349
350 #[test]
351 #[cfg(unix)]
352 fn create_and_redeem_roundtrip() {
353 with_temp_nmk(|identity, network_id| {
354 let blob = create_invite(identity, network_id, None, 24, "test-password").unwrap();
355 let payload = redeem_invite(&blob, "test-password").unwrap();
356 assert_eq!(payload.network_id, network_id);
357 assert_eq!(payload.inviter_fingerprint, identity.fingerprint);
358 assert_eq!(payload.network_meta_key_hex, hex::encode([0xABu8; 32]));
359 });
360 }
361
362 #[test]
363 #[cfg(unix)]
364 fn wrong_password_fails() {
365 with_temp_nmk(|identity, network_id| {
366 let blob = create_invite(identity, network_id, None, 24, "correct").unwrap();
367 assert!(redeem_invite(&blob, "wrong").is_err());
368 });
369 }
370
371 #[test]
372 #[cfg(unix)]
373 fn expired_token_fails() {
374 with_temp_nmk(|identity, network_id| {
375 let blob = create_invite(identity, network_id, None, 0, "pw").unwrap();
377 std::thread::sleep(std::time::Duration::from_secs(1));
379 assert!(redeem_invite(&blob, "pw").is_err());
380 });
381 }
382
383 #[test]
384 #[cfg(unix)]
385 fn tampered_blob_fails() {
386 with_temp_nmk(|identity, network_id| {
387 let mut blob_bytes =
388 hex::decode(create_invite(identity, network_id, None, 24, "pw").unwrap()).unwrap();
389 let last = blob_bytes.len() - 5;
391 blob_bytes[last] ^= 0xFF;
392 assert!(redeem_invite(&hex::encode(blob_bytes), "pw").is_err());
393 });
394 }
395
396 #[test]
397 #[cfg(unix)]
398 fn save_invite_key_persists() {
399 with_temp_nmk(|identity, network_id| {
400 let blob = create_invite(identity, network_id, None, 24, "pw").unwrap();
401 let payload = redeem_invite(&blob, "pw").unwrap();
402
403 save_invite_key(&payload).unwrap();
405
406 let loaded = NetworkMetaKey::load(network_id).unwrap().unwrap();
408 assert_eq!(loaded.0, [0xABu8; 32]);
409 });
410 }
411}