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 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 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 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 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 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 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 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 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 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}