Skip to main content

client_core/
auth_session.rs

1//! Single atomic entry point for installing a fresh sign-in onto disk.
2//!
3//! Replaces the historical pattern where each caller (CLI `run_login`,
4//! desktop `sign_in`, desktop `handle_deeplink`) wrote credentials in three
5//! independent steps:
6//!
7//!   1. `auth::write_credentials` — token + user_id + device_id + bump
8//!   2. `credstore::write_encryption_key` — generated lazily, sometimes
9//!      after the version bump fired
10//!   3. `credstore::write_device_privkey` — generated lazily as well
11//!
12//! The lazy generation race meant the desktop FS watcher could fire on the
13//! version bump from step 1 and adopt credentials before steps 2-3 had
14//! produced the AES + X25519 material. `install_credentials` collapses all
15//! three writes into a single transaction with exactly one
16//! `credential_version` bump at the end.
17//!
18//! AES + X25519 are generated up-front (eager) and reused if the user
19//! already has them on this machine.
20
21use crate::auth::{load_multi_config, save_multi_config, CredentialError};
22use crate::config::RelayProfile;
23use crate::credstore;
24use crate::crypto;
25
26/// Inputs for an atomic credential install. Everything the relay returned
27/// for a fresh device-code or pair handshake.
28pub struct InstallParams<'a> {
29    pub user_id: &'a str,
30    pub device_id: &'a str,
31    pub token: &'a str,
32    pub relay_url: &'a str,
33    pub hostname: &'a str,
34    /// Optional pre-supplied X25519 device private key (base64url). When
35    /// `None`, `install_credentials` generates a fresh keypair if the user
36    /// does not already have one on this machine.
37    pub device_private_key: Option<&'a str>,
38    /// Verified email returned by the OAuth provider. Empty string when not available.
39    pub email: &'a str,
40    /// OAuth identity provider name ("google" or "github"). Empty string when not available.
41    pub identity_provider: &'a str,
42    /// Effective display name returned by the relay's poll response. Empty when
43    /// no name is available; the CLI falls back to email/user_id in `auth status`.
44    pub display_name: &'a str,
45}
46
47/// Outcome of `install_credentials` — useful for callers that want to
48/// surface "this is the first sign-in on this machine" or report which
49/// credstore backend was used.
50#[derive(Debug, Clone)]
51pub struct InstallOutcome {
52    /// Active relay_id after the install (matches `MultiConfig.active_relay_id`).
53    pub active_relay_id: String,
54    /// New `credential_version` value persisted to disk.
55    pub credential_version: u64,
56    /// Backend used for the AES key write: `"keyring"` or `"plaintext"`.
57    pub encryption_backend: &'static str,
58    /// True when this call generated the AES key (vs. reused an existing one).
59    /// After the E2EE-001 fix this is only true when the caller passed in a
60    /// pre-existing master key path that wrote into the credstore; brand-new
61    /// devices never fabricate a master key locally and instead surface
62    /// `key_pending = true`.
63    pub generated_encryption_key: bool,
64    /// True when this call generated the X25519 device key.
65    pub generated_device_private_key: bool,
66    /// True when no user-scoped master AES key was available at install
67    /// time. The device's X25519 keypair is registered with the relay, but
68    /// every push/pull must trigger `retry_key_bundle` + `poll_key_bundle`
69    /// until a paired device responds with an encrypted bundle.
70    pub key_pending: bool,
71}
72
73/// Install credentials atomically: writes the AES user key + X25519 device
74/// key first, then updates `~/.cinch/config.json` with token / user_id /
75/// device_id / hostname / machine_id and bumps `credential_version` exactly
76/// once at the end.
77///
78/// This is the only function CLI / desktop should call when persisting a
79/// fresh sign-in. It guarantees the desktop FS watcher sees a fully-formed
80/// credential set on the version bump.
81pub fn install_credentials(p: InstallParams<'_>) -> Result<InstallOutcome, CredentialError> {
82    if p.user_id.is_empty() || p.device_id.is_empty() || p.token.is_empty() {
83        return Err(CredentialError::BadConfig(
84            "user_id, device_id, token are required".into(),
85        ));
86    }
87
88    // Step 1: never fabricate a local AES key. The user-scoped master key
89    // is either already on disk (re-login on the same machine) or it must
90    // arrive via ECDH from a paired device. Anything else silently
91    // partitions this device from the rest of the account (E2EE-001).
92    let generated_encryption_key = false;
93    let encryption_backend: &'static str = "plaintext";
94    let key_pending = credstore::read_encryption_key(p.user_id).is_none();
95
96    // Step 2: ensure a per-device X25519 keypair exists.
97    let mut generated_device_private_key = false;
98    let device_priv_b64: String = if let Some(provided) = p.device_private_key {
99        if !provided.is_empty() {
100            credstore::write_device_privkey(p.user_id, p.device_id, provided)
101                .map_err(|e| CredentialError::Io(format!("device key: {}", e)))?;
102            provided.to_string()
103        } else {
104            install_or_reuse_device_privkey(
105                p.user_id,
106                p.device_id,
107                &mut generated_device_private_key,
108            )?
109        }
110    } else {
111        install_or_reuse_device_privkey(p.user_id, p.device_id, &mut generated_device_private_key)?
112    };
113
114    // Step 3: update config.json — set/replace the active profile and bump
115    // credential_version exactly once.
116    let mut mc = load_multi_config()?;
117    let next_version = mc
118        .relays
119        .iter()
120        .map(|r| r.credential_version)
121        .max()
122        .unwrap_or(0)
123        .checked_add(1)
124        .ok_or_else(|| CredentialError::BadConfig("credential_version overflow".into()))?;
125
126    let machine_id = crate::machine::stable_machine_id();
127
128    if let Some(profile) = mc.active_profile_mut() {
129        profile.token = p.token.to_string();
130        profile.user_id = p.user_id.to_string();
131        profile.device_id = p.device_id.to_string();
132        profile.relay_url = p.relay_url.to_string();
133        profile.hostname = p.hostname.to_string();
134        profile.device_private_key = device_priv_b64;
135        profile.machine_id = machine_id;
136        profile.credential_version = next_version;
137        profile.key_pending = key_pending;
138        if !p.email.is_empty() {
139            profile.email = p.email.to_string();
140        }
141        if !p.identity_provider.is_empty() {
142            profile.identity_provider = p.identity_provider.to_string();
143        }
144        if !p.display_name.is_empty() {
145            profile.display_name = p.display_name.to_string();
146        }
147    } else {
148        use ulid::Ulid;
149        let label = url::Url::parse(p.relay_url)
150            .ok()
151            .and_then(|u| u.host_str().map(|h| h.to_string()))
152            .unwrap_or_else(|| p.relay_url.to_string());
153        let profile = RelayProfile {
154            id: Ulid::new().to_string(),
155            label,
156            relay_url: p.relay_url.to_string(),
157            user_id: p.user_id.to_string(),
158            device_id: p.device_id.to_string(),
159            hostname: p.hostname.to_string(),
160            encryption_key: String::new(),
161            device_private_key: device_priv_b64,
162            credential_version: next_version,
163            token: p.token.to_string(),
164            machine_id,
165            email: p.email.to_string(),
166            identity_provider: p.identity_provider.to_string(),
167            display_name: p.display_name.to_string(),
168            key_pending,
169        };
170        let id = profile.id.clone();
171        mc.relays.push(profile);
172        mc.active_relay_id = Some(id);
173    }
174
175    let active_relay_id = mc.active_relay_id.clone().unwrap_or_default();
176    save_multi_config(&mc)?;
177
178    Ok(InstallOutcome {
179        active_relay_id,
180        credential_version: next_version,
181        encryption_backend,
182        generated_encryption_key,
183        generated_device_private_key,
184        key_pending,
185    })
186}
187
188/// Error returned when the E2EE key is not available for a user.
189#[derive(Debug, thiserror::Error)]
190pub enum RequireKeyError {
191    /// No master key on disk AND no record that one should arrive — the user
192    /// genuinely has no credential for this user_id. CLI should prompt
193    /// `cinch auth login`.
194    #[error("encryption key not found for user")]
195    Missing,
196    /// The device registered its X25519 public key with the relay but the
197    /// master AES key has not yet been received via ECDH. CLI should auto
198    /// retry `attempt_key_exchange_blocking` once before failing.
199    #[error("encryption key pending — waiting for paired device to share")]
200    PendingExchange,
201}
202
203/// Persist a freshly-received 32-byte master AES key for `user_id` and
204/// atomically clear `key_pending` on the active relay profile. Called by
205/// `auth::poll_key_bundle` and by any other entry point (e.g. recovery-code
206/// restore) that hands the device its real master key.
207pub fn persist_received_master_key(
208    user_id: &str,
209    master_key: &[u8; 32],
210) -> Result<(), CredentialError> {
211    credstore::write_encryption_key(user_id, master_key)
212        .map_err(|e| CredentialError::Io(format!("write encryption key: {}", e)))?;
213    let mut mc = load_multi_config()?;
214    if let Some(profile) = mc.active_profile_mut() {
215        if profile.key_pending {
216            profile.key_pending = false;
217            save_multi_config(&mc)?;
218        }
219    }
220    Ok(())
221}
222
223/// E2EE precondition. Returns the user's AES-256 key, `PendingExchange`
224/// when the device is waiting on a paired peer to share the master key, or
225/// `Missing` when no credential exists at all. Callers map:
226/// - `Missing` → `ENCRYPTION_REQUIRED` exit code, prompt `cinch auth login`
227/// - `PendingExchange` → `ENCRYPTION_PENDING` exit code, auto-retry key exchange
228pub fn require_encryption_key(user_id: &str) -> Result<[u8; 32], RequireKeyError> {
229    if user_id.is_empty() {
230        return Err(RequireKeyError::Missing);
231    }
232    if let Some(key) = credstore::read_encryption_key(user_id) {
233        return Ok(key);
234    }
235    // No key on disk — distinguish "pending ECDH" from "not signed in".
236    if let Ok(cfg) = crate::auth::load_config() {
237        if cfg.key_pending && cfg.user_id == user_id {
238            return Err(RequireKeyError::PendingExchange);
239        }
240    }
241    Err(RequireKeyError::Missing)
242}
243
244fn install_or_reuse_device_privkey(
245    user_id: &str,
246    device_id: &str,
247    generated_flag: &mut bool,
248) -> Result<String, CredentialError> {
249    // Best-effort: if the active profile already has a non-empty device_private_key
250    // for this same (user_id, device_id), reuse it. Otherwise generate one.
251    let existing = load_multi_config()
252        .ok()
253        .and_then(|mc| mc.active_profile().cloned())
254        .filter(|p| {
255            p.user_id == user_id && p.device_id == device_id && !p.device_private_key.is_empty()
256        })
257        .map(|p| p.device_private_key);
258
259    if let Some(priv_b64) = existing {
260        return Ok(priv_b64);
261    }
262
263    let (priv_b64, _pub_b64) = crypto::generate_device_keypair();
264    credstore::write_device_privkey(user_id, device_id, &priv_b64)
265        .map_err(|e| CredentialError::Io(format!("device key: {}", e)))?;
266    *generated_flag = true;
267    Ok(priv_b64)
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use std::sync::Mutex;
274
275    /// Tests below mutate `HOME` to redirect `~/.cinch/config.json` into a
276    /// per-test `tempdir`. Cargo runs tests in parallel by default, so
277    /// without this lock two tests would see each other's HOME and read a
278    /// half-cleaned config file. The lock is held only for the body of each
279    /// HOME-mutating test, so non-HOME tests stay parallel.
280    static HOME_LOCK: Mutex<()> = Mutex::new(());
281
282    #[test]
283    fn rejects_empty_required_fields() {
284        let p = InstallParams {
285            user_id: "",
286            device_id: "d1",
287            token: "tok",
288            relay_url: "http://localhost:8080",
289            hostname: "h",
290            device_private_key: None,
291            email: "",
292            identity_provider: "",
293            display_name: "",
294        };
295        assert!(install_credentials(p).is_err());
296    }
297
298    #[test]
299    fn install_writes_display_name_into_profile() {
300        let _guard = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
301        let tmp = tempfile::tempdir().unwrap();
302        std::env::set_var("HOME", tmp.path());
303        let outcome = install_credentials(InstallParams {
304            user_id: "u1",
305            device_id: "d1",
306            token: "tok",
307            relay_url: "https://r",
308            hostname: "host",
309            device_private_key: None,
310            email: "alice@example.com",
311            identity_provider: "github",
312            display_name: "Alice Example",
313        })
314        .expect("install");
315        assert!(!outcome.active_relay_id.is_empty());
316
317        let cfg = crate::auth::load_config().expect("load");
318        assert_eq!(cfg.display_name, "Alice Example");
319    }
320
321    #[test]
322    fn require_encryption_key_errors_when_missing() {
323        let err = require_encryption_key("test-no-key-x7k9q").unwrap_err();
324        assert!(matches!(err, RequireKeyError::Missing));
325    }
326
327    #[test]
328    fn require_encryption_key_errors_on_empty_user_id() {
329        let err = require_encryption_key("").unwrap_err();
330        assert!(matches!(err, RequireKeyError::Missing));
331    }
332
333    /// E2EE-001 phase A3: when the active relay profile is marked
334    /// `key_pending`, `require_encryption_key` must surface a distinct
335    /// `PendingExchange` error so the CLI can branch into auto-retry
336    /// instead of asking the user to run `cinch auth login`.
337    #[test]
338    fn require_encryption_key_returns_pending_when_flagged() {
339        let _guard = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
340        let tmp = tempfile::tempdir().unwrap();
341        std::env::set_var("HOME", tmp.path());
342
343        install_credentials(InstallParams {
344            user_id: "u-pending",
345            device_id: "d-pending",
346            token: "tok",
347            relay_url: "https://r",
348            hostname: "host",
349            device_private_key: None,
350            email: "",
351            identity_provider: "",
352            display_name: "",
353        })
354        .expect("install");
355
356        let err = require_encryption_key("u-pending").unwrap_err();
357        assert!(
358            matches!(err, RequireKeyError::PendingExchange),
359            "expected PendingExchange, got {:?}",
360            err
361        );
362    }
363
364    /// E2EE-001: re-login on the same machine (master key already on disk
365    /// from a prior key-exchange handshake) must REUSE the existing master
366    /// key and clear `key_pending`. Otherwise an innocent re-login would
367    /// wipe the device's working key and force another ECDH round trip.
368    #[test]
369    fn install_reuses_existing_master_key_and_leaves_pending_false() {
370        let _guard = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
371        let tmp = tempfile::tempdir().unwrap();
372        std::env::set_var("HOME", tmp.path());
373
374        // Simulate the realistic post-handshake state: a first install ran
375        // and left key_pending=true, then a paired device sent the bundle
376        // and poll_key_bundle persisted the master key for this user.
377        install_credentials(InstallParams {
378            user_id: "u-reuse",
379            device_id: "d-reuse",
380            token: "tok-1",
381            relay_url: "https://r",
382            hostname: "host",
383            device_private_key: None,
384            email: "",
385            identity_provider: "",
386            display_name: "",
387        })
388        .expect("first install");
389        let existing_key = [0x77u8; 32];
390        credstore::write_encryption_key("u-reuse", &existing_key).expect("seed key");
391
392        // Second install (re-login on the same machine) for the same user.
393        let outcome = install_credentials(InstallParams {
394            user_id: "u-reuse",
395            device_id: "d-reuse",
396            token: "tok-2",
397            relay_url: "https://r",
398            hostname: "host",
399            device_private_key: None,
400            email: "",
401            identity_provider: "",
402            display_name: "",
403        })
404        .expect("second install");
405
406        assert!(
407            !outcome.generated_encryption_key,
408            "must not regenerate when the user already has a master key"
409        );
410        assert!(
411            !outcome.key_pending,
412            "key_pending must be false when the master key is already on disk"
413        );
414        let roundtrip = credstore::read_encryption_key("u-reuse").expect("read back");
415        assert_eq!(
416            roundtrip, existing_key,
417            "install must not overwrite the existing master key"
418        );
419        let cfg = crate::auth::load_config().expect("load");
420        assert!(
421            !cfg.encryption_key.is_empty(),
422            "config.encryption_key should reflect the preserved master key"
423        );
424        assert!(!cfg.key_pending);
425    }
426
427    /// E2EE-001: a brand-new device must NOT fabricate a fresh master AES key
428    /// locally. Silently doing so partitions the device from every other
429    /// device on the same account once `poll_key_bundle` times out — every
430    /// push/pull then fails with `aead::Error`. Instead the device is marked
431    /// `key_pending` and waits for a paired device to share the real master
432    /// key via ECDH.
433    /// E2EE-001 phase A2: when a key bundle arrives via the ECDH handshake,
434    /// `persist_received_master_key` must (a) store the 32-byte master key
435    /// for the user, and (b) atomically clear the `key_pending` flag on the
436    /// active relay profile so subsequent push/pull no longer auto-retry.
437    #[test]
438    fn persist_received_master_key_clears_key_pending_and_stores_key() {
439        let _guard = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
440        let tmp = tempfile::tempdir().unwrap();
441        std::env::set_var("HOME", tmp.path());
442
443        // First install leaves the device in key_pending state.
444        install_credentials(InstallParams {
445            user_id: "u-recv",
446            device_id: "d-recv",
447            token: "tok",
448            relay_url: "https://r",
449            hostname: "host",
450            device_private_key: None,
451            email: "",
452            identity_provider: "",
453            display_name: "",
454        })
455        .expect("install");
456        let cfg_before = crate::auth::load_config().expect("load before");
457        assert!(cfg_before.key_pending, "precondition: install left key_pending=true");
458        assert!(cfg_before.encryption_key.is_empty());
459
460        // Simulate a successfully decrypted bundle landing.
461        let master = [0xAAu8; 32];
462        crate::auth_session::persist_received_master_key("u-recv", &master)
463            .expect("persist master key");
464
465        let cfg_after = crate::auth::load_config().expect("load after");
466        assert!(!cfg_after.key_pending, "key_pending must be cleared on receipt");
467        let roundtrip = credstore::read_encryption_key("u-recv").expect("read key");
468        assert_eq!(roundtrip, master, "master key must be persisted byte-for-byte");
469    }
470
471    #[test]
472    fn install_does_not_generate_local_aes_when_user_has_no_existing_key() {
473        let _guard = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
474        let tmp = tempfile::tempdir().unwrap();
475        std::env::set_var("HOME", tmp.path());
476        let outcome = install_credentials(InstallParams {
477            user_id: "u-new-001",
478            device_id: "d-new-001",
479            token: "tok",
480            relay_url: "https://r",
481            hostname: "host",
482            device_private_key: None,
483            email: "",
484            identity_provider: "",
485            display_name: "",
486        })
487        .expect("install");
488        assert!(
489            !outcome.generated_encryption_key,
490            "install_credentials must not generate a fresh AES key for a brand-new device"
491        );
492        assert!(
493            outcome.key_pending,
494            "outcome.key_pending must be true when no master key was provided or found"
495        );
496
497        let cfg = crate::auth::load_config().expect("load");
498        assert!(
499            cfg.encryption_key.is_empty(),
500            "encryption_key on disk must remain empty until a paired device shares it"
501        );
502        assert!(
503            cfg.key_pending,
504            "key_pending on disk must be true so push/pull know to auto-retry"
505        );
506    }
507}