1mod generate;
2pub mod install;
3mod inventory;
4mod repair;
5
6pub use install::InstallOutcome;
7pub use install::UninstallOutcome;
8
9use std::ffi::OsStr;
10
11use toride_ssh_core::SshPaths;
12use toride_ssh_core::{Error, KeyCreateParams, KeyDeleteParams, KeyFormat, Result, SshKey};
13
14const MAX_KEY_NAME_LENGTH: usize = 255;
15
16#[cfg(unix)]
17pub(crate) fn get_permissions(path: &std::path::Path) -> Option<toride_ssh_core::Permissions> {
18 use std::os::unix::fs::PermissionsExt;
19 let metadata = std::fs::metadata(path).ok()?;
20 let mode = metadata.permissions().mode();
21 Some(toride_ssh_core::Permissions {
22 mode: mode & 0o7777,
23 })
24}
25
26#[cfg(not(unix))]
27pub(crate) fn get_permissions(_path: &std::path::Path) -> Option<toride_ssh_core::Permissions> {
28 None
29}
30
31fn validate_key_name(name: &str) -> Result<()> {
32 if name.is_empty() {
33 return Err(Error::InvalidKeyName(
34 "key name must not be empty".to_owned(),
35 ));
36 }
37 if name.len() > MAX_KEY_NAME_LENGTH {
38 return Err(Error::InvalidKeyName(format!(
39 "key name must not exceed {MAX_KEY_NAME_LENGTH} bytes"
40 )));
41 }
42 if name.contains('\0') {
43 return Err(Error::InvalidKeyName(
44 "key name must not contain null bytes".to_owned(),
45 ));
46 }
47 if name.contains('/') || name.contains('\\') {
48 return Err(Error::InvalidKeyName(
49 "key name must not contain path separators".to_owned(),
50 ));
51 }
52 if name.contains("..") {
53 return Err(Error::InvalidKeyName(
54 "key name must not contain '..'".to_owned(),
55 ));
56 }
57 Ok(())
58}
59
60fn unique_backup_path(base: &std::path::Path) -> std::path::PathBuf {
61 if !base.exists() {
62 return base.to_path_buf();
63 }
64 let ts = std::time::SystemTime::now()
65 .duration_since(std::time::UNIX_EPOCH)
66 .unwrap_or_default()
67 .as_secs();
68 let ext = match base.extension() {
69 Some(e) => format!("{}.{}", e.to_string_lossy(), ts),
70 None => ts.to_string(),
71 };
72 base.with_extension(ext)
73}
74
75pub struct KeyService<'a> {
76 paths: &'a SshPaths,
77 runner: &'a dyn toride_ssh_core::CliRunner,
78}
79
80pub fn inspect_key_cached(path: &std::path::Path) -> Result<SshKey> {
84 inventory::inspect_private_key_cached(path)
85}
86
87impl<'a> KeyService<'a> {
88 pub fn new(paths: &'a SshPaths, runner: &'a dyn toride_ssh_core::CliRunner) -> Self {
89 Self { paths, runner }
90 }
91
92 pub async fn list(&self) -> Result<Vec<SshKey>> {
96 inventory::scan_keys(self.paths, Some(self.runner)).await
97 }
98
99 pub async fn create(&self, params: KeyCreateParams) -> Result<SshKey> {
103 validate_key_name(¶ms.name)?;
104 generate::generate_key(self.paths, params, self.runner).await
105 }
106
107 pub async fn delete(&self, params: KeyDeleteParams) -> Result<()> {
111 validate_key_name(¶ms.name)?;
112 let private_path = self.paths.ssh_dir().join(¶ms.name);
113
114 if !private_path.exists() {
115 return Err(Error::KeyNotFound(params.name.clone()));
116 }
117
118 let public_path = private_path.with_extension("pub");
119
120 let cert_path = self
121 .paths
122 .ssh_dir()
123 .join(format!("{}-cert.pub", params.name));
124
125 let backup = params.backup;
126 let remove_public = params.remove_public;
127 let remove_certificate = params.remove_certificate;
128
129 if params.remove_from_agent {
130 remove_key_from_agent(&self.paths.ssh_dir().join(¶ms.name), self.runner).await;
131 }
132
133 tokio::task::spawn_blocking(move || {
134 if backup {
135 let backup_path = unique_backup_path(&private_path.with_extension("bak"));
136 std::fs::rename(&private_path, &backup_path)?;
137
138 if remove_public && public_path.exists() {
139 let stem = public_path
140 .file_stem()
141 .unwrap_or_else(|| OsStr::new(""))
142 .to_string_lossy();
143 let pub_backup_base = public_path.with_file_name(format!("{stem}.pub.bak"));
144 let pub_backup = unique_backup_path(&pub_backup_base);
145 if let Err(e) = std::fs::rename(&public_path, &pub_backup) {
146 tracing::warn!("failed to backup {}: {e}", public_path.display());
147 }
148 }
149
150 if remove_certificate && cert_path.exists() {
151 let name = cert_path
152 .file_name()
153 .unwrap_or_else(|| OsStr::new(""))
154 .to_string_lossy();
155 let cert_backup_base = cert_path.with_file_name(format!("{name}.bak"));
156 let cert_backup = unique_backup_path(&cert_backup_base);
157 if let Err(e) = std::fs::rename(&cert_path, &cert_backup) {
158 tracing::warn!("failed to backup {}: {e}", cert_path.display());
159 }
160 }
161 } else {
162 std::fs::remove_file(&private_path)?;
163
164 if remove_public && public_path.exists() {
165 std::fs::remove_file(&public_path)?;
166 }
167
168 if remove_certificate && cert_path.exists() {
169 std::fs::remove_file(&cert_path)?;
170 }
171 }
172
173 Ok::<(), Error>(())
174 })
175 .await
176 .map_err(|e| Error::TaskFailed(format!("delete task failed: {e}")))??;
177
178 if params.remove_from_config {
179 remove_from_config(self.paths, ¶ms.name).await?;
180 }
181
182 Ok(())
183 }
184
185 pub async fn repair_public(
189 &self,
190 private_key_path: &std::path::Path,
191 passphrase: Option<&str>,
192 ) -> Result<()> {
193 repair::repair_public_key(private_key_path, passphrase, self.runner).await
194 }
195
196 pub async fn rename(&self, old_name: &str, new_name: &str) -> Result<()> {
200 validate_key_name(old_name)?;
201 validate_key_name(new_name)?;
202
203 let old_private = self.paths.ssh_dir().join(old_name);
204 let new_private = self.paths.ssh_dir().join(new_name);
205
206 if !old_private.exists() {
207 return Err(Error::KeyNotFound(old_name.to_owned()));
208 }
209 if new_private.exists() {
210 return Err(Error::KeyExists(new_name.to_owned()));
211 }
212
213 let old_public = old_private.with_extension("pub");
214 let new_public = new_private.with_extension("pub");
215 let old_cert = self.paths.ssh_dir().join(format!("{old_name}-cert.pub"));
216 let new_cert = self.paths.ssh_dir().join(format!("{new_name}-cert.pub"));
217
218 tokio::task::spawn_blocking(move || {
219 std::fs::rename(&old_private, &new_private).map_err(Error::Io)?;
220
221 if old_public.exists()
222 && let Err(e) = std::fs::rename(&old_public, &new_public)
223 {
224 tracing::warn!("failed to rename public key: {e}");
225 }
226
227 if old_cert.exists()
228 && let Err(e) = std::fs::rename(&old_cert, &new_cert)
229 {
230 tracing::warn!("failed to rename certificate: {e}");
231 }
232
233 Ok(())
234 })
235 .await
236 .map_err(|e| Error::TaskFailed(format!("rename task failed: {e}")))?
237 }
238
239 pub async fn chmod_fix(&self, key_name: &str) -> Result<()> {
243 validate_key_name(key_name)?;
244
245 let private_path = self.paths.ssh_dir().join(key_name);
246 if !private_path.exists() {
247 return Err(Error::KeyNotFound(key_name.to_owned()));
248 }
249
250 let public_path = private_path.with_extension("pub");
251
252 tokio::task::spawn_blocking(move || {
253 #[cfg(unix)]
254 {
255 use std::os::unix::fs::PermissionsExt;
256 std::fs::set_permissions(&private_path, std::fs::Permissions::from_mode(0o600))
257 .map_err(Error::Io)?;
258
259 if public_path.exists()
260 && let Err(e) = std::fs::set_permissions(
261 &public_path,
262 std::fs::Permissions::from_mode(0o644),
263 )
264 {
265 tracing::warn!("failed to set public key permissions: {e}");
266 }
267 }
268 #[cfg(not(unix))]
269 {
270 let _ = (private_path, public_path);
271 }
272 Ok(())
273 })
274 .await
275 .map_err(|e| Error::TaskFailed(format!("chmod task failed: {e}")))?
276 }
277
278 pub async fn change_passphrase(
282 &self,
283 key_path: &std::path::Path,
284 old_passphrase: Option<&str>,
285 new_passphrase: Option<&str>,
286 ) -> Result<()> {
287 if !key_path.exists() {
288 return Err(Error::KeyNotFound(key_path.display().to_string()));
289 }
290
291 let path_str = key_path
292 .to_str()
293 .ok_or_else(|| Error::CommandFailed("key path is not valid UTF-8".to_owned()))?
294 .to_owned();
295
296 let old_pass = old_passphrase.unwrap_or("").to_owned();
297 let new_pass = new_passphrase.unwrap_or("").to_owned();
298
299 let askpass = MultiAskpassHandler::new(&[&old_pass, &new_pass, &new_pass])?;
300 let args = vec!["-p".to_owned(), "-f".to_owned(), path_str];
301 run_with_askpass(self.runner, "ssh-keygen", args, &askpass).await?;
302
303 Ok(())
304 }
305
306 pub async fn change_comment(
310 &self,
311 key_path: &std::path::Path,
312 new_comment: &str,
313 passphrase: Option<&str>,
314 ) -> Result<()> {
315 if !key_path.exists() {
316 return Err(Error::KeyNotFound(key_path.display().to_string()));
317 }
318
319 let path_str = key_path
320 .to_str()
321 .ok_or_else(|| Error::CommandFailed("key path is not valid UTF-8".to_owned()))?
322 .to_owned();
323
324 let pass = passphrase.unwrap_or("");
325
326 let args = vec![
327 "-c".to_owned(),
328 "-f".to_owned(),
329 path_str,
330 "-C".to_owned(),
331 new_comment.to_owned(),
332 ];
333
334 if pass.is_empty() {
335 self.runner.run("ssh-keygen", args).await?;
336 } else {
337 let askpass = toride_ssh_agent::AskpassHandler::new(pass)?;
338 run_with_askpass(self.runner, "ssh-keygen", args, &askpass).await?;
339 }
340 Ok(())
341 }
342
343 pub async fn convert(
347 &self,
348 key_path: &std::path::Path,
349 target_format: KeyFormat,
350 ) -> Result<String> {
351 if !key_path.exists() {
352 return Err(Error::KeyNotFound(key_path.display().to_string()));
353 }
354
355 if !self.runner.tool_exists("ssh-keygen") {
356 return Err(Error::ToolNotFound("ssh-keygen".to_owned()));
357 }
358
359 let path_str = key_path
360 .to_str()
361 .ok_or_else(|| Error::CommandFailed("key path is not valid UTF-8".to_owned()))?
362 .to_owned();
363
364 let args = match target_format {
365 KeyFormat::Pem => vec![
366 "-e".to_owned(),
367 "-m".to_owned(),
368 "PEM".to_owned(),
369 "-f".to_owned(),
370 path_str,
371 ],
372 KeyFormat::OpenSSH => vec![
373 "-i".to_owned(),
374 "-m".to_owned(),
375 "PEM".to_owned(),
376 "-f".to_owned(),
377 path_str,
378 ],
379 };
380
381 self.runner.run("ssh-keygen", args).await
382 }
383
384 pub async fn install_key_to_remote(
388 &self,
389 key_path: &std::path::Path,
390 dest: &str,
391 ) -> Result<install::InstallOutcome> {
392 install::install_key_to_remote(key_path, dest, self.runner).await
393 }
394
395 pub async fn uninstall_key_from_remote(
399 &self,
400 key_path: &std::path::Path,
401 dest: &str,
402 ) -> Result<install::UninstallOutcome> {
403 install::uninstall_key_from_remote(key_path, dest, self.runner).await
404 }
405}
406
407pub(crate) trait Askpass {
408 fn script_path(&self) -> &std::path::Path;
409}
410
411impl Askpass for toride_ssh_agent::AskpassHandler {
412 fn script_path(&self) -> &std::path::Path {
413 toride_ssh_agent::AskpassHandler::script_path(self)
414 }
415}
416
417pub(crate) async fn run_with_askpass(
418 runner: &dyn toride_ssh_core::CliRunner,
419 cmd: &str,
420 args: Vec<String>,
421 askpass: &dyn Askpass,
422) -> Result<String> {
423 let env = vec![
424 (
425 "SSH_ASKPASS".to_owned(),
426 askpass.script_path().to_string_lossy().into_owned(),
427 ),
428 ("SSH_ASKPASS_REQUIRE".to_owned(), "force".to_owned()),
429 ("DISPLAY".to_owned(), ":0".to_owned()),
430 ];
431 runner.run_with_env(cmd, args, env).await
432}
433
434struct MultiAskpassHandler {
435 script_path: std::path::PathBuf,
436}
437
438impl MultiAskpassHandler {
439 fn new(responses: &[&str]) -> Result<Self> {
440 #[cfg(unix)]
441 use std::io::Write;
442 #[cfg(unix)]
443 use std::os::unix::fs::OpenOptionsExt;
444
445 use std::fmt::Write as _;
446 let mut arms = String::new();
447 for (i, resp) in responses.iter().enumerate() {
448 let arm = i + 1;
449 let escaped = resp.replace('\'', "'\\''");
450 let _ = writeln!(arms, " {arm}) echo '{escaped}';;");
451 }
452 let last_escaped = responses
453 .last()
454 .map(|r| r.replace('\'', "'\\''"))
455 .unwrap_or_default();
456
457 let dir = std::env::temp_dir();
458 let ts = std::time::SystemTime::now()
459 .duration_since(std::time::UNIX_EPOCH)
460 .unwrap_or_default()
461 .as_nanos();
462 let pid = std::process::id();
463 let tid = format!("{:?}", std::thread::current().id())
464 .replace("ThreadId(", "")
465 .replace(')', "");
466 let filename = format!("toride-askpass-multi-{pid}-{tid}-{ts}");
467
468 #[cfg(unix)]
472 let tmp_path = dir.join(format!("{filename}.tmp"));
473 let script_path = dir.join(&filename);
474 let count_path = dir.join(format!("{filename}.cnt"));
475
476 let count_path_str = count_path.to_string_lossy().replace('\'', "'\\''");
477 let script = format!(
478 "#!/bin/sh\n\
479 # Generated by toride-ssh-key: answers SSH_ASKPASS prompts in order.\n\
480 n=$(cat '{count_path_str}' 2>/dev/null || echo 0)\n\
481 n=$((n+1))\n\
482 printf '%s' \"$n\" >'{count_path_str}'\n\
483 case \"$n\" in\n\
484 {arms}\
485 *) echo '{last_escaped}';;\n\
486 esac\n"
487 );
488
489 #[cfg(unix)]
490 {
491 let mut file = std::fs::OpenOptions::new()
492 .write(true)
493 .create_new(true)
494 .mode(0o700)
495 .open(&tmp_path)
496 .map_err(|e| {
497 Error::CommandFailed(format!(
498 "failed to create multi-askpass script {}: {e}",
499 tmp_path.display()
500 ))
501 })?;
502 file.write_all(script.as_bytes()).map_err(|e| {
503 Error::CommandFailed(format!(
504 "failed to write multi-askpass script {}: {e}",
505 tmp_path.display()
506 ))
507 })?;
508 let _ = file.sync_all();
509 drop(file);
510 std::fs::rename(&tmp_path, &script_path).map_err(|e| {
511 let _ = std::fs::remove_file(&tmp_path);
512 Error::CommandFailed(format!(
513 "failed to publish multi-askpass script {}: {e}",
514 script_path.display()
515 ))
516 })?;
517 }
518
519 #[cfg(not(unix))]
520 {
521 std::fs::write(&script_path, script.as_bytes()).map_err(|e| {
522 Error::CommandFailed(format!(
523 "failed to write multi-askpass script {}: {e}",
524 script_path.display()
525 ))
526 })?;
527 }
528
529 Ok(Self { script_path })
530 }
531
532 fn script_path(&self) -> &std::path::Path {
533 &self.script_path
534 }
535}
536
537impl Askpass for MultiAskpassHandler {
538 fn script_path(&self) -> &std::path::Path {
539 self.script_path()
540 }
541}
542
543impl Drop for MultiAskpassHandler {
544 fn drop(&mut self) {
545 if let Err(e) = std::fs::remove_file(&self.script_path) {
546 tracing::warn!(
547 "failed to remove multi-askpass script {}: {e}",
548 self.script_path.display()
549 );
550 }
551 let count_path = self.script_path.with_extension("cnt");
552 let _ = std::fs::remove_file(&count_path);
553 }
554}
555
556async fn remove_key_from_agent(
557 private_path: &std::path::Path,
558 runner: &dyn toride_ssh_core::CliRunner,
559) {
560 let Some(path_str) = private_path.to_str().map(str::to_owned) else {
561 tracing::warn!("invalid key path for ssh-add, skipping agent removal");
562 return;
563 };
564
565 if let Err(e) = runner.run("ssh-add", vec!["-d".to_owned(), path_str]).await {
566 tracing::warn!("ssh-add -d failed (key may not be in agent): {e}");
567 }
568}
569
570fn filter_config_lines(content: &str, ssh_dir_str: &str, key_name: &str) -> String {
571 let key_pattern_tilde = format!("~/.ssh/{key_name}");
572 let key_pattern_abs = format!("{ssh_dir_str}/{key_name}");
573
574 let cert_name = format!("{key_name}-cert.pub");
575 let cert_pattern_tilde = format!("~/.ssh/{cert_name}");
576 let cert_pattern_abs = format!("{ssh_dir_str}/{cert_name}");
577
578 let trailing_newline = content.ends_with('\n');
579 let line_ending = if content.contains("\r\n") {
580 "\r\n"
581 } else {
582 "\n"
583 };
584
585 let new_content: String = content
586 .lines()
587 .filter(|line| {
588 let trimmed = line.trim();
589 let keyword = trimmed.split_whitespace().next().unwrap_or("");
590
591 if keyword.eq_ignore_ascii_case("IdentityFile") {
592 let value = trimmed[keyword.len()..].trim();
593 let value = value.trim_matches('"').trim_matches('\'');
594 return value != key_pattern_tilde && value != key_pattern_abs && value != key_name;
595 }
596
597 if keyword.eq_ignore_ascii_case("CertificateFile") {
598 let value = trimmed[keyword.len()..].trim();
599 let value = value.trim_matches('"').trim_matches('\'');
600 return value != cert_pattern_tilde
601 && value != cert_pattern_abs
602 && value != cert_name;
603 }
604
605 true
606 })
607 .collect::<Vec<&str>>()
608 .join(line_ending);
609
610 if trailing_newline && !new_content.is_empty() {
611 format!("{new_content}{line_ending}")
612 } else {
613 new_content
614 }
615}
616
617async fn remove_from_config(paths: &SshPaths, key_name: &str) -> Result<()> {
618 let config_path = paths.config_path().to_path_buf();
619
620 if !config_path.exists() {
621 return Ok(());
622 }
623
624 let key_name_owned = key_name.to_owned();
625 let ssh_dir_str = paths
626 .ssh_dir()
627 .to_str()
628 .ok_or_else(|| {
629 Error::Io(std::io::Error::new(
630 std::io::ErrorKind::InvalidData,
631 format!(
632 "SSH directory path is not valid UTF-8: {}",
633 paths.ssh_dir().display()
634 ),
635 ))
636 })?
637 .to_owned();
638
639 tokio::task::spawn_blocking(move || {
640 let content = match std::fs::read_to_string(&config_path) {
641 Ok(c) => c,
642 Err(e) => {
643 tracing::warn!("cannot read config for cleanup: {e}");
644 return Ok(());
645 }
646 };
647
648 let final_content = filter_config_lines(&content, &ssh_dir_str, &key_name_owned);
649
650 if final_content != content {
651 let parent = config_path
652 .parent()
653 .unwrap_or_else(|| std::path::Path::new("."));
654 let tmp_path = parent.join(format!(
655 ".config.tmp.{}.{}",
656 std::process::id(),
657 std::time::SystemTime::now()
658 .duration_since(std::time::UNIX_EPOCH)
659 .unwrap_or_default()
660 .as_nanos()
661 ));
662 #[cfg(unix)]
666 {
667 use std::io::Write;
668 use std::os::unix::fs::OpenOptionsExt;
669 let mut file = std::fs::OpenOptions::new()
670 .write(true)
671 .create_new(true)
672 .mode(0o600)
673 .open(&tmp_path)
674 .map_err(|e| {
675 Error::ConfigWriteFailed(format!(
676 "failed to create temp config {}: {e}",
677 tmp_path.display()
678 ))
679 })?;
680 file.write_all(final_content.as_bytes()).map_err(|e| {
681 Error::ConfigWriteFailed(format!("failed to write temp config: {e}"))
682 })?;
683 let _ = file.sync_all();
684 drop(file);
685 }
686 #[cfg(not(unix))]
687 {
688 use std::io::Write;
689 let mut file = std::fs::OpenOptions::new()
690 .write(true)
691 .create_new(true)
692 .open(&tmp_path)
693 .map_err(|e| {
694 Error::ConfigWriteFailed(format!(
695 "failed to create temp config {}: {e}",
696 tmp_path.display()
697 ))
698 })?;
699 file.write_all(final_content.as_bytes()).map_err(|e| {
700 Error::ConfigWriteFailed(format!("failed to write temp config: {e}"))
701 })?;
702 let _ = file.sync_all();
703 drop(file);
704 }
705 if let Err(e) = std::fs::rename(&tmp_path, &config_path) {
706 let _ = std::fs::remove_file(&tmp_path);
707 return Err(Error::ConfigWriteFailed(format!(
708 "failed to rename config: {e}"
709 )));
710 }
711 }
712
713 Ok(())
714 })
715 .await
716 .map_err(|e| Error::TaskFailed(format!("config cleanup task failed: {e}")))?
717}
718
719#[cfg(test)]
720#[path = "mod.test.rs"]
721mod tests;