1use std::{
2 fs::{self, File, OpenOptions},
3 io::Read,
4 net::IpAddr,
5};
6
7use base64::{Engine as _, engine::general_purpose};
8use camino::{Utf8Path, Utf8PathBuf};
9use sha2::{Digest as _, Sha256};
10use thiserror::Error;
11
12const MAX_REMOTE_NAME_BYTES: usize = 64;
13const MAX_HOST_BYTES: usize = 253;
14const MAX_USER_BYTES: usize = 64;
15const MAX_KNOWN_HOSTS_BYTES: u64 = 16 * 1024;
16const MAX_HOST_KEY_BLOB_BYTES: usize = 16 * 1024;
17
18#[derive(Clone, Debug, Eq, Error, PartialEq)]
20pub enum SshConfigError {
21 #[error("SSH endpoint field `{field}` is invalid: {reason}")]
23 InvalidField {
24 field: &'static str,
26 reason: &'static str,
28 },
29 #[error("SSH endpoint port must be between 1 and 65535")]
31 InvalidPort,
32 #[error("SSH endpoint field `{field}` must be an absolute UTF-8 path")]
34 PathNotAbsolute {
35 field: &'static str,
37 },
38 #[error("SSH endpoint field `{field}` contains unsafe path syntax")]
40 UnsafePath {
41 field: &'static str,
43 },
44 #[error("SSH endpoint field `{field}` is not readable")]
46 PathUnreadable {
47 field: &'static str,
49 },
50 #[error("SSH endpoint field `{field}` must name a regular file, not a symlink")]
52 PathNotRegularFile {
53 field: &'static str,
55 },
56 #[error("SSH endpoint field `{field}` must not be empty")]
58 EmptyFile {
59 field: &'static str,
61 },
62 #[error("SSH identity file permissions must not allow group or other access")]
64 IdentityFilePermissions,
65 #[error("SSH identity path traverses an untrusted directory")]
67 IdentityPathPermissions,
68 #[error("SSH known-hosts file permissions must not allow group or other writes")]
70 KnownHostsFilePermissions,
71 #[error("SSH host-key fingerprint must be canonical SHA256 base64 without padding")]
73 InvalidHostKeyFingerprint,
74 #[error(
76 "SSH known-hosts file must contain exactly one supported key matching the endpoint and pinned fingerprint"
77 )]
78 InvalidKnownHosts,
79 #[error("SSH known-hosts snapshot could not be created securely")]
81 KnownHostsSnapshotFailed,
82}
83
84#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
86pub struct SshHostKeySha256(String);
87
88impl SshHostKeySha256 {
89 pub fn new(value: impl Into<String>) -> Result<Self, SshConfigError> {
95 let value = value.into();
96 let encoded = value
97 .strip_prefix("SHA256:")
98 .ok_or(SshConfigError::InvalidHostKeyFingerprint)?;
99 let digest = general_purpose::STANDARD_NO_PAD
100 .decode(encoded)
101 .map_err(|_| SshConfigError::InvalidHostKeyFingerprint)?;
102 if digest.len() != 32 || general_purpose::STANDARD_NO_PAD.encode(&digest) != encoded {
103 return Err(SshConfigError::InvalidHostKeyFingerprint);
104 }
105 Ok(Self(value))
106 }
107
108 pub fn as_str(&self) -> &str {
110 &self.0
111 }
112}
113
114#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
116pub struct SshRemoteName(String);
117
118impl SshRemoteName {
119 pub fn new(value: impl Into<String>) -> Result<Self, SshConfigError> {
125 let value = value.into();
126 validate_atom("remote_name", &value, MAX_REMOTE_NAME_BYTES, true)?;
127 Ok(Self(value))
128 }
129
130 pub fn as_str(&self) -> &str {
132 &self.0
133 }
134}
135
136#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
138pub struct SshHost(String);
139
140impl SshHost {
141 pub fn new(value: impl Into<String>) -> Result<Self, SshConfigError> {
147 let value = value.into();
148 if value.is_empty() || value.len() > MAX_HOST_BYTES {
149 return Err(invalid("host", "value length is outside 1..=253 bytes"));
150 }
151 if value.parse::<IpAddr>().is_ok() || valid_dns_name(&value) {
152 Ok(Self(value))
153 } else {
154 Err(invalid(
155 "host",
156 "value must be an ASCII DNS name or an IP literal",
157 ))
158 }
159 }
160
161 pub fn as_str(&self) -> &str {
163 &self.0
164 }
165}
166
167#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
169pub struct SshUser(String);
170
171impl SshUser {
172 pub fn new(value: impl Into<String>) -> Result<Self, SshConfigError> {
178 let value = value.into();
179 validate_atom("user", &value, MAX_USER_BYTES, false)?;
180 Ok(Self(value))
181 }
182
183 pub fn as_str(&self) -> &str {
185 &self.0
186 }
187}
188
189#[derive(Clone, Debug, Eq, PartialEq)]
191pub struct SshEndpointConfig {
192 remote_name: SshRemoteName,
193 host: SshHost,
194 user: SshUser,
195 port: u16,
196 known_hosts_file: Utf8PathBuf,
197 host_key_sha256: SshHostKeySha256,
198 identity_file: Option<Utf8PathBuf>,
199}
200
201#[derive(Debug)]
202pub(crate) struct SshIdentityFileGuard {
203 path: Utf8PathBuf,
204 file: File,
205}
206
207impl SshIdentityFileGuard {
208 pub(crate) fn revalidate(&self) -> Result<(), SshConfigError> {
209 let opened_metadata = self
210 .file
211 .metadata()
212 .map_err(|_| SshConfigError::PathUnreadable {
213 field: "identity_file",
214 })?;
215 validate_identity_metadata(&opened_metadata)?;
216 recheck_open_file_identity(&self.path, &opened_metadata, "identity_file")?;
217 #[cfg(unix)]
218 validate_unix_identity_path(&self.path, &opened_metadata)?;
219 Ok(())
220 }
221}
222
223impl SshEndpointConfig {
224 pub fn new(
233 remote_name: SshRemoteName,
234 host: SshHost,
235 user: SshUser,
236 port: u16,
237 known_hosts_file: Utf8PathBuf,
238 host_key_sha256: SshHostKeySha256,
239 identity_file: Option<Utf8PathBuf>,
240 ) -> Result<Self, SshConfigError> {
241 if port == 0 {
242 return Err(SshConfigError::InvalidPort);
243 }
244 let config = Self {
245 remote_name,
246 host,
247 user,
248 port,
249 known_hosts_file,
250 host_key_sha256,
251 identity_file,
252 };
253 config.validate_files()?;
254 Ok(config)
255 }
256
257 pub fn validate_files(&self) -> Result<(), SshConfigError> {
263 self.validated_known_hosts_bytes()?;
264 drop(self.open_identity_file_guard()?);
265 Ok(())
266 }
267
268 pub(crate) fn open_identity_file_guard(
269 &self,
270 ) -> Result<Option<SshIdentityFileGuard>, SshConfigError> {
271 self.identity_file
272 .as_ref()
273 .map(|path| {
274 let file = open_identity_no_follow(path)?;
275 let guard = SshIdentityFileGuard {
276 path: path.clone(),
277 file,
278 };
279 guard.revalidate()?;
280 Ok(guard)
281 })
282 .transpose()
283 }
284
285 pub(crate) fn validated_known_hosts_bytes(&self) -> Result<Vec<u8>, SshConfigError> {
286 let bytes = read_known_hosts_no_follow(&self.known_hosts_file)?;
287 validate_known_hosts_bytes(
288 &bytes,
289 &expected_host_token(&self.host, self.port),
290 &self.host_key_sha256,
291 )
292 }
293
294 pub fn remote_name(&self) -> &SshRemoteName {
296 &self.remote_name
297 }
298
299 pub fn host(&self) -> &SshHost {
301 &self.host
302 }
303
304 pub fn user(&self) -> &SshUser {
306 &self.user
307 }
308
309 pub const fn port(&self) -> u16 {
311 self.port
312 }
313
314 pub fn known_hosts_file(&self) -> &Utf8Path {
316 &self.known_hosts_file
317 }
318
319 pub fn host_key_sha256(&self) -> &SshHostKeySha256 {
321 &self.host_key_sha256
322 }
323
324 pub fn identity_file(&self) -> Option<&Utf8Path> {
326 self.identity_file.as_deref()
327 }
328}
329
330fn expected_host_token(host: &SshHost, port: u16) -> String {
331 if port == 22 {
332 host.as_str().to_owned()
333 } else {
334 format!("[{}]:{port}", host.as_str())
335 }
336}
337
338fn read_known_hosts_no_follow(path: &Utf8Path) -> Result<Vec<u8>, SshConfigError> {
339 validate_sensitive_path(path, "known_hosts_file")?;
340 let mut file = open_no_follow(path, "known_hosts_file")?;
341 let opened_metadata = file
342 .metadata()
343 .map_err(|_| SshConfigError::PathUnreadable {
344 field: "known_hosts_file",
345 })?;
346 validate_known_hosts_metadata(&opened_metadata)?;
347 recheck_open_file_identity(path, &opened_metadata, "known_hosts_file")?;
348 let limit = MAX_KNOWN_HOSTS_BYTES.saturating_add(1);
349 let maximum_capacity = usize::try_from(MAX_KNOWN_HOSTS_BYTES).unwrap_or(usize::MAX);
350 let mut bytes = Vec::with_capacity(
351 usize::try_from(opened_metadata.len())
352 .unwrap_or(usize::MAX)
353 .min(maximum_capacity),
354 );
355 file.by_ref()
356 .take(limit)
357 .read_to_end(&mut bytes)
358 .map_err(|_| SshConfigError::PathUnreadable {
359 field: "known_hosts_file",
360 })?;
361 if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_KNOWN_HOSTS_BYTES {
362 return Err(SshConfigError::InvalidKnownHosts);
363 }
364 recheck_open_file_identity(path, &opened_metadata, "known_hosts_file")?;
365 Ok(bytes)
366}
367
368fn open_identity_no_follow(path: &Utf8Path) -> Result<File, SshConfigError> {
369 validate_sensitive_path(path, "identity_file")?;
370 let file = open_no_follow(path, "identity_file")?;
371 let metadata = file
372 .metadata()
373 .map_err(|_| SshConfigError::PathUnreadable {
374 field: "identity_file",
375 })?;
376 validate_identity_metadata(&metadata)?;
377 recheck_open_file_identity(path, &metadata, "identity_file")?;
378 #[cfg(unix)]
379 validate_unix_identity_path(path, &metadata)?;
380 Ok(file)
381}
382
383fn recheck_open_file_identity(
384 path: &Utf8Path,
385 opened_metadata: &fs::Metadata,
386 field: &'static str,
387) -> Result<(), SshConfigError> {
388 let path_metadata =
389 fs::symlink_metadata(path).map_err(|_| SshConfigError::PathUnreadable { field })?;
390 if path_metadata.file_type().is_symlink() || !path_metadata.file_type().is_file() {
391 return Err(SshConfigError::PathNotRegularFile { field });
392 }
393 if !same_open_file_identity(opened_metadata, &path_metadata) {
394 return Err(SshConfigError::PathUnreadable { field });
395 }
396 Ok(())
397}
398
399fn open_no_follow(path: &Utf8Path, field: &'static str) -> Result<File, SshConfigError> {
400 let preflight =
401 fs::symlink_metadata(path).map_err(|_| SshConfigError::PathUnreadable { field })?;
402 if preflight.file_type().is_symlink() || !preflight.file_type().is_file() {
403 return Err(SshConfigError::PathNotRegularFile { field });
404 }
405 let mut options = OpenOptions::new();
406 options.read(true);
407 #[cfg(unix)]
408 {
409 use std::os::unix::fs::OpenOptionsExt as _;
410 options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK);
411 }
412 #[cfg(windows)]
413 {
414 use std::os::windows::fs::OpenOptionsExt as _;
415 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
416 const FILE_SHARE_READ: u32 = 0x0000_0001;
417 options
418 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
419 .share_mode(FILE_SHARE_READ);
420 }
421 options
422 .open(path)
423 .map_err(|_| SshConfigError::PathUnreadable { field })
424}
425
426fn validate_known_hosts_metadata(metadata: &fs::Metadata) -> Result<(), SshConfigError> {
427 if !metadata.file_type().is_file() {
428 return Err(SshConfigError::PathNotRegularFile {
429 field: "known_hosts_file",
430 });
431 }
432 if metadata.len() == 0 {
433 return Err(SshConfigError::EmptyFile {
434 field: "known_hosts_file",
435 });
436 }
437 if metadata.len() > MAX_KNOWN_HOSTS_BYTES {
438 return Err(SshConfigError::InvalidKnownHosts);
439 }
440 #[cfg(unix)]
441 {
442 use std::os::unix::fs::MetadataExt as _;
443 if metadata.mode() & 0o022 != 0 {
444 return Err(SshConfigError::KnownHostsFilePermissions);
445 }
446 }
447 Ok(())
448}
449
450#[cfg(unix)]
451fn same_open_file_identity(opened: &fs::Metadata, path: &fs::Metadata) -> bool {
452 use std::os::unix::fs::MetadataExt as _;
453 opened.dev() == path.dev() && opened.ino() == path.ino()
454}
455
456#[cfg(not(unix))]
457fn same_open_file_identity(opened: &fs::Metadata, path: &fs::Metadata) -> bool {
458 opened.file_type().is_file() && path.file_type().is_file() && opened.len() == path.len()
459}
460
461fn validate_known_hosts_bytes(
462 bytes: &[u8],
463 expected_host: &str,
464 expected_fingerprint: &SshHostKeySha256,
465) -> Result<Vec<u8>, SshConfigError> {
466 let text = std::str::from_utf8(bytes).map_err(|_| SshConfigError::InvalidKnownHosts)?;
467 let entries = text
468 .lines()
469 .map(str::trim)
470 .filter(|line| !line.is_empty() && !line.starts_with('#'))
471 .collect::<Vec<_>>();
472 let [entry] = entries.as_slice() else {
473 return Err(SshConfigError::InvalidKnownHosts);
474 };
475 let mut fields = entry.split_ascii_whitespace();
476 let host = fields.next().ok_or(SshConfigError::InvalidKnownHosts)?;
477 let key_type = fields.next().ok_or(SshConfigError::InvalidKnownHosts)?;
478 let encoded_key = fields.next().ok_or(SshConfigError::InvalidKnownHosts)?;
479 if host != expected_host || !supported_host_key_type(key_type) {
480 return Err(SshConfigError::InvalidKnownHosts);
481 }
482 let key_blob = general_purpose::STANDARD
483 .decode(encoded_key)
484 .map_err(|_| SshConfigError::InvalidKnownHosts)?;
485 if key_blob.is_empty()
486 || key_blob.len() > MAX_HOST_KEY_BLOB_BYTES
487 || !key_blob_type_matches(&key_blob, key_type)
488 {
489 return Err(SshConfigError::InvalidKnownHosts);
490 }
491 let digest = Sha256::digest(&key_blob);
492 let fingerprint = format!("SHA256:{}", general_purpose::STANDARD_NO_PAD.encode(digest));
493 if fingerprint != expected_fingerprint.as_str() {
494 return Err(SshConfigError::InvalidKnownHosts);
495 }
496 Ok(format!(
497 "{host} {key_type} {}\n",
498 general_purpose::STANDARD.encode(key_blob)
499 )
500 .into_bytes())
501}
502
503fn supported_host_key_type(key_type: &str) -> bool {
504 matches!(
505 key_type,
506 "ssh-ed25519"
507 | "ecdsa-sha2-nistp256"
508 | "ecdsa-sha2-nistp384"
509 | "ecdsa-sha2-nistp521"
510 | "ssh-rsa"
511 | "sk-ssh-ed25519@openssh.com"
512 | "sk-ecdsa-sha2-nistp256@openssh.com"
513 )
514}
515
516fn key_blob_type_matches(blob: &[u8], expected_type: &str) -> bool {
517 let Some(length_bytes) = blob.get(..4) else {
518 return false;
519 };
520 let length = u32::from_be_bytes(
521 length_bytes
522 .try_into()
523 .expect("four-byte slice has exact array length"),
524 ) as usize;
525 let Some(key_type) = blob.get(4..4_usize.saturating_add(length)) else {
526 return false;
527 };
528 key_type == expected_type.as_bytes() && blob.len() > 4_usize.saturating_add(length)
529}
530
531fn validate_atom(
532 field: &'static str,
533 value: &str,
534 maximum: usize,
535 require_alphanumeric_first: bool,
536) -> Result<(), SshConfigError> {
537 if value.is_empty() || value.len() > maximum {
538 return Err(invalid(field, "value length is outside the accepted bound"));
539 }
540 let mut bytes = value.bytes();
541 let first = bytes.next().expect("non-empty value has a first byte");
542 if !(first.is_ascii_alphanumeric() || (!require_alphanumeric_first && first == b'_')) {
543 return Err(invalid(field, "value has an unsafe leading byte"));
544 }
545 if !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) {
546 return Err(invalid(
547 field,
548 "value may contain only ASCII letters, digits, dot, underscore, and hyphen",
549 ));
550 }
551 Ok(())
552}
553
554fn valid_dns_name(value: &str) -> bool {
555 if !value.is_ascii() || value.bytes().any(|byte| byte.is_ascii_control()) {
556 return false;
557 }
558 let name = value.strip_suffix('.').unwrap_or(value);
559 !name.is_empty()
560 && name.split('.').all(|label| {
561 !label.is_empty()
562 && label.len() <= 63
563 && label
564 .bytes()
565 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
566 && label
567 .as_bytes()
568 .first()
569 .is_some_and(u8::is_ascii_alphanumeric)
570 && label
571 .as_bytes()
572 .last()
573 .is_some_and(u8::is_ascii_alphanumeric)
574 })
575}
576
577fn validate_identity_metadata(metadata: &fs::Metadata) -> Result<(), SshConfigError> {
578 if !metadata.file_type().is_file() {
579 return Err(SshConfigError::PathNotRegularFile {
580 field: "identity_file",
581 });
582 }
583 if metadata.len() == 0 {
584 return Err(SshConfigError::EmptyFile {
585 field: "identity_file",
586 });
587 }
588 #[cfg(unix)]
589 {
590 use std::os::unix::fs::MetadataExt as _;
591 if metadata.mode() & 0o077 != 0 {
592 return Err(SshConfigError::IdentityFilePermissions);
593 }
594 if metadata.nlink() != 1 {
595 return Err(SshConfigError::IdentityPathPermissions);
596 }
597 }
598 Ok(())
599}
600
601#[cfg(unix)]
602fn validate_unix_identity_path(
603 path: &Utf8Path,
604 opened_metadata: &fs::Metadata,
605) -> Result<(), SshConfigError> {
606 use std::os::unix::fs::MetadataExt as _;
607
608 let canonical = fs::canonicalize(path).map_err(|_| SshConfigError::PathUnreadable {
609 field: "identity_file",
610 })?;
611 let canonical_metadata =
612 fs::symlink_metadata(&canonical).map_err(|_| SshConfigError::PathUnreadable {
613 field: "identity_file",
614 })?;
615 if canonical_metadata.dev() != opened_metadata.dev()
616 || canonical_metadata.ino() != opened_metadata.ino()
617 {
618 return Err(SshConfigError::PathUnreadable {
619 field: "identity_file",
620 });
621 }
622 validate_unix_identity_ancestors(path.as_std_path(), opened_metadata.uid())?;
623 validate_unix_identity_ancestors(&canonical, opened_metadata.uid())
624}
625
626#[cfg(unix)]
627fn validate_unix_identity_ancestors(
628 path: &std::path::Path,
629 key_uid: u32,
630) -> Result<(), SshConfigError> {
631 use std::os::unix::fs::MetadataExt as _;
632
633 for ancestor in path.ancestors().skip(1) {
634 let metadata =
635 fs::symlink_metadata(ancestor).map_err(|_| SshConfigError::IdentityPathPermissions)?;
636 let trusted_owner = metadata.uid() == key_uid || metadata.uid() == 0;
637 if metadata.file_type().is_symlink() {
638 if !trusted_owner {
639 return Err(SshConfigError::IdentityPathPermissions);
640 }
641 continue;
642 }
643 let mode = metadata.mode();
644 let writable_by_another_principal = mode & 0o022 != 0;
645 #[allow(clippy::useless_conversion)]
646 let sticky = mode & u32::from(libc::S_ISVTX) != 0;
647 if !metadata.file_type().is_dir()
648 || !trusted_owner
649 || (writable_by_another_principal && !sticky)
650 {
651 return Err(SshConfigError::IdentityPathPermissions);
652 }
653 }
654 Ok(())
655}
656
657fn validate_sensitive_path(path: &Utf8Path, field: &'static str) -> Result<(), SshConfigError> {
658 if !path.is_absolute() {
659 return Err(SshConfigError::PathNotAbsolute { field });
660 }
661 if path
662 .as_str()
663 .chars()
664 .any(|character| character.is_control() || matches!(character, '%' | '$'))
665 {
666 return Err(SshConfigError::UnsafePath { field });
667 }
668 Ok(())
669}
670
671const fn invalid(field: &'static str, reason: &'static str) -> SshConfigError {
672 SshConfigError::InvalidField { field, reason }
673}