1#![allow(dead_code)]
2use std::fs;
11use std::path::PathBuf;
12
13use crate::config::{Config, MultiConfig, RelayProfile};
14
15pub const SERVICE_NAME: &str = "com.cinchcli";
16
17pub 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 if profile.machine_id.is_empty() {
105 profile.machine_id = crate::machine::stable_machine_id();
106 }
107 } else {
108 let profile = RelayProfile::from_config(cfg, None);
109 let id = profile.id.clone();
110 mc.relays.push(profile);
111 mc.active_relay_id = Some(id);
112 }
113 save_multi_config(&mc)
114}
115
116pub fn add_relay_profile(
120 user_id: &str,
121 device_id: &str,
122 token: &str,
123 relay_url: &str,
124 hostname: &str,
125 label: Option<&str>,
126 device_private_key: &str,
127) -> Result<String, CredentialError> {
128 let mut mc = load_multi_config()?;
129
130 let label_str = label
131 .filter(|s| !s.is_empty())
132 .map(|s| s.to_string())
133 .unwrap_or_else(|| {
134 url::Url::parse(relay_url)
135 .ok()
136 .and_then(|u| u.host_str().map(|h| h.to_string()))
137 .unwrap_or_else(|| relay_url.to_string())
138 });
139
140 let next_version = mc
141 .relays
142 .iter()
143 .map(|r| r.credential_version)
144 .max()
145 .unwrap_or(0)
146 .checked_add(1)
147 .ok_or_else(|| CredentialError::BadConfig("credential_version overflow".into()))?;
148
149 use ulid::Ulid;
150 let relay_id = Ulid::new().to_string();
151 let profile = RelayProfile {
152 id: relay_id.clone(),
153 label: label_str,
154 relay_url: relay_url.to_string(),
155 user_id: user_id.to_string(),
156 device_id: device_id.to_string(),
157 hostname: hostname.to_string(),
158 encryption_key: String::new(),
159 device_private_key: device_private_key.to_string(),
160 credential_version: next_version,
161 token: token.to_string(),
162 machine_id: crate::machine::stable_machine_id(),
163 email: String::new(),
164 identity_provider: String::new(),
165 display_name: String::new(),
166 };
167 mc.relays.push(profile);
168 if mc.active_relay_id.is_none() {
169 mc.active_relay_id = Some(relay_id.clone());
170 }
171 save_multi_config(&mc)?;
172 Ok(relay_id)
173}
174
175pub fn wipe_relay_credentials(relay_id: &str) -> Result<(), CredentialError> {
177 let mut mc = load_multi_config()?;
178 mc.relays.retain(|r| r.id != relay_id);
179 if mc.active_relay_id.as_deref() == Some(relay_id) {
180 mc.active_relay_id = mc.relays.first().map(|r| r.id.clone());
181 }
182 let new_version = mc
183 .relays
184 .iter()
185 .map(|r| r.credential_version)
186 .max()
187 .unwrap_or(0)
188 .checked_add(1)
189 .ok_or_else(|| CredentialError::BadConfig("credential_version overflow".into()))?;
190 if let Some(p) = mc.active_profile_mut() {
191 p.credential_version = new_version;
192 }
193 save_multi_config(&mc)
194}
195
196pub fn write_credentials(
199 user_id: &str,
200 device_id: &str,
201 token: &str,
202 relay_url: &str,
203 hostname: &str,
204) -> Result<(), CredentialError> {
205 let mut cfg = load_config()?;
206 cfg.token = token.to_string();
207 cfg.user_id = user_id.to_string();
208 cfg.active_device_id = device_id.to_string();
209 cfg.relay_url = relay_url.to_string();
210 cfg.hostname = hostname.to_string();
211 cfg.credential_version = cfg
212 .credential_version
213 .checked_add(1)
214 .ok_or_else(|| CredentialError::BadConfig("credential_version overflow".into()))?;
215 save_config_to_disk(&cfg)?;
216 Ok(())
217}
218
219pub fn read_credentials(cfg: &Config) -> Result<String, CredentialError> {
221 if cfg.user_id.is_empty() || cfg.active_device_id.is_empty() {
222 return Err(CredentialError::NoEntry);
223 }
224 if cfg.token.is_empty() {
225 return Err(CredentialError::NoEntry);
226 }
227 Ok(cfg.token.clone())
228}
229
230pub fn wipe_credentials() -> Result<(), CredentialError> {
234 let mut cfg = load_config()?;
235 let user_id = std::mem::take(&mut cfg.user_id);
236 let device_id = std::mem::take(&mut cfg.active_device_id);
237 cfg.token = String::new();
238 cfg.encryption_key = String::new();
239 cfg.device_private_key = String::new();
240 cfg.credential_version = cfg
241 .credential_version
242 .checked_add(1)
243 .ok_or_else(|| CredentialError::BadConfig("credential_version overflow".into()))?;
244 save_config_to_disk(&cfg)?;
245 crate::credstore::wipe_keyring_for(&user_id, &device_id);
246 Ok(())
247}
248
249pub fn read_encryption_key(user_id: &str) -> Result<Vec<u8>, CredentialError> {
251 if user_id.is_empty() {
252 return Err(CredentialError::NoEntry);
253 }
254 let cfg = load_config()?;
255 if !cfg.encryption_key.is_empty() {
256 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
257 use base64::Engine;
258 if let Ok(key_bytes) = URL_SAFE_NO_PAD.decode(&cfg.encryption_key) {
259 if key_bytes.len() == 32 {
260 return Ok(key_bytes);
261 }
262 }
263 }
264 Err(CredentialError::NoEntry)
265}
266
267pub fn write_encryption_key(user_id: &str, key_bytes: &[u8]) -> Result<(), CredentialError> {
269 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
270 use base64::Engine;
271 let _ = user_id;
272 let key_b64 = URL_SAFE_NO_PAD.encode(key_bytes);
273 let mut cfg = load_config()?;
274 cfg.encryption_key = key_b64;
275 save_config_to_disk(&cfg)?;
276 Ok(())
277}
278
279pub async fn poll_key_bundle(
290 client: &crate::http::RestClient,
291 priv_b64: &str,
292 user_id: &str,
293) -> bool {
294 use std::time::{Duration, Instant};
295 let deadline = Instant::now() + Duration::from_secs(30);
296 while Instant::now() < deadline {
297 match client.get_key_bundle().await {
298 Ok(bundle) if !bundle.encrypted_bundle.is_empty() => {
299 let aes_key = match crate::crypto::derive_shared_key(
300 priv_b64,
301 &bundle.ephemeral_public_key,
302 ) {
303 Ok(k) => k,
304 Err(e) => {
305 eprintln!(" ECDH derive failed: {}", e);
306 return false;
307 }
308 };
309 let user_key_bytes =
310 match crate::crypto::decrypt(&aes_key, &bundle.encrypted_bundle) {
311 Ok(b) => b,
312 Err(e) => {
313 eprintln!(" Bundle decrypt failed: {}", e);
314 return false;
315 }
316 };
317 if user_key_bytes.len() != 32 {
318 eprintln!(" Unexpected user-key length: {}", user_key_bytes.len());
319 return false;
320 }
321 let mut key = [0u8; 32];
322 key.copy_from_slice(&user_key_bytes);
323 if let Err(e) = crate::credstore::write_encryption_key(user_id, &key) {
324 eprintln!(" Saving encryption key: {}", e);
325 return false;
326 }
327 return true;
328 }
329 _ => {}
331 }
332 tokio::time::sleep(Duration::from_secs(2)).await;
333 }
334 false
335}
336
337pub fn rotate_credentials(
339 user_id: &str,
340 device_id: &str,
341 token: &str,
342 hostname: &str,
343) -> Result<(), CredentialError> {
344 let cfg = load_config()?;
345 write_credentials(user_id, device_id, token, &cfg.relay_url, hostname)
346}
347
348pub const DEVICE_CODE_MARKER_START: &str = "<<CINCH-DEVICE-CODE>>";
355pub const DEVICE_CODE_MARKER_END: &str = "<<END>>";
356
357#[derive(Debug, serde::Serialize, serde::Deserialize)]
358pub struct DeviceCodeMarker {
359 pub url: String,
360 pub user_code: String,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub approve_command: Option<String>,
363}
364
365pub fn format_device_code_marker(url: &str, user_code: &str) -> String {
366 let payload = serde_json::to_string(&DeviceCodeMarker {
367 url: url.to_string(),
368 user_code: user_code.to_string(),
369 approve_command: Some(format!("cinch auth approve {}", user_code)),
370 })
371 .expect("serialize DeviceCodeMarker");
372 format!(
373 "{}{}{}",
374 DEVICE_CODE_MARKER_START, payload, DEVICE_CODE_MARKER_END
375 )
376}
377
378pub fn parse_device_code_marker(line: &str) -> Option<DeviceCodeMarker> {
379 let start = line.find(DEVICE_CODE_MARKER_START)?;
380 let after_start = start + DEVICE_CODE_MARKER_START.len();
381 let end = line[after_start..].find(DEVICE_CODE_MARKER_END)?;
382 let payload = &line[after_start..after_start + end];
383 serde_json::from_str(payload).ok()
384}
385
386pub const PAIRING_COMPLETE_MARKER_START: &str = "<<CINCH-PAIRED-OK>>";
397pub const PAIRING_COMPLETE_MARKER_END: &str = "<<END>>";
398
399#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
400pub struct PairingCompleteMarker {
401 pub user_id: String,
402 pub device_id: String,
403 #[serde(default)]
406 pub reused: bool,
407}
408
409pub fn parse_pairing_complete_marker(line: &str) -> Option<PairingCompleteMarker> {
410 let start = line.find(PAIRING_COMPLETE_MARKER_START)?;
411 let after_start = start + PAIRING_COMPLETE_MARKER_START.len();
412 let end = line[after_start..].find(PAIRING_COMPLETE_MARKER_END)?;
413 let payload = &line[after_start..after_start + end];
414 serde_json::from_str(payload).ok()
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn account_key_format() {
423 assert_eq!(account_key("u1", "d1"), "u1:d1");
424 }
425}
426
427#[cfg(test)]
428mod marker_tests {
429 use super::*;
430
431 #[test]
432 fn round_trip() {
433 let s = format_device_code_marker("https://x/y", "AB12");
434 let parsed = parse_device_code_marker(&s).unwrap();
435 assert_eq!(parsed.url, "https://x/y");
436 assert_eq!(parsed.user_code, "AB12");
437 assert_eq!(
438 parsed.approve_command.as_deref(),
439 Some("cinch auth approve AB12")
440 );
441 }
442
443 #[test]
444 fn old_marker_without_approve_command_still_parses() {
445 let s = "<<CINCH-DEVICE-CODE>>{\"url\":\"https://x/y\",\"user_code\":\"AB12\"}<<END>>";
446 let parsed = parse_device_code_marker(s).unwrap();
447 assert_eq!(parsed.url, "https://x/y");
448 assert_eq!(parsed.user_code, "AB12");
449 assert_eq!(parsed.approve_command, None);
450 }
451
452 #[test]
453 fn no_marker_returns_none() {
454 assert!(parse_device_code_marker("just some log line").is_none());
455 }
456
457 #[test]
458 fn truncated_marker_returns_none() {
459 assert!(
460 parse_device_code_marker("<<CINCH-DEVICE-CODE>>{\"url\":\"x\",\"user_code\":")
461 .is_none()
462 );
463 }
464
465 #[test]
466 fn pairing_complete_marker_round_trip() {
467 let s =
468 "<<CINCH-PAIRED-OK>>{\"user_id\":\"u1\",\"device_id\":\"d1\",\"reused\":true}<<END>>";
469 let parsed = parse_pairing_complete_marker(s).unwrap();
470 assert_eq!(parsed.user_id, "u1");
471 assert_eq!(parsed.device_id, "d1");
472 assert!(parsed.reused);
473 }
474
475 #[test]
476 fn pairing_complete_marker_defaults_reused_false() {
477 let s = "<<CINCH-PAIRED-OK>>{\"user_id\":\"u1\",\"device_id\":\"d1\"}<<END>>";
478 let parsed = parse_pairing_complete_marker(s).unwrap();
479 assert!(!parsed.reused);
480 }
481
482 #[test]
483 fn pairing_complete_marker_rejects_garbage() {
484 assert!(parse_pairing_complete_marker("just a log line").is_none());
485 assert!(parse_pairing_complete_marker("<<CINCH-PAIRED-OK>>not json<<END>>").is_none());
486 }
487}