Skip to main content

client_core/
credstore.rs

1//! Credential store abstraction — plaintext config.json as canonical store.
2//!
3//! `~/.cinch/config.json` (mode 0600) is the single credential store on every
4//! platform. Service name and account formats remain lock-step with the Go CLI's
5//! `cinch/cmd/internal/credstore/store.go` for the Keychain migration window:
6//!
7//!   service = "com.cinchcli"
8//!   account = "<user_id>:<device_id>"             // auth token
9//!   account = "encryption:<user_id>"              // 32-byte AES key (base64url)
10//!   account = "device-privkey:<user_id>:<device_id>"  // X25519 private key (base64url)
11//!
12//! **Migration.** CLI builds prior to 2026-05-08 wrote credentials to the OS
13//! Keychain (service `com.cinchcli`, legacy `com.cinch.app`). The `read_*`
14//! helpers transparently fall back to both Keychain services on a plaintext
15//! miss and copy the value forward to config.json on first success. No writes
16//! go to the Keychain any longer.
17
18use crate::auth::{LEGACY_SERVICE_NAME, SERVICE_NAME};
19
20pub fn account_key(user_id: &str, device_id: &str) -> String {
21    format!("{}:{}", user_id, device_id)
22}
23
24pub fn encryption_account_key(user_id: &str) -> String {
25    format!("encryption:{}", user_id)
26}
27
28pub fn device_privkey_account_key(user_id: &str, device_id: &str) -> String {
29    format!("device-privkey:{}:{}", user_id, device_id)
30}
31
32#[derive(Debug, thiserror::Error)]
33pub enum CredstoreError {
34    #[error("no entry")]
35    NoEntry,
36    #[error("backend: {0}")]
37    Backend(String),
38}
39
40pub trait Credstore: Send + Sync {
41    fn get(&self, account: &str) -> Result<Option<String>, CredstoreError>;
42    fn set(&self, account: &str, value: &str) -> Result<(), CredstoreError>;
43    fn delete(&self, account: &str) -> Result<(), CredstoreError>;
44    fn backend_name(&self) -> &'static str;
45}
46
47/// macOS Keychain / Linux Secret Service / Windows Credential Manager backend
48/// pinned to a specific service name. Used only for one-time migration reads
49/// from pre-2026-05-08 CLI builds. No new writes go here.
50pub struct KeyringStore {
51    service: &'static str,
52}
53
54impl KeyringStore {
55    pub fn canonical() -> Self {
56        Self {
57            service: SERVICE_NAME,
58        }
59    }
60
61    pub fn legacy() -> Self {
62        Self {
63            service: LEGACY_SERVICE_NAME,
64        }
65    }
66}
67
68/// Prefix that `zalando/go-keyring` adds to values it writes on macOS so
69/// arbitrary bytes survive the Keychain string interface. The Rust
70/// `keyring` crate does not use this wrapper, so we transparently
71/// unwrap on read to stay byte-compatible with any Go CLI entries still
72/// sitting in the Keychain during the migration window.
73const GO_KEYRING_BASE64_PREFIX: &str = "go-keyring-base64:";
74
75fn unwrap_go_keyring(value: String) -> String {
76    use base64::engine::general_purpose::STANDARD;
77    use base64::Engine;
78    if let Some(rest) = value.strip_prefix(GO_KEYRING_BASE64_PREFIX) {
79        if let Ok(bytes) = STANDARD.decode(rest) {
80            if let Ok(s) = String::from_utf8(bytes) {
81                return s;
82            }
83        }
84    }
85    value
86}
87
88impl Credstore for KeyringStore {
89    fn get(&self, account: &str) -> Result<Option<String>, CredstoreError> {
90        let entry = keyring::Entry::new(self.service, account)
91            .map_err(|e| CredstoreError::Backend(e.to_string()))?;
92        match entry.get_password() {
93            Ok(v) => Ok(Some(unwrap_go_keyring(v))),
94            Err(keyring::Error::NoEntry) => Ok(None),
95            Err(e) => Err(CredstoreError::Backend(e.to_string())),
96        }
97    }
98
99    fn set(&self, account: &str, value: &str) -> Result<(), CredstoreError> {
100        let entry = keyring::Entry::new(self.service, account)
101            .map_err(|e| CredstoreError::Backend(e.to_string()))?;
102        entry
103            .set_password(value)
104            .map_err(|e| CredstoreError::Backend(e.to_string()))
105    }
106
107    fn delete(&self, account: &str) -> Result<(), CredstoreError> {
108        let entry = keyring::Entry::new(self.service, account)
109            .map_err(|e| CredstoreError::Backend(e.to_string()))?;
110        match entry.delete_credential() {
111            Ok(()) => Ok(()),
112            Err(keyring::Error::NoEntry) => Ok(()),
113            Err(e) => Err(CredstoreError::Backend(e.to_string())),
114        }
115    }
116
117    fn backend_name(&self) -> &'static str {
118        if self.service == LEGACY_SERVICE_NAME {
119            "keyring-legacy"
120        } else {
121            "keyring"
122        }
123    }
124}
125
126/// Plaintext store — reads/writes via `client_core::auth` config helpers.
127/// Only token, encryption_key, device_private_key are persisted; other
128/// account names return None.
129pub struct PlaintextStore;
130
131impl Credstore for PlaintextStore {
132    fn get(&self, account: &str) -> Result<Option<String>, CredstoreError> {
133        let cfg = crate::auth::load_config().map_err(|e| CredstoreError::Backend(e.to_string()))?;
134        if account.starts_with("encryption:") {
135            let expected = encryption_account_key(&cfg.user_id);
136            if account == expected {
137                return Ok(non_empty(cfg.encryption_key));
138            }
139            return Ok(None);
140        }
141        if account.starts_with("device-privkey:") {
142            let expected = device_privkey_account_key(&cfg.user_id, &cfg.active_device_id);
143            if account == expected {
144                return Ok(non_empty(cfg.device_private_key));
145            }
146            return Ok(None);
147        }
148        let expected = account_key(&cfg.user_id, &cfg.active_device_id);
149        if account == expected {
150            return Ok(non_empty(cfg.token));
151        }
152        Ok(None)
153    }
154
155    fn set(&self, _account: &str, _value: &str) -> Result<(), CredstoreError> {
156        Err(CredstoreError::Backend(
157            "plaintext credstore writes go through client_core::auth helpers".into(),
158        ))
159    }
160
161    fn delete(&self, _account: &str) -> Result<(), CredstoreError> {
162        Err(CredstoreError::Backend(
163            "plaintext credstore deletes go through client_core::auth helpers".into(),
164        ))
165    }
166
167    fn backend_name(&self) -> &'static str {
168        "plaintext"
169    }
170}
171
172fn non_empty(s: String) -> Option<String> {
173    if s.is_empty() {
174        None
175    } else {
176        Some(s)
177    }
178}
179
180/// Returns the canonical credential store. Always plaintext (config.json,
181/// mode 0600). Keychain entries from prior CLI builds are read by the
182/// `read_*` migration helpers below, never by this function.
183pub fn detect() -> Box<dyn Credstore> {
184    Box::new(PlaintextStore)
185}
186
187/// Read `account` from `plaintext`; on miss, try each `fallback` store in
188/// order. On a fallback hit, call `plaintext_writer` to copy the value
189/// forward. Returns the value or `None` if all stores miss.
190fn get_with_migration_via(
191    plaintext: &dyn Credstore,
192    fallbacks: &[&dyn Credstore],
193    plaintext_writer: impl FnOnce(&str) -> Result<(), CredstoreError>,
194    account: &str,
195) -> Option<String> {
196    if let Ok(Some(value)) = plaintext.get(account) {
197        return Some(value);
198    }
199    for fb in fallbacks {
200        if let Ok(Some(value)) = fb.get(account) {
201            // Best-effort copy-forward. Failure is non-fatal — subsequent
202            // reads hit Keychain again until plaintext succeeds.
203            let _ = plaintext_writer(&value);
204            return Some(value);
205        }
206    }
207    None
208}
209
210/// Read `account` from plaintext first, falling back to Keychain (canonical
211/// then legacy services) for users upgrading from the Keychain-era CLI.
212/// On a Keychain hit the value is copied forward to config.json.
213fn get_with_keyring_migration(
214    plaintext_writer: impl FnOnce(&str) -> Result<(), CredstoreError>,
215    account: &str,
216) -> Option<String> {
217    let canonical = KeyringStore::canonical();
218    let legacy = KeyringStore::legacy();
219    get_with_migration_via(
220        &PlaintextStore,
221        &[&canonical as &dyn Credstore, &legacy as &dyn Credstore],
222        plaintext_writer,
223        account,
224    )
225}
226
227/// Read the encryption key for `user_id`. Returns the 32-byte AES key or `None`.
228pub fn read_encryption_key(user_id: &str) -> Option<[u8; 32]> {
229    if user_id.is_empty() {
230        return None;
231    }
232    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
233    use base64::Engine;
234    let acct = encryption_account_key(user_id);
235    let user_id_owned = user_id.to_string();
236    let copy_forward = move |value: &str| -> Result<(), CredstoreError> {
237        let bytes = URL_SAFE_NO_PAD
238            .decode(value)
239            .map_err(|e| CredstoreError::Backend(e.to_string()))?;
240        if bytes.len() != 32 {
241            return Err(CredstoreError::Backend("not 32 bytes".into()));
242        }
243        let mut key = [0u8; 32];
244        key.copy_from_slice(&bytes);
245        crate::auth::write_encryption_key(&user_id_owned, &key)
246            .map_err(|e| CredstoreError::Backend(e.to_string()))
247    };
248    let b64 = get_with_keyring_migration(copy_forward, &acct)?;
249    let bytes = URL_SAFE_NO_PAD.decode(&b64).ok()?;
250    if bytes.len() != 32 {
251        return None;
252    }
253    let mut key = [0u8; 32];
254    key.copy_from_slice(&bytes);
255    Some(key)
256}
257
258/// Persist a 32-byte AES encryption key for `user_id` to config.json.
259/// Always returns `"plaintext"`.
260pub fn write_encryption_key(user_id: &str, key: &[u8; 32]) -> Result<&'static str, CredstoreError> {
261    crate::auth::write_encryption_key(user_id, key)
262        .map_err(|e| CredstoreError::Backend(e.to_string()))?;
263    Ok("plaintext")
264}
265
266/// Read the base64url-encoded X25519 private key for `(user_id, device_id)`.
267/// Returns `None` when the key has not yet been written for this pair.
268pub fn read_device_privkey(user_id: &str, device_id: &str) -> Option<String> {
269    if user_id.is_empty() || device_id.is_empty() {
270        return None;
271    }
272    let acct = device_privkey_account_key(user_id, device_id);
273    let copy_forward = |value: &str| -> Result<(), CredstoreError> {
274        let mut cfg =
275            crate::auth::load_config().map_err(|e| CredstoreError::Backend(e.to_string()))?;
276        cfg.device_private_key = value.to_string();
277        crate::auth::save_config_to_disk(&cfg).map_err(|e| CredstoreError::Backend(e.to_string()))
278    };
279    let value = get_with_keyring_migration(copy_forward, &acct)?;
280    if value.is_empty() {
281        None
282    } else {
283        Some(value)
284    }
285}
286
287/// Persist a base64url-encoded X25519 private key for `(user_id, device_id)`
288/// to config.json. Always returns `"plaintext"`.
289pub fn write_device_privkey(
290    user_id: &str,
291    device_id: &str,
292    privkey_b64: &str,
293) -> Result<&'static str, CredstoreError> {
294    let _ = (user_id, device_id);
295    let mut cfg = crate::auth::load_config().map_err(|e| CredstoreError::Backend(e.to_string()))?;
296    cfg.device_private_key = privkey_b64.to_string();
297    crate::auth::save_config_to_disk(&cfg).map_err(|e| CredstoreError::Backend(e.to_string()))?;
298    Ok("plaintext")
299}
300
301/// Read the auth token for the active (user, device) pair.
302pub fn read_token(user_id: &str, device_id: &str) -> Option<String> {
303    if user_id.is_empty() || device_id.is_empty() {
304        return None;
305    }
306    let acct = account_key(user_id, device_id);
307    // Token write-forward writes only cfg.token — we don't want to bump
308    // credential_version during a passive read.
309    let copy_forward = |value: &str| -> Result<(), CredstoreError> {
310        let mut cfg =
311            crate::auth::load_config().map_err(|e| CredstoreError::Backend(e.to_string()))?;
312        cfg.token = value.to_string();
313        crate::auth::save_config_to_disk(&cfg).map_err(|e| CredstoreError::Backend(e.to_string()))
314    };
315    get_with_keyring_migration(copy_forward, &acct).filter(|t| !t.is_empty())
316}
317
318/// Best-effort delete of all Keychain entries this user/device might have
319/// from prior Keychain-era CLI builds. Errors are swallowed: the goal is
320/// hygiene, not correctness — config.json is the source of truth.
321pub fn wipe_keyring_for(user_id: &str, device_id: &str) {
322    if user_id.is_empty() {
323        return;
324    }
325    let mut accounts = vec![encryption_account_key(user_id)];
326    if !device_id.is_empty() {
327        accounts.push(account_key(user_id, device_id));
328        accounts.push(device_privkey_account_key(user_id, device_id));
329    }
330    for service in [KeyringStore::canonical(), KeyringStore::legacy()] {
331        for acct in &accounts {
332            let _ = service.delete(acct);
333        }
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn account_keys_match_go_format() {
343        assert_eq!(account_key("u1", "d1"), "u1:d1");
344        assert_eq!(encryption_account_key("u1"), "encryption:u1");
345        assert_eq!(
346            device_privkey_account_key("u1", "d1"),
347            "device-privkey:u1:d1"
348        );
349    }
350
351    #[test]
352    fn go_keyring_unwrap_roundtrips() {
353        use base64::engine::general_purpose::STANDARD;
354        use base64::Engine;
355        let raw = "abcXYZ_-=";
356        let wrapped = format!("{}{}", GO_KEYRING_BASE64_PREFIX, STANDARD.encode(raw));
357        assert!(wrapped.starts_with(GO_KEYRING_BASE64_PREFIX));
358        assert_eq!(unwrap_go_keyring(wrapped), raw);
359    }
360
361    #[test]
362    fn go_keyring_unwrap_passthrough_for_unwrapped_values() {
363        let raw = "plain-string-without-prefix".to_string();
364        assert_eq!(unwrap_go_keyring(raw.clone()), raw);
365    }
366
367    // --- migration shape tests (in-memory fakes; no real Keychain access) ---
368
369    struct InMemoryStore {
370        map: std::sync::Mutex<std::collections::HashMap<String, String>>,
371        name: &'static str,
372    }
373    impl InMemoryStore {
374        fn new(name: &'static str) -> Self {
375            Self {
376                map: Default::default(),
377                name,
378            }
379        }
380        fn seed(&self, k: &str, v: &str) {
381            self.map.lock().unwrap().insert(k.into(), v.into());
382        }
383    }
384    impl Credstore for InMemoryStore {
385        fn get(&self, account: &str) -> Result<Option<String>, CredstoreError> {
386            Ok(self.map.lock().unwrap().get(account).cloned())
387        }
388        fn set(&self, account: &str, value: &str) -> Result<(), CredstoreError> {
389            self.map
390                .lock()
391                .unwrap()
392                .insert(account.into(), value.into());
393            Ok(())
394        }
395        fn delete(&self, account: &str) -> Result<(), CredstoreError> {
396            self.map.lock().unwrap().remove(account);
397            Ok(())
398        }
399        fn backend_name(&self) -> &'static str {
400            self.name
401        }
402    }
403
404    #[test]
405    fn migration_reads_from_canonical_and_copies_forward() {
406        let plaintext = InMemoryStore::new("plaintext");
407        let canonical = InMemoryStore::new("canonical");
408        canonical.seed("encryption:u1", "AAAA");
409
410        let mut copied = None;
411        let writer = |v: &str| -> Result<(), CredstoreError> {
412            copied = Some(v.to_string());
413            Ok(())
414        };
415        let v = get_with_migration_via(
416            &plaintext,
417            &[&canonical as &dyn Credstore],
418            writer,
419            "encryption:u1",
420        );
421        assert_eq!(v.as_deref(), Some("AAAA"));
422        assert_eq!(
423            copied.as_deref(),
424            Some("AAAA"),
425            "must copy forward to plaintext"
426        );
427    }
428
429    #[test]
430    fn migration_falls_through_canonical_to_legacy() {
431        let plaintext = InMemoryStore::new("plaintext");
432        let canonical = InMemoryStore::new("canonical");
433        let legacy = InMemoryStore::new("legacy");
434        legacy.seed("encryption:u1", "BBBB");
435
436        let writer = |_: &str| -> Result<(), CredstoreError> { Ok(()) };
437        let v = get_with_migration_via(
438            &plaintext,
439            &[&canonical as &dyn Credstore, &legacy as &dyn Credstore],
440            writer,
441            "encryption:u1",
442        );
443        assert_eq!(v.as_deref(), Some("BBBB"));
444    }
445
446    #[test]
447    fn migration_skips_writer_when_plaintext_already_has_value() {
448        let plaintext = InMemoryStore::new("plaintext");
449        plaintext.seed("encryption:u1", "EXISTING");
450        let canonical = InMemoryStore::new("canonical");
451        canonical.seed("encryption:u1", "STALE");
452
453        let mut writer_called = false;
454        let writer = |_: &str| -> Result<(), CredstoreError> {
455            writer_called = true;
456            Ok(())
457        };
458        let v = get_with_migration_via(
459            &plaintext,
460            &[&canonical as &dyn Credstore],
461            writer,
462            "encryption:u1",
463        );
464        assert_eq!(v.as_deref(), Some("EXISTING"));
465        assert!(
466            !writer_called,
467            "must not overwrite plaintext when it already has the value"
468        );
469    }
470}