Skip to main content

client_core/
auth.rs

1#![allow(dead_code)]
2//! Credential storage — `~/.cinch/config.json` (0600 permissions).
3//!
4//! This module is the source of truth for the disk credential format used
5//! by both CLI and desktop. The Go CLI's
6//! `cinch/cmd/internal/credstore/store.go` uses identical service/account
7//! conventions; do not change `SERVICE_NAME` or the account key format
8//! without coordinated updates on both sides.
9
10use std::fs;
11use std::path::PathBuf;
12
13use crate::config::{Config, MultiConfig, RelayProfile};
14
15pub const SERVICE_NAME: &str = "com.cinchcli";
16
17/// Legacy Keychain service name used by builds prior to 2026-04-29. The
18/// credstore reads this as a fallback and migrates entries forward on
19/// first successful read.
20pub const LEGACY_SERVICE_NAME: &str = "com.cinch.app";
21
22#[derive(Debug)]
23pub enum CredentialError {
24    NoEntry,
25    Io(String),
26    BadConfig(String),
27}
28
29impl std::fmt::Display for CredentialError {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            CredentialError::NoEntry => write!(f, "no credential stored"),
33            CredentialError::Io(s) => write!(f, "io: {}", s),
34            CredentialError::BadConfig(s) => write!(f, "bad config: {}", s),
35        }
36    }
37}
38
39fn account_key(user_id: &str, device_id: &str) -> String {
40    format!("{}:{}", user_id, device_id)
41}
42
43fn config_path() -> Result<PathBuf, CredentialError> {
44    let home = dirs::home_dir()
45        .ok_or_else(|| CredentialError::Io("cannot determine home directory".into()))?;
46    Ok(home.join(".cinch").join("config.json"))
47}
48
49pub fn load_multi_config() -> Result<MultiConfig, CredentialError> {
50    let p = config_path()?;
51    if !p.exists() {
52        return Ok(MultiConfig::default());
53    }
54    let data =
55        fs::read_to_string(&p).map_err(|e| CredentialError::Io(format!("read config: {}", e)))?;
56    let v: serde_json::Value = serde_json::from_str(&data)
57        .map_err(|e| CredentialError::BadConfig(format!("parse config: {}", e)))?;
58    if v.get("relays").is_some() {
59        serde_json::from_value(v)
60            .map_err(|e| CredentialError::BadConfig(format!("parse multi_config: {}", e)))
61    } else {
62        let old: Config = serde_json::from_value(v)
63            .map_err(|e| CredentialError::BadConfig(format!("parse legacy config: {}", e)))?;
64        Ok(MultiConfig::from_legacy_pub(old))
65    }
66}
67
68pub fn save_multi_config(mc: &MultiConfig) -> Result<(), CredentialError> {
69    let p = config_path()?;
70    if let Some(dir) = p.parent() {
71        fs::create_dir_all(dir).map_err(|e| CredentialError::Io(format!("mkdir: {}", e)))?;
72    }
73    let data = serde_json::to_string_pretty(mc)
74        .map_err(|e| CredentialError::BadConfig(format!("marshal: {}", e)))?;
75    fs::write(&p, data).map_err(|e| CredentialError::Io(format!("write config: {}", e)))?;
76    #[cfg(unix)]
77    {
78        use std::os::unix::fs::PermissionsExt;
79        let mut perms = fs::metadata(&p)
80            .map_err(|e| CredentialError::Io(format!("stat: {}", e)))?
81            .permissions();
82        perms.set_mode(0o600);
83        fs::set_permissions(&p, perms)
84            .map_err(|e| CredentialError::Io(format!("chmod 0600: {}", e)))?;
85    }
86    Ok(())
87}
88
89pub fn load_config() -> Result<Config, CredentialError> {
90    Ok(load_multi_config()?.to_active_config())
91}
92
93pub fn save_config_to_disk(cfg: &Config) -> Result<(), CredentialError> {
94    let mut mc = load_multi_config()?;
95    if let Some(profile) = mc.active_profile_mut() {
96        profile.token = cfg.token.clone();
97        profile.user_id = cfg.user_id.clone();
98        profile.relay_url = cfg.relay_url.clone();
99        profile.hostname = cfg.hostname.clone();
100        profile.device_id = cfg.active_device_id.clone();
101        profile.credential_version = cfg.credential_version;
102        profile.encryption_key = cfg.encryption_key.clone();
103        profile.device_private_key = cfg.device_private_key.clone();
104        profile.key_pending = cfg.key_pending;
105        if profile.machine_id.is_empty() {
106            profile.machine_id = crate::machine::stable_machine_id();
107        }
108    } else {
109        let profile = RelayProfile::from_config(cfg, None);
110        let id = profile.id.clone();
111        mc.relays.push(profile);
112        mc.active_relay_id = Some(id);
113    }
114    save_multi_config(&mc)
115}
116
117/// Add a new RelayProfile to MultiConfig for a freshly-authenticated relay.
118/// Used by the deep-link callback when PendingRelayAdd is set.
119/// Returns relay_id.
120pub fn add_relay_profile(
121    user_id: &str,
122    device_id: &str,
123    token: &str,
124    relay_url: &str,
125    hostname: &str,
126    label: Option<&str>,
127    device_private_key: &str,
128) -> Result<String, CredentialError> {
129    let mut mc = load_multi_config()?;
130
131    let label_str = label
132        .filter(|s| !s.is_empty())
133        .map(|s| s.to_string())
134        .unwrap_or_else(|| {
135            url::Url::parse(relay_url)
136                .ok()
137                .and_then(|u| u.host_str().map(|h| h.to_string()))
138                .unwrap_or_else(|| relay_url.to_string())
139        });
140
141    let next_version = mc
142        .relays
143        .iter()
144        .map(|r| r.credential_version)
145        .max()
146        .unwrap_or(0)
147        .checked_add(1)
148        .ok_or_else(|| CredentialError::BadConfig("credential_version overflow".into()))?;
149
150    use ulid::Ulid;
151    let relay_id = Ulid::new().to_string();
152    let profile = RelayProfile {
153        id: relay_id.clone(),
154        label: label_str,
155        relay_url: relay_url.to_string(),
156        user_id: user_id.to_string(),
157        device_id: device_id.to_string(),
158        hostname: hostname.to_string(),
159        encryption_key: String::new(),
160        device_private_key: device_private_key.to_string(),
161        credential_version: next_version,
162        token: token.to_string(),
163        machine_id: crate::machine::stable_machine_id(),
164        email: String::new(),
165        identity_provider: String::new(),
166        display_name: String::new(),
167        // The deep-link relay-add path has no master key yet; it will be
168        // populated by the subsequent key-exchange handshake. Mark pending
169        // so push/pull know to auto-retry until the bundle arrives.
170        key_pending: true,
171    };
172    mc.relays.push(profile);
173    if mc.active_relay_id.is_none() {
174        mc.active_relay_id = Some(relay_id.clone());
175    }
176    save_multi_config(&mc)?;
177    Ok(relay_id)
178}
179
180/// Remove credentials for a specific relay from MultiConfig.
181pub fn wipe_relay_credentials(relay_id: &str) -> Result<(), CredentialError> {
182    let mut mc = load_multi_config()?;
183    mc.relays.retain(|r| r.id != relay_id);
184    if mc.active_relay_id.as_deref() == Some(relay_id) {
185        mc.active_relay_id = mc.relays.first().map(|r| r.id.clone());
186    }
187    let new_version = mc
188        .relays
189        .iter()
190        .map(|r| r.credential_version)
191        .max()
192        .unwrap_or(0)
193        .checked_add(1)
194        .ok_or_else(|| CredentialError::BadConfig("credential_version overflow".into()))?;
195    if let Some(p) = mc.active_profile_mut() {
196        p.credential_version = new_version;
197    }
198    save_multi_config(&mc)
199}
200
201/// write_credentials stores token in config.json (0600).
202/// Bumps credential_version and persists via save_config.
203pub fn write_credentials(
204    user_id: &str,
205    device_id: &str,
206    token: &str,
207    relay_url: &str,
208    hostname: &str,
209) -> Result<(), CredentialError> {
210    let mut cfg = load_config()?;
211    cfg.token = token.to_string();
212    cfg.user_id = user_id.to_string();
213    cfg.active_device_id = device_id.to_string();
214    cfg.relay_url = relay_url.to_string();
215    cfg.hostname = hostname.to_string();
216    cfg.credential_version = cfg
217        .credential_version
218        .checked_add(1)
219        .ok_or_else(|| CredentialError::BadConfig("credential_version overflow".into()))?;
220    save_config_to_disk(&cfg)?;
221    Ok(())
222}
223
224/// read_credentials returns the token for the currently-configured (user_id, device_id).
225pub fn read_credentials(cfg: &Config) -> Result<String, CredentialError> {
226    if cfg.user_id.is_empty() || cfg.active_device_id.is_empty() {
227        return Err(CredentialError::NoEntry);
228    }
229    if cfg.token.is_empty() {
230        return Err(CredentialError::NoEntry);
231    }
232    Ok(cfg.token.clone())
233}
234
235/// wipe_credentials clears all credential fields from config, bumps
236/// credential_version, and best-effort deletes any Keychain entries left over
237/// from pre-2026-05-08 CLI builds.
238pub fn wipe_credentials() -> Result<(), CredentialError> {
239    let mut cfg = load_config()?;
240    let user_id = std::mem::take(&mut cfg.user_id);
241    let device_id = std::mem::take(&mut cfg.active_device_id);
242    cfg.token = String::new();
243    cfg.encryption_key = String::new();
244    cfg.device_private_key = String::new();
245    cfg.credential_version = cfg
246        .credential_version
247        .checked_add(1)
248        .ok_or_else(|| CredentialError::BadConfig("credential_version overflow".into()))?;
249    save_config_to_disk(&cfg)?;
250    crate::credstore::wipe_keyring_for(&user_id, &device_id);
251    Ok(())
252}
253
254/// Read the encryption key for a user from config.
255pub fn read_encryption_key(user_id: &str) -> Result<Vec<u8>, CredentialError> {
256    if user_id.is_empty() {
257        return Err(CredentialError::NoEntry);
258    }
259    let cfg = load_config()?;
260    if !cfg.encryption_key.is_empty() {
261        use base64::engine::general_purpose::URL_SAFE_NO_PAD;
262        use base64::Engine;
263        if let Ok(key_bytes) = URL_SAFE_NO_PAD.decode(&cfg.encryption_key) {
264            if key_bytes.len() == 32 {
265                return Ok(key_bytes);
266            }
267        }
268    }
269    Err(CredentialError::NoEntry)
270}
271
272/// Write the encryption key for a user to config.
273pub fn write_encryption_key(user_id: &str, key_bytes: &[u8]) -> Result<(), CredentialError> {
274    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
275    use base64::Engine;
276    let _ = user_id;
277    let key_b64 = URL_SAFE_NO_PAD.encode(key_bytes);
278    let mut cfg = load_config()?;
279    cfg.encryption_key = key_b64;
280    save_config_to_disk(&cfg)?;
281    Ok(())
282}
283
284/// Compose `RestClient::retry_key_bundle` + `poll_key_bundle` so callers in
285/// the CLI (initial `auth login`, `auth retry-key`, and the push/pull
286/// auto-retry path) share a single implementation. Returns `true` if a
287/// bundle arrived and the master key was persisted via
288/// `auth_session::persist_received_master_key`; `false` otherwise.
289///
290/// Exercised end-to-end by the CLI integration tests against wiremock; not
291/// re-tested in cinch-core because it is a pure composition over already
292/// unit-tested functions.
293pub async fn attempt_key_exchange_blocking(
294    client: &crate::http::RestClient,
295    priv_b64: &str,
296    user_id: &str,
297) -> bool {
298    if client.retry_key_bundle().await.is_err() {
299        return false;
300    }
301    poll_key_bundle(client, priv_b64, user_id).await
302}
303
304/// Poll `GET /auth/key-bundle` for up to 30s waiting for a key-bearer
305/// device to publish our encrypted user-key bundle. Returns `true` if
306/// a bundle arrived and the decrypted master key was persisted via
307/// `credstore::write_encryption_key`; returns `false` on timeout or
308/// any decode failure (with a single line printed to stderr per
309/// observed failure mode, mirroring the original CLI behavior).
310///
311/// `priv_b64` is the local device's freshly-generated ephemeral
312/// X25519 private key (matches the public key registered with the
313/// relay during `auth login`); `user_id` scopes the credstore entry.
314pub async fn poll_key_bundle(
315    client: &crate::http::RestClient,
316    priv_b64: &str,
317    user_id: &str,
318) -> bool {
319    use std::time::{Duration, Instant};
320    let deadline = Instant::now() + Duration::from_secs(30);
321    while Instant::now() < deadline {
322        match client.get_key_bundle().await {
323            Ok(bundle) if !bundle.encrypted_bundle.is_empty() => {
324                let aes_key = match crate::crypto::derive_shared_key(
325                    priv_b64,
326                    &bundle.ephemeral_public_key,
327                ) {
328                    Ok(k) => k,
329                    Err(e) => {
330                        eprintln!("  ECDH derive failed: {}", e);
331                        return false;
332                    }
333                };
334                let user_key_bytes =
335                    match crate::crypto::decrypt(&aes_key, &bundle.encrypted_bundle) {
336                        Ok(b) => b,
337                        Err(e) => {
338                            eprintln!("  Bundle decrypt failed: {}", e);
339                            return false;
340                        }
341                    };
342                if user_key_bytes.len() != 32 {
343                    eprintln!("  Unexpected user-key length: {}", user_key_bytes.len());
344                    return false;
345                }
346                let mut key = [0u8; 32];
347                key.copy_from_slice(&user_key_bytes);
348                if let Err(e) = crate::auth_session::persist_received_master_key(user_id, &key)
349                {
350                    eprintln!("  Saving encryption key: {}", e);
351                    return false;
352                }
353                return true;
354            }
355            // 404 means the desktop has not published yet — keep polling.
356            _ => {}
357        }
358        tokio::time::sleep(Duration::from_secs(2)).await;
359    }
360    false
361}
362
363/// rotate_credentials persists a new token after a WS `token_rotated` event.
364pub fn rotate_credentials(
365    user_id: &str,
366    device_id: &str,
367    token: &str,
368    hostname: &str,
369) -> Result<(), CredentialError> {
370    let cfg = load_config()?;
371    write_credentials(user_id, device_id, token, &cfg.relay_url, hostname)
372}
373
374/// stdout marker emitted by `cinch auth login --headless` so the
375/// orchestrating side (e.g. `cinch pair` running over SSH) can pick
376/// up the device-code URL without parsing free-form output.
377///
378/// Format (single line, no trailing whitespace):
379///   <<CINCH-DEVICE-CODE>>{"url":"...","user_code":"..."}<<END>>
380pub const DEVICE_CODE_MARKER_START: &str = "<<CINCH-DEVICE-CODE>>";
381pub const DEVICE_CODE_MARKER_END: &str = "<<END>>";
382
383#[derive(Debug, serde::Serialize, serde::Deserialize)]
384pub struct DeviceCodeMarker {
385    pub url: String,
386    pub user_code: String,
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub approve_command: Option<String>,
389}
390
391pub fn format_device_code_marker(url: &str, user_code: &str) -> String {
392    let payload = serde_json::to_string(&DeviceCodeMarker {
393        url: url.to_string(),
394        user_code: user_code.to_string(),
395        approve_command: Some(format!("cinch auth approve {}", user_code)),
396    })
397    .expect("serialize DeviceCodeMarker");
398    format!(
399        "{}{}{}",
400        DEVICE_CODE_MARKER_START, payload, DEVICE_CODE_MARKER_END
401    )
402}
403
404pub fn parse_device_code_marker(line: &str) -> Option<DeviceCodeMarker> {
405    let start = line.find(DEVICE_CODE_MARKER_START)?;
406    let after_start = start + DEVICE_CODE_MARKER_START.len();
407    let end = line[after_start..].find(DEVICE_CODE_MARKER_END)?;
408    let payload = &line[after_start..after_start + end];
409    serde_json::from_str(payload).ok()
410}
411
412/// stdout marker emitted by the SSH pair script when the remote machine
413/// has either reused an existing matching pairing or completed a fresh
414/// device-code login. The orchestrating desktop uses this marker to
415/// verify that the remote's `user_id` matches the local active profile —
416/// without it, an exit-0 SSH session can falsely look successful when
417/// the remote was already signed in as a different user (or `cinch auth
418/// login` short-circuited before emitting any pairing evidence).
419///
420/// Format (single line, no trailing whitespace):
421///   <<CINCH-PAIRED-OK>>{"user_id":"...","device_id":"...","reused":bool}<<END>>
422pub const PAIRING_COMPLETE_MARKER_START: &str = "<<CINCH-PAIRED-OK>>";
423pub const PAIRING_COMPLETE_MARKER_END: &str = "<<END>>";
424
425#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
426pub struct PairingCompleteMarker {
427    pub user_id: String,
428    pub device_id: String,
429    /// `true` when the remote already had a matching pairing on disk and
430    /// the script skipped device-code; `false` when a fresh login ran.
431    #[serde(default)]
432    pub reused: bool,
433}
434
435pub fn parse_pairing_complete_marker(line: &str) -> Option<PairingCompleteMarker> {
436    let start = line.find(PAIRING_COMPLETE_MARKER_START)?;
437    let after_start = start + PAIRING_COMPLETE_MARKER_START.len();
438    let end = line[after_start..].find(PAIRING_COMPLETE_MARKER_END)?;
439    let payload = &line[after_start..after_start + end];
440    serde_json::from_str(payload).ok()
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn account_key_format() {
449        assert_eq!(account_key("u1", "d1"), "u1:d1");
450    }
451}
452
453#[cfg(test)]
454mod marker_tests {
455    use super::*;
456
457    #[test]
458    fn round_trip() {
459        let s = format_device_code_marker("https://x/y", "AB12");
460        let parsed = parse_device_code_marker(&s).unwrap();
461        assert_eq!(parsed.url, "https://x/y");
462        assert_eq!(parsed.user_code, "AB12");
463        assert_eq!(
464            parsed.approve_command.as_deref(),
465            Some("cinch auth approve AB12")
466        );
467    }
468
469    #[test]
470    fn old_marker_without_approve_command_still_parses() {
471        let s = "<<CINCH-DEVICE-CODE>>{\"url\":\"https://x/y\",\"user_code\":\"AB12\"}<<END>>";
472        let parsed = parse_device_code_marker(s).unwrap();
473        assert_eq!(parsed.url, "https://x/y");
474        assert_eq!(parsed.user_code, "AB12");
475        assert_eq!(parsed.approve_command, None);
476    }
477
478    #[test]
479    fn no_marker_returns_none() {
480        assert!(parse_device_code_marker("just some log line").is_none());
481    }
482
483    #[test]
484    fn truncated_marker_returns_none() {
485        assert!(
486            parse_device_code_marker("<<CINCH-DEVICE-CODE>>{\"url\":\"x\",\"user_code\":")
487                .is_none()
488        );
489    }
490
491    #[test]
492    fn pairing_complete_marker_round_trip() {
493        let s =
494            "<<CINCH-PAIRED-OK>>{\"user_id\":\"u1\",\"device_id\":\"d1\",\"reused\":true}<<END>>";
495        let parsed = parse_pairing_complete_marker(s).unwrap();
496        assert_eq!(parsed.user_id, "u1");
497        assert_eq!(parsed.device_id, "d1");
498        assert!(parsed.reused);
499    }
500
501    #[test]
502    fn pairing_complete_marker_defaults_reused_false() {
503        let s = "<<CINCH-PAIRED-OK>>{\"user_id\":\"u1\",\"device_id\":\"d1\"}<<END>>";
504        let parsed = parse_pairing_complete_marker(s).unwrap();
505        assert!(!parsed.reused);
506    }
507
508    #[test]
509    fn pairing_complete_marker_rejects_garbage() {
510        assert!(parse_pairing_complete_marker("just a log line").is_none());
511        assert!(parse_pairing_complete_marker("<<CINCH-PAIRED-OK>>not json<<END>>").is_none());
512    }
513}