sesame/
init.rs

1//! `sesame init` — first-run setup and factory reset.
2
3use anyhow::Context;
4use core_types::{EventKind, ProfileId, SecurityLevel, SensitiveBytes, TrustProfileName};
5use owo_colors::OwoColorize;
6use zeroize::Zeroize;
7
8/// Whether the COSMIC keybinding step applies.
9fn 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
52/// Resolve an `--ssh-key` CLI value into a SHA256 fingerprint.
53///
54/// Accepts three forms:
55///   - Empty string (`--ssh-key` with no value): list keys from agent, interactive select.
56///   - File path (`--ssh-key ~/.ssh/id_ed25519.pub`): read public key, compute fingerprint.
57///   - Fingerprint (`--ssh-key SHA256:...`): use directly.
58pub(crate) async fn resolve_ssh_key(raw: &str) -> anyhow::Result<String> {
59    // Empty: interactive selection from SSH agent.
60    if raw.is_empty() {
61        return select_ssh_key_interactive().await;
62    }
63
64    // Looks like a fingerprint (starts with SHA256: or is base64-ish hash).
65    if raw.starts_with("SHA256:") {
66        return Ok(raw.to_string());
67    }
68
69    // Try as a file path (expand ~ to home dir).
70    let expanded = if let Some(rest) = raw.strip_prefix("~/") {
71        if let Some(home) = dirs::home_dir() {
72            let candidate = home.join(rest);
73            // Canonicalize to resolve ../ traversals, then verify the result
74            // is still within the user's home directory.
75            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        // Guard against accidentally reading huge files — SSH public keys are
94        // never larger than a few KB.
95        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    // Not a file, not a fingerprint — error with guidance.
131    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
140/// Interactively select an SSH key from the running SSH agent.
141async 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
221// ============================================================================
222// sesame init
223// ============================================================================
224
225pub 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 1: Configuration
238    step_header(1, total_steps, "Configuration");
239    init_config()?;
240
241    // Step 2: Installation Identity
242    step_header(2, total_steps, "Installation Identity");
243    init_installation(org.as_deref())?;
244
245    // Step 3: Services
246    step_header(3, total_steps, "Services");
247    init_services().await?;
248
249    // Resolve --ssh-key into a fingerprint before vault init.
250    let ssh_fingerprint = match ssh_key {
251        Some(val) => Some(resolve_ssh_key(&val).await?),
252        None => None,
253    };
254
255    // Step 4: Vault initialization
256    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    // Step 5: Keybinding (conditional)
261    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
279// ── Step 1: Config ──────────────────────────────────────────────────────────
280
281fn 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
308// ── Step 2: Installation Identity ──────────────────────────────────────────
309
310fn 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    // Deterministic namespace for profile IDs — matches daemon-profile.
321    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    // Machine binding: read /etc/machine-id
337    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    // Write InstallationCreated audit event directly (bus not running yet)
360    {
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
441// ── Step 3: Services ────────────────────────────────────────────────────────
442
443async fn init_services() -> anyhow::Result<()> {
444    // Check if already running.
445    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        // Daemons running — verify bus is reachable.
458        if core_ipc::noise::read_bus_public_key().await.is_ok() {
459            step_skip("Daemons running");
460            return Ok(());
461        }
462    }
463
464    // Ensure runtime directory exists before starting services.
465    core_config::bootstrap_dirs();
466
467    // Also run tmpfiles to create any other dirs we declared.
468    let _ = std::process::Command::new("systemd-tmpfiles")
469        .args(["--user", "--create"])
470        .status();
471
472    // daemon-reload to pick up any new/changed unit files.
473    let _ = std::process::Command::new("systemctl")
474        .args(["--user", "daemon-reload"])
475        .status();
476
477    // Detect whether desktop units are installed before reset-failed / start.
478    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    // Reset any failed units from prior crash-loops.
491    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    // Start the headless target (always present).
520    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    // Poll for bus.pub (up to 10s).
543    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
561// ── Step 4: Vault initialization (unified) ──────────────────────────────────
562
563async 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    // Determine which factors to enroll.
574    // Default: password-only if no --key provided.
575    let has_password = use_password || ssh_fingerprint.is_none();
576    let has_ssh = ssh_fingerprint.is_some();
577
578    // Check current state.
579    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        // Generate random master key.
588        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        // Generate salt.
595        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        // Track enrolled factors for metadata.
616        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        // Enroll password factor.
623        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        // Enroll SSH factor.
688        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        // Write vault metadata.
711        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        // Compute the unlock key that daemon-secrets will use for this vault.
726        // In All mode, daemon-secrets combines factor pieces via BLAKE3 derive_key,
727        // so we must create the vault with that same derived key.
728        let unlock_key = if matches!(combine_mode, core_types::AuthCombineMode::All) {
729            // Replicate daemon-secrets All-mode combination:
730            // sort factor pieces by AuthFactorId, concatenate, BLAKE3 derive_key.
731            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            // Any mode with SSH: unlock via SSH to verify the enrollment round-trips.
750            // Drop raw master_key early — SSH unlock recovers it from the enrolled blob.
751            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            // Password-only or Any mode without SSH: use raw master key.
761            master_key
762        };
763
764        // Send unlock key to daemon-secrets to create/open the vault.
765        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    // Activate default profile.
791    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// ── Step 5: Keybinding ─────────────────────────────────────────────────────
809
810#[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
823// ============================================================================
824// sesame init --wipe-reset-destroy-all-data
825// ============================================================================
826
827pub 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    // Stop daemons — desktop first, then headless.
876    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    // Overwrite sensitive files with zeros before unlinking.
885    // This ensures key material and vault data don't linger on disk.
886    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        // Also zeroize salt and audit files at the top level.
905        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    // Remove runtime directory.
924    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}