1use crate::auth::{load_multi_config, save_multi_config, CredentialError};
22use crate::config::RelayProfile;
23use crate::credstore;
24use crate::crypto;
25
26pub 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 pub device_private_key: Option<&'a str>,
38 pub email: &'a str,
40 pub identity_provider: &'a str,
42 pub display_name: &'a str,
45}
46
47#[derive(Debug, Clone)]
51pub struct InstallOutcome {
52 pub active_relay_id: String,
54 pub credential_version: u64,
56 pub encryption_backend: &'static str,
58 pub generated_encryption_key: bool,
64 pub generated_device_private_key: bool,
66 pub key_pending: bool,
71}
72
73pub 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 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 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 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#[derive(Debug, thiserror::Error)]
190pub enum RequireKeyError {
191 #[error("encryption key not found for user")]
195 Missing,
196 #[error("encryption key pending — waiting for paired device to share")]
200 PendingExchange,
201}
202
203pub 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
223pub 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 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 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 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 #[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 #[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 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 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 #[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 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 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}