bp_core/
invite.rs

1//! Signed + password-encrypted invite tokens for network access control.
2//!
3//! ## Design
4//!
5//! A BillPouch network is private by default.  The only way to join is to
6//! receive an invite token from an existing member.  The token:
7//!
8//! 1. **Contains the `NetworkMetaKey`** — the 32-byte random secret that
9//!    protects file metadata on the network.
10//! 2. **Is signed** by the inviter's Ed25519 key so the recipient can verify
11//!    the invite is authentic.
12//! 3. **Is encrypted** with a password shared out-of-band (Signal, phone call,
13//!    etc.).  The password is never stored anywhere.
14//!
15//! ## Wire format
16//!
17//! ```text
18//! hex(
19//!   salt(16)            — Argon2id salt for password KDF
20//!   || nonce(12)        — ChaCha20-Poly1305 nonce
21//!   || ciphertext       — encrypt(
22//!          payload_len(4, LE u32)
23//!          || payload_json
24//!          || ed25519_signature(64)
25//!      )
26//! )
27//! ```
28//!
29//! ## Usage
30//!
31//! Inviter:
32//! ```no_run
33//! # use bp_core::invite::create_invite;
34//! # use bp_core::identity::Identity;
35//! # let identity = Identity::load(None).unwrap();
36//! let blob = create_invite(&identity, "amici", None, 24, "shared-password").unwrap();
37//! println!("{blob}");
38//! ```
39//!
40//! Invitee:
41//! ```no_run
42//! # use bp_core::invite::{redeem_invite, save_invite_key};
43//! let payload = redeem_invite("<blob>", "shared-password").unwrap();
44//! save_invite_key(&payload).unwrap();  // writes NetworkMetaKey to disk
45//! ```
46
47use 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// ── Payload ───────────────────────────────────────────────────────────────────
65
66/// The plaintext content of an invite token.
67///
68/// Serialised to JSON before signing and encryption.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct InvitePayload {
71    /// Format version — currently `1`.
72    pub version: u8,
73    /// Network the invitee is being invited to.
74    pub network_id: String,
75    /// Hex-encoded 32-byte `NetworkMetaKey` for `network_id`.
76    ///
77    /// **This is the secret** — it must never be transmitted in plaintext.
78    pub network_meta_key_hex: String,
79    /// Hex fingerprint of the inviter (for display / audit).
80    pub inviter_fingerprint: String,
81    /// Hex-encoded protobuf-serialised public key of the inviter.
82    ///
83    /// Included so the recipient can verify the signature without already
84    /// being in the network.
85    pub inviter_pubkey_hex: String,
86    /// Optional: if `Some`, only this fingerprint should redeem the token.
87    /// `None` = open invite (anyone who has the password can redeem it).
88    pub invitee_fingerprint: Option<String>,
89    /// Unix timestamp after which the token is considered expired.
90    pub expires_at: u64,
91    /// Random 16-byte hex nonce — prevents replay of the same token.
92    pub nonce_hex: String,
93}
94
95// ── Create ────────────────────────────────────────────────────────────────────
96
97/// Generate a signed + password-encrypted invite token for `network_id`.
98///
99/// # Parameters
100/// - `identity`             — The inviter's loaded identity (must have joined `network_id`).
101/// - `network_id`           — Network the invitee will be joining.
102/// - `invitee_fingerprint`  — Optional: restrict the token to one specific invitee.
103/// - `ttl_hours`            — How long the token is valid (e.g. `24`).
104/// - `invite_password`      — Shared out-of-band with the invitee (never stored).
105///
106/// # Returns
107/// A hex-encoded blob that can be passed to [`redeem_invite`].
108pub 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    // Load the NetworkMetaKey — must exist (node has previously joined/created the network).
116    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    // Build random nonce (prevents token replay).
123    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    // Ed25519 sign the payload.
143    let signature = identity
144        .keypair
145        .sign(&payload_json)
146        .map_err(|e| BpError::Identity(format!("Signing failed: {e}")))?;
147
148    // Build plaintext: len(4 LE) || payload_json || signature.
149    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    // Encrypt with Argon2id-derived key.
155    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    // Assemble blob: salt || enc_nonce || ciphertext.
167    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
175// ── Redeem ────────────────────────────────────────────────────────────────────
176
177/// Decrypt, verify and parse an invite token.
178///
179/// Does **not** save anything to disk — call [`save_invite_key`] afterwards.
180///
181/// # Errors
182/// Returns an error if:
183/// - The blob is malformed or truncated.
184/// - The password is wrong (ChaCha20-Poly1305 authentication fails).
185/// - The Ed25519 signature is invalid.
186/// - The token has expired.
187pub 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; // 16 = ChaCha20Poly1305 tag
192    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    // Parse: len(4) || payload_json || signature.
210    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    // Verify Ed25519 signature.
221    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    // Verify fingerprint matches the public key.
235    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    // Check expiry.
243    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
254/// Persist the `NetworkMetaKey` from a redeemed invite to local storage.
255///
256/// Must be called after [`redeem_invite`] succeeds.  After this call,
257/// `NetworkMetaKey::load(&payload.network_id)` will return `Some(key)`.
258pub 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
279// ── Internal helpers ──────────────────────────────────────────────────────────
280
281fn 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// ── Tests ─────────────────────────────────────────────────────────────────────
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::identity::{Identity, UserProfile};
295    use crate::storage::manifest::NetworkMetaKey;
296
297    /// Serialise every test that mutates `HOME`/`XDG_DATA_HOME` so parallel
298    /// test threads don't trample each other's environment.
299    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
300
301    fn make_identity_and_key() -> (Identity, NetworkMetaKey) {
302        // Use a deterministic test key (not from disk).
303        let nmk = NetworkMetaKey([0xABu8; 32]);
304        // Build a fresh keypair in memory.
305        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    /// Write a fake NetworkMetaKey for `network_id` to a temp dir and update
323    /// the config base path so `load/save` use it.
324    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        // Point HOME to the temp dir so config::base_dir() uses it.
332        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        // Restore
342        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            // TTL = 0 hours → already expired
376            let blob = create_invite(identity, network_id, None, 0, "pw").unwrap();
377            // Give clock 1s buffer — token created with expires_at = now, which may be ≤ now
378            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            // Flip a byte in the ciphertext region.
390            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 should not fail.
404            save_invite_key(&payload).unwrap();
405
406            // The key should now be loadable.
407            let loaded = NetworkMetaKey::load(network_id).unwrap().unwrap();
408            assert_eq!(loaded.0, [0xABu8; 32]);
409        });
410    }
411}