1use anyhow::Context;
4use core_types::{EventKind, ProfileId, SecurityLevel, SensitiveBytes, TrustProfileName};
5use owo_colors::OwoColorize;
6use zeroize::Zeroize;
7
8fn keybinding_applicable(no_keybinding: bool) -> bool {
10 if no_keybinding {
11 return false;
12 }
13 #[cfg(target_os = "linux")]
14 {
15 std::env::var("XDG_CURRENT_DESKTOP")
16 .map(|d| d.contains("COSMIC"))
17 .unwrap_or(false)
18 }
19 #[cfg(not(target_os = "linux"))]
20 {
21 false
22 }
23}
24
25fn step_header(step: u32, total: u32, label: &str) {
26 println!(
27 "\n {} {}",
28 format!("[{step}/{total}]").bold(),
29 label.bold(),
30 );
31}
32
33fn step_done(msg: &str) {
34 println!(" {msg} ... {}", "done".green());
35}
36
37fn step_skip(msg: &str) {
38 println!(" {msg} ... {}", "(already done)".dimmed());
39}
40
41fn parse_auth_policy(s: &str) -> anyhow::Result<core_types::AuthCombineMode> {
42 match s.to_lowercase().as_str() {
43 "any" => Ok(core_types::AuthCombineMode::Any),
44 "all" => Ok(core_types::AuthCombineMode::All),
45 _ => anyhow::bail!(
46 "unknown auth policy '{s}'. Valid values: \"any\" (either factor unlocks), \
47 \"all\" (every enrolled factor required)"
48 ),
49 }
50}
51
52pub(crate) async fn resolve_ssh_key(raw: &str) -> anyhow::Result<String> {
59 if raw.is_empty() {
61 return select_ssh_key_interactive().await;
62 }
63
64 if raw.starts_with("SHA256:") {
66 return Ok(raw.to_string());
67 }
68
69 let expanded = if let Some(rest) = raw.strip_prefix("~/") {
71 if let Some(home) = dirs::home_dir() {
72 let candidate = home.join(rest);
73 let canonical = candidate.canonicalize().unwrap_or(candidate.clone());
76 if !canonical.starts_with(&home) {
77 anyhow::bail!(
78 "SSH key path '{}' resolves to '{}' which is outside your home directory.\n\
79 Only paths within $HOME are accepted for security.",
80 raw,
81 canonical.display()
82 );
83 }
84 canonical
85 } else {
86 std::path::PathBuf::from(raw)
87 }
88 } else {
89 std::path::PathBuf::from(raw)
90 };
91
92 if expanded.exists() {
93 let meta = std::fs::metadata(&expanded).with_context(|| {
96 format!("failed to stat SSH public key file: {}", expanded.display())
97 })?;
98 if meta.len() > 64 * 1024 {
99 anyhow::bail!(
100 "SSH public key file is too large ({} bytes). \
101 Public keys should be under 64 KB.",
102 meta.len()
103 );
104 }
105 let contents = std::fs::read_to_string(&expanded).with_context(|| {
106 format!("failed to read SSH public key file: {}", expanded.display())
107 })?;
108 let pubkey = ssh_key::PublicKey::from_openssh(contents.trim()).with_context(|| {
109 format!(
110 "failed to parse SSH public key from: {}",
111 expanded.display()
112 )
113 })?;
114 let fingerprint = pubkey.fingerprint(ssh_key::HashAlg::Sha256).to_string();
115 tracing::info!(
116 fingerprint = %fingerprint,
117 path = %expanded.display(),
118 "resolved SSH key from file"
119 );
120 step_done(&format!(
121 "Resolved SSH key ({})",
122 expanded
123 .file_name()
124 .map(|n| n.to_string_lossy())
125 .unwrap_or_default()
126 ));
127 return Ok(fingerprint);
128 }
129
130 anyhow::bail!(
132 "'{raw}' is not a valid SSH key fingerprint (SHA256:...) or public key file path.\n\
133 Usage:\n \
134 --ssh-key (interactive selection from agent)\n \
135 --ssh-key SHA256:abc123... (fingerprint from `ssh-add -l`)\n \
136 --ssh-key ~/.ssh/id_ed25519.pub (public key file)"
137 );
138}
139
140async fn select_ssh_key_interactive() -> anyhow::Result<String> {
142 let sock_path =
143 std::env::var("SSH_AUTH_SOCK").context("SSH_AUTH_SOCK not set — is ssh-agent running?")?;
144 let mut agent = ssh_agent_client_rs::Client::connect(std::path::Path::new(&sock_path))
145 .context("failed to connect to SSH agent")?;
146 let identities = agent
147 .list_all_identities()
148 .context("failed to list SSH agent keys")?;
149
150 let eligible: Vec<_> = identities
151 .into_iter()
152 .filter(|id| {
153 let algo = match id {
154 ssh_agent_client_rs::Identity::PublicKey(cow) => cow.algorithm(),
155 ssh_agent_client_rs::Identity::Certificate(cow) => cow.algorithm(),
156 };
157 core_auth::SshKeyType::from_algorithm(&algo).is_ok()
158 })
159 .collect();
160
161 if eligible.is_empty() {
162 anyhow::bail!(
163 "no eligible SSH keys found in agent.\n\
164 Only Ed25519 and RSA keys are supported (ECDSA uses non-deterministic signatures).\n\
165 Add a key with: ssh-add ~/.ssh/id_ed25519"
166 );
167 }
168
169 let key_labels: Vec<String> = eligible
170 .iter()
171 .map(|id| {
172 let (fp, comment) = match id {
173 ssh_agent_client_rs::Identity::PublicKey(cow) => (
174 cow.fingerprint(ssh_key::HashAlg::Sha256).to_string(),
175 cow.comment().to_string(),
176 ),
177 ssh_agent_client_rs::Identity::Certificate(cow) => (
178 cow.public_key()
179 .fingerprint(ssh_key::HashAlg::Sha256)
180 .to_string(),
181 cow.comment().to_string(),
182 ),
183 };
184 if comment.is_empty() {
185 fp
186 } else {
187 format!("{fp} ({comment})")
188 }
189 })
190 .collect();
191
192 let selection = if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
193 dialoguer::Select::new()
194 .with_prompt(" Select SSH key for enrollment")
195 .items(&key_labels)
196 .default(0)
197 .interact()
198 .context("SSH key selection cancelled")?
199 } else {
200 anyhow::bail!(
201 "non-interactive mode requires an explicit SSH key.\n\
202 Usage:\n \
203 --ssh-key SHA256:abc123... (fingerprint from `ssh-add -l`)\n \
204 --ssh-key ~/.ssh/id_ed25519.pub (public key file)"
205 );
206 };
207
208 let fingerprint = match &eligible[selection] {
209 ssh_agent_client_rs::Identity::PublicKey(cow) => {
210 cow.fingerprint(ssh_key::HashAlg::Sha256).to_string()
211 }
212 ssh_agent_client_rs::Identity::Certificate(cow) => cow
213 .public_key()
214 .fingerprint(ssh_key::HashAlg::Sha256)
215 .to_string(),
216 };
217
218 Ok(fingerprint)
219}
220
221pub async fn cmd_init(
226 no_keybinding: bool,
227 org: Option<String>,
228 ssh_key: Option<String>,
229 password: bool,
230 auth_policy: String,
231) -> anyhow::Result<()> {
232 let do_keybinding = keybinding_applicable(no_keybinding);
233 let total_steps: u32 = if do_keybinding { 5 } else { 4 };
234
235 println!("\n {}", "Open Sesame — First-Time Setup".bold());
236
237 step_header(1, total_steps, "Configuration");
239 init_config()?;
240
241 step_header(2, total_steps, "Installation Identity");
243 init_installation(org.as_deref())?;
244
245 step_header(3, total_steps, "Services");
247 init_services().await?;
248
249 let ssh_fingerprint = match ssh_key {
251 Some(val) => Some(resolve_ssh_key(&val).await?),
252 None => None,
253 };
254
255 let combine_mode = parse_auth_policy(&auth_policy)?;
257 step_header(4, total_steps, "Vault");
258 init_vault(ssh_fingerprint.as_deref(), password, combine_mode).await?;
259
260 if do_keybinding {
262 step_header(5, total_steps, "Keybinding (COSMIC desktop detected)");
263 init_keybinding()?;
264 }
265
266 println!("\n {}", "Setup complete.".green().bold());
267 println!(" Try:");
268 println!(" {} — check system state", "sesame status".bold());
269 println!(" {} — list open windows", "sesame wm list".bold());
270 println!(
271 " {} — store a secret",
272 "sesame secret set -p default my-api-key".bold()
273 );
274 println!();
275
276 Ok(())
277}
278
279fn init_config() -> anyhow::Result<()> {
282 let config_dir = core_config::config_dir();
283 let config_path = config_dir.join("config.toml");
284
285 if config_path.exists() {
286 step_skip("Config exists");
287 return Ok(());
288 }
289
290 std::fs::create_dir_all(&config_dir).context("failed to create config directory")?;
291
292 let mut config = core_config::Config::default();
293 config.profiles.insert(
294 core_types::DEFAULT_PROFILE_NAME.into(),
295 core_config::ProfileConfig::default(),
296 );
297
298 let toml_str = toml::to_string_pretty(&config).context("failed to serialize default config")?;
299 core_config::atomic_write(&config_path, toml_str.as_bytes())
300 .context("failed to write config")?;
301
302 step_done(&format!("Creating {}", config_path.display()));
303 println!(" Default profile: {}", "\"default\"".green());
304
305 Ok(())
306}
307
308fn init_installation(org: Option<&str>) -> anyhow::Result<()> {
311 let installation_path = core_config::installation_path();
312
313 if installation_path.exists() {
314 step_skip("Installation identity exists");
315 return Ok(());
316 }
317
318 let id = uuid::Uuid::new_v4();
319
320 let profile_ns = core_types::PROFILE_NAMESPACE;
322
323 let (install_ns, org_config) = if let Some(domain) = org {
324 let org_ns = uuid::Uuid::new_v5(&profile_ns, format!("org:{domain}").as_bytes());
325 let ns = uuid::Uuid::new_v5(&org_ns, format!("install:{id}").as_bytes());
326 let org_cfg = core_config::OrgConfig {
327 domain: domain.to_string(),
328 namespace: org_ns,
329 };
330 (ns, Some(org_cfg))
331 } else {
332 let ns = uuid::Uuid::new_v5(&profile_ns, format!("install:{id}").as_bytes());
333 (ns, None)
334 };
335
336 let machine_binding = std::fs::read_to_string("/etc/machine-id").ok().map(|mid| {
338 let mid = mid.trim();
339 let mut hasher = blake3::Hasher::new();
340 hasher.update(mid.as_bytes());
341 hasher.update(id.as_bytes());
342 let hash = hasher.finalize();
343 core_config::MachineBindingConfig {
344 binding_hash: hash.to_hex().to_string(),
345 binding_type: "machine-id".to_string(),
346 }
347 });
348
349 let install_config = core_config::InstallationConfig {
350 id,
351 namespace: install_ns,
352 org: org_config,
353 machine_binding: machine_binding.clone(),
354 };
355
356 core_config::write_installation(&install_config)
357 .context("failed to write installation.toml")?;
358
359 {
361 let audit_path = core_config::config_dir().join("audit.jsonl");
362 let audit_file = std::fs::OpenOptions::new()
363 .create(true)
364 .append(true)
365 .open(&audit_path)
366 .context("failed to open audit log for installation event")?;
367 #[cfg(unix)]
368 {
369 use std::os::unix::fs::PermissionsExt;
370 std::fs::set_permissions(&audit_path, std::fs::Permissions::from_mode(0o600))
371 .context("failed to set audit file permissions")?;
372 }
373 let audit_writer = std::io::BufWriter::new(audit_file);
374
375 let (last_hash, sequence) = if audit_path.metadata().map(|m| m.len() > 0).unwrap_or(false) {
376 let contents = std::fs::read_to_string(&audit_path).unwrap_or_default();
377 if let Some(last_line) = contents.lines().rev().find(|l| !l.trim().is_empty()) {
378 if let Ok(entry) = serde_json::from_str::<core_profile::AuditEntry>(last_line) {
379 let hash = blake3::hash(last_line.as_bytes());
380 (hash.to_hex().to_string(), entry.sequence)
381 } else {
382 (String::new(), 0)
383 }
384 } else {
385 (String::new(), 0)
386 }
387 } else {
388 (String::new(), 0)
389 };
390
391 let mut audit = core_profile::AuditLogger::new(
392 audit_writer,
393 last_hash,
394 sequence,
395 core_types::AuditHash::Blake3,
396 None,
397 );
398 let audit_org_ns = org.map(|domain| core_types::OrganizationNamespace {
399 domain: domain.to_string(),
400 namespace: uuid::Uuid::new_v5(&profile_ns, format!("org:{domain}").as_bytes()),
401 });
402 let audit_machine_binding = machine_binding.as_ref().and_then(|mb| {
403 let hash = blake3::Hash::from_hex(&mb.binding_hash).ok()?;
404 Some(core_types::MachineBinding {
405 binding_hash: *hash.as_bytes(),
406 binding_type: core_types::MachineBindingType::MachineId,
407 })
408 });
409 audit
410 .append(core_profile::AuditAction::InstallationCreated {
411 id: core_types::InstallationId {
412 id,
413 org_ns: audit_org_ns,
414 namespace: install_ns,
415 machine_binding: audit_machine_binding,
416 },
417 org: org.map(|s| s.to_string()),
418 machine_binding_present: machine_binding.is_some(),
419 })
420 .map_err(|e| anyhow::anyhow!("failed to write audit event: {e}"))?;
421 }
422
423 step_done(&format!("Created {}", installation_path.display()));
424 println!(" Installation ID: {}", id);
425 println!(" Namespace: {}", install_ns);
426 if let Some(domain) = org {
427 println!(" Organization: {}", domain);
428 }
429 println!(
430 " Machine binding: {}",
431 if machine_binding.is_some() {
432 "present"
433 } else {
434 "absent"
435 }
436 );
437
438 Ok(())
439}
440
441async fn init_services() -> anyhow::Result<()> {
444 let is_active = std::process::Command::new("systemctl")
446 .args([
447 "--user",
448 "is-active",
449 "--quiet",
450 "open-sesame-headless.target",
451 ])
452 .status()
453 .map(|s| s.success())
454 .unwrap_or(false);
455
456 if is_active {
457 if core_ipc::noise::read_bus_public_key().await.is_ok() {
459 step_skip("Daemons running");
460 return Ok(());
461 }
462 }
463
464 core_config::bootstrap_dirs();
466
467 let _ = std::process::Command::new("systemd-tmpfiles")
469 .args(["--user", "--create"])
470 .status();
471
472 let _ = std::process::Command::new("systemctl")
474 .args(["--user", "daemon-reload"])
475 .status();
476
477 let desktop_installed = std::process::Command::new("systemctl")
479 .args([
480 "--user",
481 "list-unit-files",
482 "open-sesame-desktop.target",
483 "--no-pager",
484 "--no-legend",
485 ])
486 .output()
487 .map(|o| !o.stdout.is_empty())
488 .unwrap_or(false);
489
490 let _ = std::process::Command::new("systemctl")
492 .args(["--user", "reset-failed", "open-sesame-headless.target"])
493 .status();
494 for unit in [
495 "open-sesame-profile",
496 "open-sesame-secrets",
497 "open-sesame-launcher",
498 "open-sesame-snippets",
499 ] {
500 let _ = std::process::Command::new("systemctl")
501 .args(["--user", "reset-failed", unit])
502 .status();
503 }
504 if desktop_installed {
505 let _ = std::process::Command::new("systemctl")
506 .args(["--user", "reset-failed", "open-sesame-desktop.target"])
507 .status();
508 for unit in [
509 "open-sesame-wm",
510 "open-sesame-clipboard",
511 "open-sesame-input",
512 ] {
513 let _ = std::process::Command::new("systemctl")
514 .args(["--user", "reset-failed", unit])
515 .status();
516 }
517 }
518
519 let start = std::process::Command::new("systemctl")
521 .args(["--user", "start", "open-sesame-headless.target"])
522 .output()
523 .context("failed to run systemctl")?;
524
525 if !start.status.success() {
526 let stderr = String::from_utf8_lossy(&start.stderr);
527 anyhow::bail!(
528 "failed to start headless daemons: {stderr}\n\
529 Check: journalctl --user -u open-sesame-profile"
530 );
531 }
532
533 step_done("Starting headless daemons");
534
535 if desktop_installed {
536 let _ = std::process::Command::new("systemctl")
537 .args(["--user", "start", "open-sesame-desktop.target"])
538 .status();
539 step_done("Starting desktop daemons");
540 }
541
542 print!(" Waiting for IPC bus ... ");
544 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
545 loop {
546 if core_ipc::noise::read_bus_public_key().await.is_ok() {
547 println!("{}", "ready".green());
548 return Ok(());
549 }
550 if tokio::time::Instant::now() >= deadline {
551 println!("{}", "timeout".red());
552 anyhow::bail!(
553 "IPC bus not available after 10s.\n\
554 Check: journalctl --user -u open-sesame-profile"
555 );
556 }
557 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
558 }
559}
560
561async fn init_vault(
564 ssh_fingerprint: Option<&str>,
565 use_password: bool,
566 combine_mode: core_types::AuthCombineMode,
567) -> anyhow::Result<()> {
568 let config_dir = core_config::config_dir();
569 let profile =
570 TrustProfileName::try_from(core_types::DEFAULT_PROFILE_NAME).expect("hardcoded valid name");
571 let client = crate::ipc::connect().await?;
572
573 let has_password = use_password || ssh_fingerprint.is_none();
576 let has_ssh = ssh_fingerprint.is_some();
577
578 let already_unlocked = matches!(
580 crate::ipc::rpc(&client, EventKind::StatusRequest, SecurityLevel::Internal).await?,
581 EventKind::StatusResponse { locked: false, .. }
582 );
583
584 if already_unlocked {
585 step_skip("Secrets already unlocked");
586 } else {
587 let mut master_key_bytes = [0u8; 32];
589 getrandom::getrandom(&mut master_key_bytes)
590 .map_err(|e| anyhow::anyhow!("failed to generate random master key: {e}"))?;
591 let master_key = core_crypto::SecureBytes::new(master_key_bytes.to_vec());
592 master_key_bytes.zeroize();
593
594 let mut salt = [0u8; 16];
596 getrandom::getrandom(&mut salt)
597 .map_err(|e| anyhow::anyhow!("failed to generate salt: {e}"))?;
598 let salt_path = config_dir.join("vaults").join(format!("{profile}.salt"));
599 let vaults_dir = config_dir.join("vaults");
600 std::fs::create_dir_all(&vaults_dir).context("failed to create vaults dir")?;
601 #[cfg(unix)]
602 {
603 use std::os::unix::fs::PermissionsExt;
604 std::fs::set_permissions(&vaults_dir, std::fs::Permissions::from_mode(0o700))
605 .context("failed to set vaults directory permissions")?;
606 }
607 core_config::atomic_write(&salt_path, &salt).context("failed to write salt")?;
608 #[cfg(unix)]
609 {
610 use std::os::unix::fs::PermissionsExt;
611 std::fs::set_permissions(&salt_path, std::fs::Permissions::from_mode(0o600))
612 .context("failed to set salt file permissions")?;
613 }
614
615 let mut enrolled_factors = Vec::new();
617 let now = std::time::SystemTime::now()
618 .duration_since(std::time::UNIX_EPOCH)
619 .unwrap_or_default()
620 .as_secs();
621
622 if has_password {
624 if has_ssh {
625 println!(" Password is one of the enrolled factors.");
626 } else {
627 println!(" This password encrypts your secrets vault.");
628 println!(
629 " Choose something strong — you'll need it to unlock after reboot."
630 );
631 }
632 println!();
633
634 let mut password_str = if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
635 dialoguer::Password::new()
636 .with_prompt(" Master password")
637 .with_confirmation(
638 " Confirm",
639 " Passwords don't match, try again",
640 )
641 .interact()
642 .context("failed to read password")?
643 } else {
644 let mut buf = String::new();
645 std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut buf)
646 .context("failed to read password from stdin")?;
647 if buf.ends_with('\n') {
648 buf.pop();
649 if buf.ends_with('\r') {
650 buf.pop();
651 }
652 }
653 if buf.is_empty() {
654 anyhow::bail!(
655 "empty password from stdin — refusing to create vault with no password"
656 );
657 }
658 buf
659 };
660
661 let mut password_sv = core_crypto::SecureVec::for_password();
662 for ch in password_str.chars() {
663 password_sv.push_char(ch);
664 }
665 password_str.zeroize();
666
667 let pw_backend = core_auth::PasswordBackend::new().with_password(password_sv);
668 core_auth::VaultAuthBackend::enroll(
669 &pw_backend,
670 &profile,
671 &master_key,
672 &config_dir,
673 &salt,
674 None,
675 )
676 .await
677 .map_err(|e| anyhow::anyhow!("password enrollment failed: {e}"))?;
678
679 enrolled_factors.push(core_auth::EnrolledFactor {
680 factor_id: core_types::AuthFactorId::Password,
681 label: "master password".into(),
682 enrolled_at: now,
683 });
684 step_done("Password factor enrolled");
685 }
686
687 if let Some(fingerprint) = ssh_fingerprint {
689 let key_index = crate::ipc::find_ssh_key_index(fingerprint).await?;
690 let ssh_backend = core_auth::SshAgentBackend::new();
691 core_auth::VaultAuthBackend::enroll(
692 &ssh_backend,
693 &profile,
694 &master_key,
695 &config_dir,
696 &salt,
697 Some(key_index),
698 )
699 .await
700 .map_err(|e| anyhow::anyhow!("SSH enrollment failed: {e}"))?;
701
702 enrolled_factors.push(core_auth::EnrolledFactor {
703 factor_id: core_types::AuthFactorId::SshAgent,
704 label: fingerprint.to_string(),
705 enrolled_at: now,
706 });
707 step_done(&format!("SSH factor enrolled ({})", fingerprint));
708 }
709
710 let meta =
712 core_auth::VaultMetadata::new_multi_factor(enrolled_factors, combine_mode.clone());
713 meta.save(&config_dir, &profile)
714 .map_err(|e| anyhow::anyhow!("failed to write vault metadata: {e}"))?;
715
716 let policy_label = match &combine_mode {
717 core_types::AuthCombineMode::Any => "any",
718 core_types::AuthCombineMode::All => "all",
719 core_types::AuthCombineMode::Policy(_) => "policy",
720 };
721 step_done(&format!(
722 "Vault metadata written (auth policy: {policy_label})"
723 ));
724
725 let unlock_key = if matches!(combine_mode, core_types::AuthCombineMode::All) {
729 let mut pieces: Vec<(core_types::AuthFactorId, &[u8])> = Vec::new();
732 if has_password {
733 pieces.push((core_types::AuthFactorId::Password, master_key.as_bytes()));
734 }
735 if ssh_fingerprint.is_some() {
736 pieces.push((core_types::AuthFactorId::SshAgent, master_key.as_bytes()));
737 }
738 pieces.sort_by_key(|(id, _)| *id);
739 let mut combined = Vec::new();
740 for (_id, piece) in &pieces {
741 combined.extend_from_slice(piece);
742 }
743 let ctx_str = format!("pds v2 combined-master-key {profile}");
744 let derived: [u8; 32] = blake3::derive_key(&ctx_str, &combined);
745 combined.zeroize();
746 drop(master_key);
747 core_crypto::SecureBytes::new(derived.to_vec())
748 } else if has_ssh {
749 drop(master_key);
752 let unlock_backend = core_auth::SshAgentBackend::new();
753 let outcome =
754 core_auth::VaultAuthBackend::unlock(&unlock_backend, &profile, &config_dir, &salt)
755 .await
756 .map_err(|e| anyhow::anyhow!("SSH unlock failed: {e}"))?;
757 step_done("SSH enrollment verified");
758 outcome.master_key
759 } else {
760 master_key
762 };
763
764 let event = EventKind::SshUnlockRequest {
766 master_key: {
767 let (alloc, len) = unlock_key.into_protected_alloc();
768 SensitiveBytes::from_protected(alloc, len)
769 },
770 profile: profile.clone(),
771 ssh_fingerprint: "direct-init".to_string(),
772 };
773 match crate::ipc::rpc(&client, event, SecurityLevel::SecretsOnly).await? {
774 EventKind::UnlockResponse { success: true, .. } => {
775 step_done("Secrets vault unlocked");
776 }
777 EventKind::UnlockResponse { success: false, .. } => {
778 anyhow::bail!("unlock failed — key rejected by daemon-secrets");
779 }
780 EventKind::UnlockRejected {
781 reason: core_types::UnlockRejectedReason::AlreadyUnlocked,
782 ..
783 } => {
784 step_skip("Secrets already unlocked");
785 }
786 other => anyhow::bail!("unexpected response: {other:?}"),
787 }
788 }
789
790 let event = EventKind::ProfileActivate {
792 target: ProfileId::new(),
793 profile_name: profile,
794 };
795 match crate::ipc::rpc(&client, event, SecurityLevel::Internal).await? {
796 EventKind::ProfileActivateResponse { success: true } => {
797 step_done("Default profile activated");
798 }
799 EventKind::ProfileActivateResponse { success: false } => {
800 println!(" Default profile: {}", "(already active)".dimmed());
801 }
802 other => anyhow::bail!("unexpected response: {other:?}"),
803 }
804
805 Ok(())
806}
807
808#[cfg(all(target_os = "linux", feature = "desktop"))]
811fn init_keybinding() -> anyhow::Result<()> {
812 platform_linux::cosmic_keys::setup_keybinding("alt+space")
813 .map_err(|e| anyhow::anyhow!("{e}"))?;
814 step_done("Alt+Space launcher keybinding configured");
815 Ok(())
816}
817
818#[cfg(not(all(target_os = "linux", feature = "desktop")))]
819fn init_keybinding() -> anyhow::Result<()> {
820 Ok(())
821}
822
823pub fn cmd_wipe() -> anyhow::Result<()> {
828 let config_dir = core_config::config_dir();
829 let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
830 .map(|d| std::path::PathBuf::from(d).join("pds"))
831 .ok();
832
833 println!();
834 println!(
835 " {}: This will permanently destroy ALL Open Sesame data:",
836 "WARNING".red().bold()
837 );
838 println!(" - Configuration: {}/", config_dir.display());
839 println!(" - Secrets vaults: {}/vaults/", config_dir.display());
840 println!(
841 " - Secrets salt: {}/secrets.salt",
842 config_dir.display()
843 );
844 println!(" - Audit logs: {}/audit.jsonl", config_dir.display());
845 if let Some(ref rt) = runtime_dir {
846 println!(" - Runtime state: {}/", rt.display());
847 }
848 println!();
849
850 let confirmation: String = if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
851 dialoguer::Input::new()
852 .with_prompt(" Type \"destroy all data\" to confirm")
853 .interact_text()
854 .context("failed to read confirmation")?
855 } else {
856 let mut buf = String::new();
857 std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut buf)
858 .context("failed to read confirmation from stdin")?;
859 if buf.ends_with('\n') {
860 buf.pop();
861 if buf.ends_with('\r') {
862 buf.pop();
863 }
864 }
865 buf
866 };
867
868 if confirmation.trim() != "destroy all data" {
869 println!(" Cancelled.");
870 return Ok(());
871 }
872
873 println!();
874
875 let _ = std::process::Command::new("systemctl")
877 .args(["--user", "stop", "open-sesame-desktop.target"])
878 .status();
879 let _ = std::process::Command::new("systemctl")
880 .args(["--user", "stop", "open-sesame-headless.target"])
881 .status();
882 println!(" Stopping daemons ... {}", "done".green());
883
884 if config_dir.exists() {
887 let vaults_dir = config_dir.join("vaults");
888 if vaults_dir.is_dir()
889 && let Ok(entries) = std::fs::read_dir(&vaults_dir)
890 {
891 for entry in entries.flatten() {
892 let path = entry.path();
893 if path.is_file()
894 && let Ok(meta) = path.metadata()
895 {
896 let len = meta.len() as usize;
897 if len > 0 {
898 let zeros = vec![0u8; len];
899 let _ = std::fs::write(&path, &zeros);
900 }
901 }
902 }
903 }
904 for name in ["secrets.salt", "audit.jsonl"] {
906 let path = config_dir.join(name);
907 if path.is_file()
908 && let Ok(meta) = path.metadata()
909 {
910 let len = meta.len() as usize;
911 if len > 0 {
912 let zeros = vec![0u8; len];
913 let _ = std::fs::write(&path, &zeros);
914 }
915 }
916 }
917 println!(" Zeroizing sensitive files ... {}", "done".green());
918
919 std::fs::remove_dir_all(&config_dir).context("failed to remove config directory")?;
920 println!(" Removing {} ... {}", config_dir.display(), "done".green());
921 }
922
923 if let Some(ref rt) = runtime_dir
925 && rt.exists()
926 {
927 std::fs::remove_dir_all(rt).context("failed to remove runtime directory")?;
928 println!(" Removing {} ... {}", rt.display(), "done".green());
929 }
930
931 println!();
932 println!(" {}", "All data destroyed.".green().bold());
933 println!(" Run `sesame init` to start fresh.");
934 println!();
935
936 Ok(())
937}