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    pub generated_encryption_key: bool,
60    /// True when this call generated the X25519 device key.
61    pub generated_device_private_key: bool,
62}
63
64/// Install credentials atomically: writes the AES user key + X25519 device
65/// key first, then updates `~/.cinch/config.json` with token / user_id /
66/// device_id / hostname / machine_id and bumps `credential_version` exactly
67/// once at the end.
68///
69/// This is the only function CLI / desktop should call when persisting a
70/// fresh sign-in. It guarantees the desktop FS watcher sees a fully-formed
71/// credential set on the version bump.
72pub fn install_credentials(p: InstallParams<'_>) -> Result<InstallOutcome, CredentialError> {
73    if p.user_id.is_empty() || p.device_id.is_empty() || p.token.is_empty() {
74        return Err(CredentialError::BadConfig(
75            "user_id, device_id, token are required".into(),
76        ));
77    }
78
79    // Step 1: ensure the user-scoped AES key exists. Generate eagerly when missing.
80    let mut generated_encryption_key = false;
81    let encryption_backend: &'static str = "plaintext";
82    if credstore::read_encryption_key(p.user_id).is_none() {
83        let key = crypto::generate_aes_key();
84        credstore::write_encryption_key(p.user_id, &key)
85            .map_err(|e| CredentialError::Io(format!("encryption key: {}", e)))?;
86        generated_encryption_key = true;
87    }
88
89    // Step 2: ensure a per-device X25519 keypair exists.
90    let mut generated_device_private_key = false;
91    let device_priv_b64: String = if let Some(provided) = p.device_private_key {
92        if !provided.is_empty() {
93            credstore::write_device_privkey(p.user_id, p.device_id, provided)
94                .map_err(|e| CredentialError::Io(format!("device key: {}", e)))?;
95            provided.to_string()
96        } else {
97            install_or_reuse_device_privkey(
98                p.user_id,
99                p.device_id,
100                &mut generated_device_private_key,
101            )?
102        }
103    } else {
104        install_or_reuse_device_privkey(p.user_id, p.device_id, &mut generated_device_private_key)?
105    };
106
107    // Step 3: update config.json — set/replace the active profile and bump
108    // credential_version exactly once.
109    let mut mc = load_multi_config()?;
110    let next_version = mc
111        .relays
112        .iter()
113        .map(|r| r.credential_version)
114        .max()
115        .unwrap_or(0)
116        .checked_add(1)
117        .ok_or_else(|| CredentialError::BadConfig("credential_version overflow".into()))?;
118
119    let machine_id = crate::machine::stable_machine_id();
120
121    if let Some(profile) = mc.active_profile_mut() {
122        profile.token = p.token.to_string();
123        profile.user_id = p.user_id.to_string();
124        profile.device_id = p.device_id.to_string();
125        profile.relay_url = p.relay_url.to_string();
126        profile.hostname = p.hostname.to_string();
127        profile.device_private_key = device_priv_b64;
128        profile.machine_id = machine_id;
129        profile.credential_version = next_version;
130        if !p.email.is_empty() {
131            profile.email = p.email.to_string();
132        }
133        if !p.identity_provider.is_empty() {
134            profile.identity_provider = p.identity_provider.to_string();
135        }
136        if !p.display_name.is_empty() {
137            profile.display_name = p.display_name.to_string();
138        }
139    } else {
140        use ulid::Ulid;
141        let label = url::Url::parse(p.relay_url)
142            .ok()
143            .and_then(|u| u.host_str().map(|h| h.to_string()))
144            .unwrap_or_else(|| p.relay_url.to_string());
145        let profile = RelayProfile {
146            id: Ulid::new().to_string(),
147            label,
148            relay_url: p.relay_url.to_string(),
149            user_id: p.user_id.to_string(),
150            device_id: p.device_id.to_string(),
151            hostname: p.hostname.to_string(),
152            encryption_key: String::new(),
153            device_private_key: device_priv_b64,
154            credential_version: next_version,
155            token: p.token.to_string(),
156            machine_id,
157            email: p.email.to_string(),
158            identity_provider: p.identity_provider.to_string(),
159            display_name: p.display_name.to_string(),
160        };
161        let id = profile.id.clone();
162        mc.relays.push(profile);
163        mc.active_relay_id = Some(id);
164    }
165
166    let active_relay_id = mc.active_relay_id.clone().unwrap_or_default();
167    save_multi_config(&mc)?;
168
169    Ok(InstallOutcome {
170        active_relay_id,
171        credential_version: next_version,
172        encryption_backend,
173        generated_encryption_key,
174        generated_device_private_key,
175    })
176}
177
178/// Error returned when the E2EE key is not available for a user.
179#[derive(Debug, thiserror::Error)]
180pub enum RequireKeyError {
181    #[error("encryption key not found for user")]
182    Missing,
183}
184
185/// E2EE precondition. Returns the user's AES-256 key or a clear error.
186/// Callers map `Missing` to the `ENCRYPTION_REQUIRED` exit code.
187pub fn require_encryption_key(user_id: &str) -> Result<[u8; 32], RequireKeyError> {
188    if user_id.is_empty() {
189        return Err(RequireKeyError::Missing);
190    }
191    credstore::read_encryption_key(user_id).ok_or(RequireKeyError::Missing)
192}
193
194fn install_or_reuse_device_privkey(
195    user_id: &str,
196    device_id: &str,
197    generated_flag: &mut bool,
198) -> Result<String, CredentialError> {
199    // Best-effort: if the active profile already has a non-empty device_private_key
200    // for this same (user_id, device_id), reuse it. Otherwise generate one.
201    let existing = load_multi_config()
202        .ok()
203        .and_then(|mc| mc.active_profile().cloned())
204        .filter(|p| {
205            p.user_id == user_id && p.device_id == device_id && !p.device_private_key.is_empty()
206        })
207        .map(|p| p.device_private_key);
208
209    if let Some(priv_b64) = existing {
210        return Ok(priv_b64);
211    }
212
213    let (priv_b64, _pub_b64) = crypto::generate_device_keypair();
214    credstore::write_device_privkey(user_id, device_id, &priv_b64)
215        .map_err(|e| CredentialError::Io(format!("device key: {}", e)))?;
216    *generated_flag = true;
217    Ok(priv_b64)
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn rejects_empty_required_fields() {
226        let p = InstallParams {
227            user_id: "",
228            device_id: "d1",
229            token: "tok",
230            relay_url: "http://localhost:8080",
231            hostname: "h",
232            device_private_key: None,
233            email: "",
234            identity_provider: "",
235            display_name: "",
236        };
237        assert!(install_credentials(p).is_err());
238    }
239
240    #[test]
241    fn install_writes_display_name_into_profile() {
242        let tmp = tempfile::tempdir().unwrap();
243        std::env::set_var("HOME", tmp.path());
244        let outcome = install_credentials(InstallParams {
245            user_id: "u1",
246            device_id: "d1",
247            token: "tok",
248            relay_url: "https://r",
249            hostname: "host",
250            device_private_key: None,
251            email: "alice@example.com",
252            identity_provider: "github",
253            display_name: "Alice Example",
254        })
255        .expect("install");
256        assert!(!outcome.active_relay_id.is_empty());
257
258        let cfg = crate::auth::load_config().expect("load");
259        assert_eq!(cfg.display_name, "Alice Example");
260    }
261
262    #[test]
263    fn require_encryption_key_errors_when_missing() {
264        let err = require_encryption_key("test-no-key-x7k9q").unwrap_err();
265        assert!(matches!(err, RequireKeyError::Missing));
266    }
267
268    #[test]
269    fn require_encryption_key_errors_on_empty_user_id() {
270        let err = require_encryption_key("").unwrap_err();
271        assert!(matches!(err, RequireKeyError::Missing));
272    }
273}