sesame/
ssh.rs

1use anyhow::Context;
2use comfy_table::{Table, presets::UTF8_FULL};
3use core_types::TrustProfileName;
4use owo_colors::OwoColorize;
5use zeroize::Zeroize;
6
7use crate::ipc::resolve_profile_specs;
8
9pub(crate) async fn cmd_ssh_enroll(
10    profile_arg: Option<String>,
11    ssh_key: Option<String>,
12) -> anyhow::Result<()> {
13    // Resolve --ssh-key into a fingerprint (interactive select, file path, or direct fingerprint).
14    let key_fingerprint: Option<String> = match ssh_key {
15        Some(ref val) => Some(crate::init::resolve_ssh_key(val).await?),
16        None => None,
17    };
18    let specs = resolve_profile_specs(profile_arg.as_deref());
19    let config_dir = core_config::config_dir();
20
21    for spec in &specs {
22        let profile_name = &spec.vault;
23        let target = TrustProfileName::try_from(profile_name.as_str())
24            .map_err(|e| anyhow::anyhow!("invalid profile name '{profile_name}': {e}"))?;
25
26        // Vault must have a salt (must be initialized)
27        let salt_path = config_dir.join("vaults").join(format!("{target}.salt"));
28        let salt = std::fs::read(&salt_path)
29            .context("failed to read vault salt — is the vault created?")?;
30
31        // Need password to derive master key for wrapping
32        println!("SSH enrollment requires your vault password to derive the master key.");
33        let mut password = if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
34            dialoguer::Password::new()
35                .with_prompt(format!("Password for vault '{profile_name}'"))
36                .interact()
37                .context("failed to read password")?
38        } else {
39            let mut buf = String::new();
40            std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut buf)
41                .context("failed to read password from stdin")?;
42            if buf.ends_with('\n') {
43                buf.pop();
44                if buf.ends_with('\r') {
45                    buf.pop();
46                }
47            }
48            if buf.is_empty() {
49                anyhow::bail!(
50                    "empty password from stdin for vault '{profile_name}' — refusing to enroll with no password"
51                );
52            }
53            buf
54        };
55
56        // Unwrap the real master key from the PasswordWrapBlob.
57        // In Any/Policy mode, the master key is a random value wrapped under
58        // the Argon2id-derived KEK. Each factor independently wraps this same
59        // master key, so factors can be added/revoked independently.
60        let mut password_sv = core_crypto::SecureVec::for_password();
61        for ch in password.chars() {
62            password_sv.push_char(ch);
63        }
64        password.zeroize();
65        let pw_backend = core_auth::PasswordBackend::new().with_password(password_sv);
66        let outcome = core_auth::VaultAuthBackend::unlock(&pw_backend, &target, &config_dir, &salt)
67            .await
68            .map_err(|e| anyhow::anyhow!("failed to derive master key from password: {e}"))?;
69        let master_key = outcome.master_key;
70
71        // List SSH agent keys for user selection
72        let sock_path = std::env::var("SSH_AUTH_SOCK")
73            .context("SSH_AUTH_SOCK not set — is ssh-agent running?")?;
74        let mut agent = ssh_agent_client_rs::Client::connect(std::path::Path::new(&sock_path))
75            .context("failed to connect to SSH agent")?;
76        let identities = agent
77            .list_all_identities()
78            .context("failed to list SSH agent keys")?;
79
80        let eligible: Vec<_> = identities
81            .into_iter()
82            .filter(|id| {
83                let algo = match id {
84                    ssh_agent_client_rs::Identity::PublicKey(cow) => cow.algorithm(),
85                    ssh_agent_client_rs::Identity::Certificate(cow) => cow.algorithm(),
86                };
87                core_auth::SshKeyType::from_algorithm(&algo).is_ok()
88            })
89            .collect();
90
91        if eligible.is_empty() {
92            anyhow::bail!(
93                "no eligible SSH keys found in agent.\n\
94                 Only Ed25519 and RSA keys are supported (ECDSA uses non-deterministic signatures).\n\
95                 Add a key with: ssh-add ~/.ssh/id_ed25519"
96            );
97        }
98
99        let key_labels: Vec<String> = eligible
100            .iter()
101            .map(|id| match id {
102                ssh_agent_client_rs::Identity::PublicKey(cow) => {
103                    let fp = cow.fingerprint(ssh_key::HashAlg::Sha256);
104                    let algo = cow.algorithm();
105                    format!("{fp} ({algo:?})")
106                }
107                ssh_agent_client_rs::Identity::Certificate(cow) => {
108                    let algo = cow.algorithm();
109                    format!("<certificate> ({algo:?})")
110                }
111            })
112            .collect();
113
114        let selection = if let Some(ref fp) = key_fingerprint {
115            // Explicit key selection by fingerprint (headless-safe).
116            let fp_normalized = fp.strip_prefix("SHA256:").unwrap_or(fp);
117            eligible
118                .iter()
119                .position(|id| {
120                    let id_fp = match id {
121                        ssh_agent_client_rs::Identity::PublicKey(cow) => {
122                            cow.fingerprint(ssh_key::HashAlg::Sha256).to_string()
123                        }
124                        ssh_agent_client_rs::Identity::Certificate(cow) => cow
125                            .public_key()
126                            .fingerprint(ssh_key::HashAlg::Sha256)
127                            .to_string(),
128                    };
129                    // Match with or without "SHA256:" prefix.
130                    let id_fp_bare = id_fp.strip_prefix("SHA256:").unwrap_or(&id_fp);
131                    id_fp_bare == fp_normalized
132                })
133                .ok_or_else(|| {
134                    anyhow::anyhow!(
135                        "SSH key with fingerprint '{fp}' not found in agent.\n\
136                         Available keys:\n{}\n\
137                         Use `ssh-add -l` to list loaded keys.",
138                        key_labels.join("\n")
139                    )
140                })?
141        } else if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
142            // Interactive: always require explicit selection.
143            dialoguer::Select::new()
144                .with_prompt("Select SSH key for enrollment")
145                .items(&key_labels)
146                .default(0)
147                .interact()?
148        } else {
149            anyhow::bail!(
150                "no --ssh-key specified (required for non-interactive use).\n\
151                 Available keys:\n{}\n\
152                 Use --ssh-key <SHA256:fingerprint> or --ssh-key ~/.ssh/id_ed25519.pub",
153                key_labels.join("\n")
154            );
155        };
156
157        let backend = core_auth::SshAgentBackend::new();
158        core_auth::VaultAuthBackend::enroll(
159            &backend,
160            &target,
161            &master_key,
162            &config_dir,
163            &salt,
164            Some(selection),
165        )
166        .await?;
167
168        // Update vault metadata to record the new SSH factor.
169        let fingerprint = match &eligible[selection] {
170            ssh_agent_client_rs::Identity::PublicKey(cow) => {
171                cow.fingerprint(ssh_key::HashAlg::Sha256).to_string()
172            }
173            ssh_agent_client_rs::Identity::Certificate(cow) => cow
174                .public_key()
175                .fingerprint(ssh_key::HashAlg::Sha256)
176                .to_string(),
177        };
178        let mut meta = core_auth::VaultMetadata::load(&config_dir, &target).unwrap_or_else(|_| {
179            core_auth::VaultMetadata::new_password(core_types::AuthCombineMode::Any)
180        });
181        meta.add_factor(core_types::AuthFactorId::SshAgent, fingerprint);
182        meta.save(&config_dir, &target)
183            .context("failed to update vault metadata")?;
184
185        println!(
186            "{}",
187            format!("SSH enrollment created for vault '{profile_name}'.").green()
188        );
189        println!("Future unlocks will use your SSH key automatically when the agent is loaded.");
190    }
191
192    Ok(())
193}
194
195pub(crate) async fn cmd_ssh_list(profile_arg: Option<String>) -> anyhow::Result<()> {
196    let specs = resolve_profile_specs(profile_arg.as_deref());
197    let config_dir = core_config::config_dir();
198
199    let mut table = Table::new();
200    table.load_preset(UTF8_FULL);
201    table.set_header(vec![
202        "Profile",
203        "SSH Enrolled",
204        "Key Fingerprint",
205        "Key Type",
206        "Agent Available",
207    ]);
208
209    for spec in &specs {
210        let profile_name = &spec.vault;
211        let target = TrustProfileName::try_from(profile_name.as_str())
212            .map_err(|e| anyhow::anyhow!("invalid profile name '{profile_name}': {e}"))?;
213
214        let backend = core_auth::SshAgentBackend::new();
215        let enrolled = core_auth::VaultAuthBackend::is_enrolled(&backend, &target, &config_dir);
216
217        if enrolled {
218            let blob_path = config_dir
219                .join("vaults")
220                .join(format!("{target}.ssh-enrollment"));
221            let (fp, kt) = std::fs::read(&blob_path)
222                .ok()
223                .and_then(|data| core_auth::EnrollmentBlob::deserialize(&data).ok())
224                .map(|blob| (blob.key_fingerprint, blob.key_type.wire_name().to_string()))
225                .unwrap_or_else(|| ("<unreadable>".into(), "<unknown>".into()));
226
227            let available =
228                core_auth::VaultAuthBackend::can_unlock(&backend, &target, &config_dir).await;
229
230            table.add_row(vec![
231                profile_name.clone(),
232                "yes".into(),
233                fp,
234                kt,
235                if available { "yes" } else { "no" }.into(),
236            ]);
237        } else {
238            table.add_row(vec![
239                profile_name.clone(),
240                "no".into(),
241                "-".into(),
242                "-".into(),
243                "-".into(),
244            ]);
245        }
246    }
247
248    println!("{table}");
249    Ok(())
250}
251
252pub(crate) async fn cmd_ssh_revoke(profile_arg: Option<String>) -> anyhow::Result<()> {
253    let specs = resolve_profile_specs(profile_arg.as_deref());
254    let config_dir = core_config::config_dir();
255
256    for spec in &specs {
257        let profile_name = &spec.vault;
258        let target = TrustProfileName::try_from(profile_name.as_str())
259            .map_err(|e| anyhow::anyhow!("invalid profile name '{profile_name}': {e}"))?;
260
261        let backend = core_auth::SshAgentBackend::new();
262        if !core_auth::VaultAuthBackend::is_enrolled(&backend, &target, &config_dir) {
263            println!(
264                "{}",
265                format!("No SSH enrollment found for vault '{profile_name}'.").yellow()
266            );
267            continue;
268        }
269
270        core_auth::VaultAuthBackend::revoke(&backend, &target, &config_dir).await?;
271        println!(
272            "{}",
273            format!("SSH enrollment revoked for vault '{profile_name}'.").green()
274        );
275    }
276
277    Ok(())
278}