diff --git a/Cargo.lock b/Cargo.lock index 7ba42ef..e62b9ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -290,7 +290,7 @@ dependencies = [ "p12", "p256 0.13.2", "pem", - "pkcs1", + "pkcs1 0.7.5", "pkcs8 0.10.2", "plist", "rand 0.8.7", @@ -299,7 +299,7 @@ dependencies = [ "regex", "reqwest 0.12.28", "ring", - "rsa", + "rsa 0.9.10", "scroll", "security-framework 2.11.1", "security-framework-sys", @@ -2020,6 +2020,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint 0.7.5", + "rand_core 0.10.1", +] + [[package]] name = "cryptographic-message-syntax" version = "0.27.0" @@ -2807,7 +2817,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3834,7 +3844,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1" dependencies = [ "bstr", - "hashbrown 0.15.5", + "hashbrown 0.17.1", ] [[package]] @@ -5378,6 +5388,7 @@ dependencies = [ "smallvec", "tempfile", "tokio", + "tokio-stream", "toml 0.9.12+spec-1.1.0", "url", "windows-native-keyring-store", @@ -5463,7 +5474,7 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6947,7 +6958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -7354,7 +7365,7 @@ dependencies = [ "rand 0.8.7", "replace_with", "ripemd", - "rsa", + "rsa 0.9.10", "sha1 0.10.7", "sha1-checked", "sha2 0.10.9", @@ -7484,6 +7495,16 @@ dependencies = [ "spki 0.7.3", ] +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der 0.8.1", + "spki 0.8.0", +] + [[package]] name = "pkcs5" version = "0.8.1" @@ -7857,7 +7878,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8446,7 +8467,7 @@ dependencies = [ "num-bigint-dig", "num-integer", "num-traits", - "pkcs1", + "pkcs1 0.7.5", "pkcs8 0.10.2", "rand_core 0.6.4", "signature 2.2.0", @@ -8455,6 +8476,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rsa" +version = "0.10.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" +dependencies = [ + "const-oid 0.10.2", + "crypto-bigint 0.7.5", + "crypto-primes", + "digest 0.11.3", + "pkcs1 0.8.0-rc.4", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sha2 0.11.0", + "signature 3.0.0", + "spki 0.8.0", + "zeroize", +] + [[package]] name = "rtoolbox" version = "0.0.5" @@ -8508,12 +8548,14 @@ dependencies = [ "p521 0.14.0", "pageant", "pbkdf2", + "pkcs1 0.8.0-rc.4", "pkcs5", "pkcs8 0.11.0", "polyval 0.7.3", "rand 0.10.2", "rand_core 0.10.1", "ring", + "rsa 0.10.0-rc.18", "russh-cryptovec", "russh-util", "salsa20", @@ -8623,7 +8665,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8713,7 +8755,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9574,6 +9616,7 @@ checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e" dependencies = [ "base64ct", "bytes", + "crypto-bigint 0.7.5", "ctutils", "digest 0.11.3", "pem-rfc7468 1.0.0", @@ -9596,6 +9639,7 @@ dependencies = [ "p384 0.14.0", "p521 0.14.0", "rand_core 0.10.1", + "rsa 0.10.0-rc.18", "sec1 0.8.1", "sha1 0.11.0", "sha2 0.11.0", @@ -9770,7 +9814,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10040,6 +10084,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -11198,7 +11253,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8aa507d..9d7fc60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ reqwest = { version = "0.13", default-features = false, features = ["blocking", rfd = { version = "0.17", default-features = false } rqrr = { version = "0.10", default-features = false } rpassword = "7.5" -russh = { version = "0.63.1", default-features = false, features = ["ring"] } +russh = { version = "0.63.1", default-features = false, features = ["ring", "rsa"] } ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color", "unstable-rendered-line-info"] } security-framework = "3.7" secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] } @@ -63,7 +63,8 @@ sha2 = "0.10" shlex = "1.3" simple-file-manifest = "0.11" toml = "0.9" -tokio = { version = "1.53.1", default-features = false, features = ["io-util", "net", "rt-multi-thread", "sync", "time"] } +tokio = { version = "1.53.1", default-features = false, features = ["io-util", "macros", "net", "rt-multi-thread", "sync", "time"] } +tokio-stream = { version = "0.1", features = ["net"] } uniffi = "0.32" url = { version = "2.5", default-features = false } windows-native-keyring-store = { version = "1.1", default-features = false } diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index 45260c4..5bad378 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -1,6 +1,6 @@ # Dependency and license review -Reviewed 2026-08-16. The OpenPGP backend decision and release packaging review +Reviewed 2026-08-25. The OpenPGP backend decision and release packaging review are complete. IronStorage is licensed under the MIT License. Every package in the Rust workspace declares that license, and the iPhone release bundles the exact third-party license files from its Cargo dependency graph. @@ -23,6 +23,7 @@ The current direct dependencies are: | [rfd 0.17](https://crates.io/crates/rfd/0.17.2), [ashpd 0.13](https://crates.io/crates/ashpd/0.13.13) | Native macOS/Windows folder picker and Linux XDG portal folder picker | MIT | | [gix 0.86](https://crates.io/crates/gix/0.86.0), [gix-config 0.59](https://crates.io/crates/gix-config/0.59.0) | Embedded Git objects, index, references, fetch, and merge | MIT OR Apache-2.0 | | [reqwest 0.13](https://crates.io/crates/reqwest/0.13.4) | HTTPS smart-Git transport with Rustls | MIT OR Apache-2.0 | +| [russh 0.63](https://crates.io/crates/russh/0.63.1), [Tokio 1.53](https://crates.io/crates/tokio/1.53.1) | Optional pure-Rust SSH transport, key/agent authentication, and protocol runtime | Apache-2.0 OR MIT; MIT | | [flate2 1.1](https://crates.io/crates/flate2/1.1.9), [sha1 0.10](https://crates.io/crates/sha1/0.10.7) | Git pack compression and checksums | MIT OR Apache-2.0 | | [pgp 0.20](https://crates.io/crates/pgp/0.20.0) | Embedded OpenPGP key import, encryption, decryption, and signatures | MIT OR Apache-2.0 | | [keepass 0.13](https://crates.io/crates/keepass/0.13.20) | Pure-Rust KeePass KDBX 3/4 decryption and parsing for direct imports | MIT | @@ -51,6 +52,7 @@ decision. | Alternative GPG implementation | [`sequoia-openpgp` 2.4](https://crates.io/crates/sequoia-openpgp/2.4.1) | LGPL-2.0-or-later | Hold in reserve. Its default Nettle backend is native; its Rust backend exists, but the LGPL adds distribution work we can avoid. | | GnuPG integration | [`gpgme` 0.11](https://crates.io/crates/gpgme/0.11.0) | LGPL-2.1 | Reject: native GPGME/GnuPG integration and GPG engine processes violate the portability and no-process requirements. | | Local Git plus HTTPS fetch/push | [`gix` 0.86](https://crates.io/crates/gix/0.86.0) | MIT OR Apache-2.0 | Selected with default features off and `blocking-http-transport-reqwest-rust-tls`; accept HTTPS remotes only, supply credentials directly, and use the storage-owned receive-pack implementation for push. | +| SSH client and agent protocol | [`russh` 0.63](https://crates.io/crates/russh/0.63.1) | Apache-2.0 OR MIT | Selected behind the optional `ssh` feature with default features off and Ring/RSA enabled. Storage restricts host and authentication signatures to modern algorithms, selects exactly one configured key or agent fingerprint, and never invokes OpenSSH or another process. | | Git FFI fallback | [`git2` 0.21](https://crates.io/crates/git2/0.21.0) | MIT OR Apache-2.0 | Reject for now; it links libgit2 and is unnecessary for the HTTPS-only scope. | | Server/application credentials | [`keyring-core` 1.0](https://crates.io/crates/keyring-core/1.0.0), [`apple-native-keyring-store` 1.0](https://crates.io/crates/apple-native-keyring-store/1.0.2), [`windows-native-keyring-store` 1.1](https://crates.io/crates/windows-native-keyring-store/1.1.0), [`zbus-secret-service-keyring-store` 1.0](https://crates.io/crates/zbus-secret-service-keyring-store/1.0.0) | MIT OR Apache-2.0 | Selected behind target-specific dependencies. Apple supports legacy Keychain plus protected-data user presence, Windows uses Credential Manager, and Linux uses Secret Service with the Tokio/Rust-crypto feature. | | Secret values in memory | [`secrecy` 0.10](https://crates.io/crates/secrecy/0.10.3), [`zeroize` 1.9](https://crates.io/crates/zeroize/1.9.0) | MIT OR Apache-2.0 | `zeroize` selected for the storage-owned redacted byte type; consider `secrecy` only when typed exposure controls add value. | @@ -68,6 +70,17 @@ With this path, the central crate needs no third-party native GPG, Git, OTP, or QR library. Apple Security/LocalAuthentication, Windows Credential Manager, Linux Secret Service, and Apple camera APIs remain operating-system boundaries. +## Security audit + +`cargo audit` 0.22.2 was run against the 2026-08-25 lockfile. It reports +RUSTSEC-2023-0071 for `rsa` 0.9.10 (through `pgp`) and 0.10.0-rc.18 (through +Russh); no fixed release is available. The Marvin advisory concerns RSA +decryption timing. The SSH dependency is used for signatures, while the +existing OpenPGP dependency can decrypt RSA material, so the finding remains a +tracked risk rather than an ignored or suppressed advisory. The audit also +reports allowed unmaintained/unsound warnings in transitive UI and utility +dependencies; none are hidden by an audit configuration. + ## Release packaging `ironstorage-apple-release licenses` traverses the iPhone Rust library's locked, diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index eb29062..dbf13a3 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -94,3 +94,4 @@ nix = { version = "0.31", features = ["fs"] } rand_chacha = "0.3" smallvec = "1.15" tempfile = "3" +tokio-stream.workspace = true diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs index c5e0a6a..85607f4 100644 --- a/crates/storage/src/config.rs +++ b/crates/storage/src/config.rs @@ -422,7 +422,7 @@ impl Config { Some(remote) => { git.insert( "remotes".to_owned(), - toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote))]), + toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote)?)]), ); } None => { @@ -497,7 +497,7 @@ impl Config { let mut git = toml::Table::new(); git.insert( "remotes".to_owned(), - toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote))]), + toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote)?)]), ); root.insert("git".to_owned(), toml::Value::Table(git)); } @@ -734,7 +734,7 @@ impl Config { } } -fn git_remote_document(remote: &GitRemote) -> toml::Table { +fn git_remote_document(remote: &GitRemote) -> Result { let mut configured = toml::Table::new(); configured.insert( "name".to_owned(), @@ -754,7 +754,39 @@ fn git_remote_document(remote: &GitRemote) -> toml::Table { toml::Value::String(application_id.as_str().to_owned()), ); } - configured + if let Some(authentication) = remote.ssh_authentication() { + match authentication.identity() { + SshIdentitySource::KeyFile(path) => { + configured.insert( + "ssh_identity_file".to_owned(), + toml::Value::String(path_text(path, "git.remotes.ssh_identity_file")?), + ); + } + SshIdentitySource::Agent { + fingerprint, + socket, + } => { + configured.insert( + "ssh_agent_fingerprint".to_owned(), + toml::Value::String(fingerprint.to_string()), + ); + if let Some(socket) = socket { + configured.insert( + "ssh_agent_socket".to_owned(), + toml::Value::String(path_text(socket, "git.remotes.ssh_agent_socket")?), + ); + } + } + } + configured.insert( + "ssh_known_hosts_file".to_owned(), + toml::Value::String(path_text( + authentication.known_hosts_file(), + "git.remotes.ssh_known_hosts_file", + )?), + ); + } + Ok(configured) } /// Deterministic path context for configuration loading. @@ -940,6 +972,114 @@ pub struct SshEndpoint { path: SshRepositoryPath, } +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct SshFingerprint(String); + +impl SshFingerprint { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + let encoded = value.strip_prefix("SHA256:").ok_or(InvalidRemoteEndpoint)?; + if encoded.len() != 43 { + return Err(InvalidRemoteEndpoint); + } + let digest = data_encoding::BASE64_NOPAD + .decode(encoded.as_bytes()) + .map_err(|_| InvalidRemoteEndpoint)?; + if digest.len() != 32 { + return Err(InvalidRemoteEndpoint); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for SshFingerprint { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SshIdentitySource { + KeyFile(PathBuf), + Agent { + fingerprint: SshFingerprint, + socket: Option, + }, +} + +impl SshIdentitySource { + pub fn key_file(&self) -> Option<&Path> { + match self { + Self::KeyFile(path) => Some(path), + Self::Agent { .. } => None, + } + } + + pub fn agent_fingerprint(&self) -> Option<&SshFingerprint> { + match self { + Self::KeyFile(_) => None, + Self::Agent { fingerprint, .. } => Some(fingerprint), + } + } + + pub fn agent_socket(&self) -> Option<&Path> { + match self { + Self::Agent { socket, .. } => socket.as_deref(), + Self::KeyFile(_) => None, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SshRemoteAuthentication { + identity: SshIdentitySource, + known_hosts_file: PathBuf, +} + +impl SshRemoteAuthentication { + pub fn key_file( + identity_file: PathBuf, + known_hosts_file: PathBuf, + ) -> Result { + validate_standalone_ssh_path(&identity_file, "git.remotes.ssh_identity_file")?; + validate_standalone_ssh_path(&known_hosts_file, "git.remotes.ssh_known_hosts_file")?; + Ok(Self { + identity: SshIdentitySource::KeyFile(identity_file), + known_hosts_file, + }) + } + + pub fn agent( + fingerprint: SshFingerprint, + socket: Option, + known_hosts_file: PathBuf, + ) -> Result { + if let Some(socket) = &socket { + validate_standalone_ssh_path(socket, "git.remotes.ssh_agent_socket")?; + } + validate_standalone_ssh_path(&known_hosts_file, "git.remotes.ssh_known_hosts_file")?; + Ok(Self { + identity: SshIdentitySource::Agent { + fingerprint, + socket, + }, + known_hosts_file, + }) + } + + pub const fn identity(&self) -> &SshIdentitySource { + &self.identity + } + + pub fn known_hosts_file(&self) -> &Path { + &self.known_hosts_file + } +} + impl SshEndpoint { pub fn user(&self) -> Option<&str> { self.user.as_deref() @@ -1122,7 +1262,7 @@ enum GitRemoteCredentials { server_id: ServerId, application_id: ApplicationId, }, - Ssh, + Ssh(Option), } impl GitRemote { @@ -1132,25 +1272,49 @@ impl GitRemote { server_id: impl Into, application_id: impl Into, ) -> Result { - let mut remotes = validate_remotes(vec![RawGitRemote { - name: name.into(), - url: url.into(), - server_id: Some(server_id.into()), - application_id: Some(application_id.into()), - }])?; + let mut remotes = validate_remotes( + vec![RawGitRemote { + name: name.into(), + url: url.into(), + server_id: Some(server_id.into()), + application_id: Some(application_id.into()), + ssh_identity_file: None, + ssh_agent_fingerprint: None, + ssh_agent_socket: None, + ssh_known_hosts_file: None, + }], + None, + )?; Ok(remotes.remove(0)) } pub fn ssh(name: impl Into, url: impl Into) -> Result { - let mut remotes = validate_remotes(vec![RawGitRemote { - name: name.into(), - url: url.into(), - server_id: None, - application_id: None, - }])?; + let mut remotes = validate_remotes( + vec![RawGitRemote { + name: name.into(), + url: url.into(), + server_id: None, + application_id: None, + ssh_identity_file: None, + ssh_agent_fingerprint: None, + ssh_agent_socket: None, + ssh_known_hosts_file: None, + }], + None, + )?; Ok(remotes.remove(0)) } + pub fn ssh_with_authentication( + name: impl Into, + url: impl Into, + authentication: SshRemoteAuthentication, + ) -> Result { + let mut remote = Self::ssh(name, url)?; + remote.credentials = GitRemoteCredentials::Ssh(Some(authentication)); + Ok(remote) + } + pub fn name(&self) -> &RemoteName { &self.name } @@ -1169,7 +1333,14 @@ impl GitRemote { server_id, application_id, } => Some((server_id, application_id)), - GitRemoteCredentials::Ssh => None, + GitRemoteCredentials::Ssh(_) => None, + } + } + + pub const fn ssh_authentication(&self) -> Option<&SshRemoteAuthentication> { + match &self.credentials { + GitRemoteCredentials::Ssh(authentication) => authentication.as_ref(), + GitRemoteCredentials::Https { .. } => None, } } } @@ -1412,6 +1583,10 @@ struct RawGitRemote { url: String, server_id: Option, application_id: Option, + ssh_identity_file: Option, + ssh_agent_fingerprint: Option, + ssh_agent_socket: Option, + ssh_known_hosts_file: Option, } fn validate_config( @@ -1512,7 +1687,7 @@ fn validate_config( }); } }; - let git_remotes = validate_remotes(raw.git.remotes)?; + let git_remotes = validate_remotes(raw.git.remotes, Some(base))?; Ok(Config { source, @@ -1664,7 +1839,10 @@ fn parse_environment_editor( .map_err(|()| EditorError::InvalidCommand { source: variable }) } -fn validate_remotes(raw: Vec) -> Result, ConfigError> { +fn validate_remotes( + raw: Vec, + config_base: Option<&Path>, +) -> Result, ConfigError> { let mut names = BTreeSet::new(); let mut references = BTreeSet::new(); let mut remotes = Vec::with_capacity(raw.len()); @@ -1679,6 +1857,15 @@ fn validate_remotes(raw: Vec) -> Result, ConfigErro RemoteEndpoint::parse(&remote.url).map_err(|_| ConfigError::InvalidRemoteUrl { name: name.0.clone(), })?; + let has_ssh_fields = remote.ssh_identity_file.is_some() + || remote.ssh_agent_fingerprint.is_some() + || remote.ssh_agent_socket.is_some() + || remote.ssh_known_hosts_file.is_some(); + if matches!(&endpoint, RemoteEndpoint::Https(_)) && has_ssh_fields { + return Err(ConfigError::InvalidField { + field: "git.remotes.ssh_authentication", + }); + } let credentials = match (&endpoint, remote.server_id, remote.application_id) { (RemoteEndpoint::Https(_), Some(server_id), Some(application_id)) => { let server_id = ServerId(validate_identifier("git.remotes.server_id", server_id)?); @@ -1704,7 +1891,60 @@ fn validate_remotes(raw: Vec) -> Result, ConfigErro field: "git.remotes.application_id", }); } - (RemoteEndpoint::Ssh(_), None, None) => GitRemoteCredentials::Ssh, + (RemoteEndpoint::Ssh(_), None, None) => { + let Some(base) = config_base else { + if has_ssh_fields { + return Err(ConfigError::InvalidField { + field: "git.remotes.ssh_authentication", + }); + } + remotes.push(GitRemote { + name, + url: remote.url, + endpoint, + credentials: GitRemoteCredentials::Ssh(None), + }); + continue; + }; + let known_hosts_file = match remote.ssh_known_hosts_file { + Some(path) => resolve_ssh_path(base, path, "git.remotes.ssh_known_hosts_file")?, + None => default_known_hosts_path().ok_or(ConfigError::InvalidField { + field: "git.remotes.ssh_known_hosts_file", + })?, + }; + let identity = match ( + remote.ssh_identity_file, + remote.ssh_agent_fingerprint, + remote.ssh_agent_socket, + ) { + (Some(path), None, None) => SshIdentitySource::KeyFile(resolve_ssh_path( + base, + path, + "git.remotes.ssh_identity_file", + )?), + (None, Some(fingerprint), socket) => SshIdentitySource::Agent { + fingerprint: SshFingerprint::parse(fingerprint).map_err(|_| { + ConfigError::InvalidField { + field: "git.remotes.ssh_agent_fingerprint", + } + })?, + socket: socket + .map(|path| { + resolve_ssh_path(base, path, "git.remotes.ssh_agent_socket") + }) + .transpose()?, + }, + _ => { + return Err(ConfigError::InvalidField { + field: "git.remotes.ssh_authentication", + }); + } + }; + GitRemoteCredentials::Ssh(Some(SshRemoteAuthentication { + identity, + known_hosts_file, + })) + } (RemoteEndpoint::Ssh(_), _, _) => { return Err(ConfigError::InvalidField { field: "git.remotes.https_credentials", @@ -1721,6 +1961,43 @@ fn validate_remotes(raw: Vec) -> Result, ConfigErro Ok(remotes) } +fn resolve_ssh_path( + base: &Path, + value: PathBuf, + field: &'static str, +) -> Result { + if value.as_os_str().is_empty() || path_text(&value, field)?.chars().any(char::is_control) { + return Err(ConfigError::InvalidField { field }); + } + Ok(resolve_path(base, &value)) +} + +fn validate_standalone_ssh_path(path: &Path, field: &'static str) -> Result<(), ConfigError> { + if !path.is_absolute() + || path.as_os_str().is_empty() + || path_text(path, field)?.chars().any(char::is_control) + { + return Err(ConfigError::InvalidField { field }); + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn default_known_hosts_path() -> Option { + env::var_os("USERPROFILE") + .filter(|home| !home.is_empty() && Path::new(home).is_absolute()) + .map(PathBuf::from) + .map(|home| home.join(".ssh").join("known_hosts")) +} + +#[cfg(not(target_os = "windows"))] +fn default_known_hosts_path() -> Option { + env::var_os("HOME") + .filter(|home| !home.is_empty() && Path::new(home).is_absolute()) + .map(PathBuf::from) + .map(|home| home.join(".ssh").join("known_hosts")) +} + fn validate_identifier(field: &'static str, value: String) -> Result { if value.is_empty() || value.len() > 128 @@ -1794,7 +2071,16 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi validate_table( remote, &format!("git.remotes[{index}]"), - &["name", "url", "server_id", "application_id"], + &[ + "name", + "url", + "server_id", + "application_id", + "ssh_identity_file", + "ssh_agent_fingerprint", + "ssh_agent_socket", + "ssh_known_hosts_file", + ], )?; } } diff --git a/crates/storage/src/git.rs b/crates/storage/src/git.rs index 3fb333d..34a57e7 100644 --- a/crates/storage/src/git.rs +++ b/crates/storage/src/git.rs @@ -24,7 +24,7 @@ use sha1::{Digest as _, Sha1}; use zeroize::Zeroize as _; use crate::{ - config::{ApplicationId, GitRemote, RemoteEndpoint, RemoteTransport, ServerId}, + config::{ApplicationId, GitRemote, RemoteEndpoint, RemoteTransport, ServerId, SshFingerprint}, crypto::{KeyHandle, KeyStore, SecretProvider}, mutation::{TreeCommit, TreeCommitError, TreeCommitter}, recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter}, @@ -364,6 +364,67 @@ impl GitDiffEntry { } } +#[derive(Clone, Eq, PartialEq)] +pub struct SshHostKey { + host: String, + port: u16, + algorithm: String, + fingerprint: SshFingerprint, + encoded: String, +} + +impl SshHostKey { + #[cfg(feature = "ssh")] + pub(crate) fn new( + host: String, + port: u16, + algorithm: String, + fingerprint: SshFingerprint, + encoded: String, + ) -> Self { + Self { + host, + port, + algorithm, + fingerprint, + encoded, + } + } + + pub fn host(&self) -> &str { + &self.host + } + + pub const fn port(&self) -> u16 { + self.port + } + + pub fn algorithm(&self) -> &str { + &self.algorithm + } + + pub const fn fingerprint(&self) -> &SshFingerprint { + &self.fingerprint + } + + #[cfg(feature = "ssh")] + pub(crate) fn encoded(&self) -> &str { + &self.encoded + } +} + +impl fmt::Debug for SshHostKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SshHostKey") + .field("host", &self.host) + .field("port", &self.port) + .field("algorithm", &self.algorithm) + .field("fingerprint", &self.fingerprint) + .finish_non_exhaustive() + } +} + #[derive(Debug, Clone, Eq, PartialEq)] pub enum GitError { NotRepository, @@ -390,6 +451,44 @@ pub enum GitError { RemoteNotFound { name: String, }, + SshAuthenticationNotConfigured, + SshIdentityMissing { + path: PathBuf, + }, + SshIdentityInvalid { + path: PathBuf, + }, + SshUnsupportedAlgorithm { + algorithm: String, + }, + SshKeyPassphraseUnavailable { + fingerprint: SshFingerprint, + }, + SshKeyPassphraseDenied { + fingerprint: SshFingerprint, + }, + SshKeyPassphraseCancelled { + fingerprint: SshFingerprint, + }, + SshKeyPassphraseRejected { + fingerprint: SshFingerprint, + }, + SshAgentUnavailable, + SshAgentIdentityMissing { + fingerprint: SshFingerprint, + }, + SshAuthenticationRejected, + UnknownSshHostKey { + host_key: Box, + }, + ChangedSshHostKey { + host_key: Box, + line: usize, + }, + SshKnownHostsUnavailable { + path: PathBuf, + }, + SshProtocolFailed, CredentialsUnavailable, CredentialAccessDenied, CredentialCancelled, @@ -441,6 +540,73 @@ impl fmt::Display for GitError { ) } Self::RemoteNotFound { name } => write!(formatter, "Git remote not found: {name}"), + Self::SshAuthenticationNotConfigured => { + formatter.write_str("SSH authentication is not configured") + } + Self::SshIdentityMissing { path } => { + write!( + formatter, + "SSH identity file was not found: {}", + path.display() + ) + } + Self::SshIdentityInvalid { path } => { + write!( + formatter, + "SSH identity file is invalid: {}", + path.display() + ) + } + Self::SshUnsupportedAlgorithm { algorithm } => { + write!(formatter, "unsupported SSH key algorithm: {algorithm}") + } + Self::SshKeyPassphraseUnavailable { fingerprint } => { + write!( + formatter, + "SSH key passphrase is unavailable for {fingerprint}" + ) + } + Self::SshKeyPassphraseDenied { fingerprint } => { + write!( + formatter, + "access to the SSH key passphrase was denied for {fingerprint}" + ) + } + Self::SshKeyPassphraseCancelled { fingerprint } => { + write!( + formatter, + "SSH key passphrase access was cancelled for {fingerprint}" + ) + } + Self::SshKeyPassphraseRejected { fingerprint } => { + write!( + formatter, + "SSH key passphrase was rejected for {fingerprint}" + ) + } + Self::SshAgentUnavailable => formatter.write_str("the SSH agent is unavailable"), + Self::SshAgentIdentityMissing { fingerprint } => { + write!(formatter, "the SSH agent does not contain {fingerprint}") + } + Self::SshAuthenticationRejected => { + formatter.write_str("SSH public-key authentication was rejected") + } + Self::UnknownSshHostKey { host_key } => write!( + formatter, + "unknown SSH host key for {}:{} ({})", + host_key.host, host_key.port, host_key.fingerprint + ), + Self::ChangedSshHostKey { host_key, line } => write!( + formatter, + "changed SSH host key for {}:{} ({}, known_hosts line {line})", + host_key.host, host_key.port, host_key.fingerprint + ), + Self::SshKnownHostsUnavailable { path } => write!( + formatter, + "SSH known-hosts file is unavailable: {}", + path.display() + ), + Self::SshProtocolFailed => formatter.write_str("the SSH protocol failed"), Self::CredentialsUnavailable => { formatter.write_str("HTTPS Git credentials are unavailable") } @@ -617,6 +783,10 @@ pub trait GitCredentialProvider { ) -> Result; } +pub trait SshPassphraseProvider { + fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result; +} + pub trait GitSmartHttpTransport { fn advertise_receive_pack( &self, diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 1073a59..9dfb3b2 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -58,6 +58,8 @@ pub mod repository; mod secret; #[cfg(feature = "full")] pub mod secret_store; +#[cfg(all(feature = "full", feature = "ssh"))] +pub mod ssh; #[cfg(feature = "full")] pub mod write; diff --git a/crates/storage/src/secret_store.rs b/crates/storage/src/secret_store.rs index 08a377a..e1c2936 100644 --- a/crates/storage/src/secret_store.rs +++ b/crates/storage/src/secret_store.rs @@ -10,9 +10,9 @@ use std::{ }; use crate::{ - config::{ApplicationId, ServerId}, + config::{ApplicationId, ServerId, SshFingerprint}, crypto::{KeyInfo, SecretProvider, SecretProviderError}, - git::{GitCredential, GitCredentialProvider, GitError}, + git::{GitCredential, GitCredentialProvider, GitError, SshPassphraseProvider}, repository::SecretBytes, }; @@ -27,6 +27,7 @@ const MAX_CACHE_LIFETIME: Duration = Duration::from_secs(15 * 60); const MAX_CACHE_CAPACITY: usize = 128; const OPENPGP_PASSPHRASE_SERVICE: &str = "de.rfc1437.ironstorage.openpgp-passphrase"; const HTTPS_GIT_SERVICE: &str = "de.rfc1437.ironstorage.https-git"; +const SSH_KEY_PASSPHRASE_SERVICE: &str = "de.rfc1437.ironstorage.ssh-key-passphrase"; /// The purpose and stable, non-secret identity of an OS credential. #[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] @@ -44,6 +45,9 @@ enum SecretReferenceKind { application_id: String, account: String, }, + SshKeyPassphrase { + fingerprint: SshFingerprint, + }, } impl SecretReference { @@ -87,10 +91,17 @@ impl SecretReference { }) } + pub fn ssh_key_passphrase(fingerprint: SshFingerprint) -> Self { + Self { + kind: SecretReferenceKind::SshKeyPassphrase { fingerprint }, + } + } + pub fn account(&self) -> Option<&str> { match &self.kind { SecretReferenceKind::OpenPgpPassphrase { .. } => None, SecretReferenceKind::HttpsGitCredential { account, .. } => Some(account), + SecretReferenceKind::SshKeyPassphrase { .. } => None, } } @@ -109,6 +120,11 @@ impl SecretReference { server_id: server_id.clone(), application_id: application_id.clone(), }, + SecretReferenceKind::SshKeyPassphrase { fingerprint } => { + SecretLocator::SshKeyPassphrase { + fingerprint: fingerprint.clone(), + } + } } } } @@ -122,6 +138,9 @@ impl fmt::Debug for SecretReference { Self { kind: SecretReferenceKind::HttpsGitCredential { .. }, } => formatter.write_str("SecretReference::HttpsGitCredential([REDACTED])"), + Self { + kind: SecretReferenceKind::SshKeyPassphrase { .. }, + } => formatter.write_str("SecretReference::SshKeyPassphrase([REDACTED])"), } } } @@ -137,6 +156,9 @@ pub enum SecretLocator { server_id: String, application_id: String, }, + SshKeyPassphrase { + fingerprint: SshFingerprint, + }, } impl fmt::Debug for SecretLocator { @@ -148,6 +170,9 @@ impl fmt::Debug for SecretLocator { Self::HttpsGitCredential { .. } => { formatter.write_str("SecretLocator::HttpsGitCredential([REDACTED])") } + Self::SshKeyPassphrase { .. } => { + formatter.write_str("SecretLocator::SshKeyPassphrase([REDACTED])") + } } } } @@ -162,6 +187,9 @@ impl SecretLocator { server_id, application_id, } => (HTTPS_GIT_SERVICE, format!("{server_id}/{application_id}")), + Self::SshKeyPassphrase { fingerprint } => { + (SSH_KEY_PASSPHRASE_SERVICE, fingerprint.to_string()) + } } } } @@ -209,6 +237,7 @@ impl SecretProtectionPolicy { match &reference.kind { SecretReferenceKind::OpenPgpPassphrase { .. } => self.openpgp, SecretReferenceKind::HttpsGitCredential { .. } => self.git, + SecretReferenceKind::SshKeyPassphrase { .. } => self.git, } } @@ -216,6 +245,7 @@ impl SecretProtectionPolicy { match locator { SecretLocator::OpenPgpPassphrase { .. } => self.openpgp, SecretLocator::HttpsGitCredential { .. } => self.git, + SecretLocator::SshKeyPassphrase { .. } => self.git, } } } @@ -815,6 +845,23 @@ impl GitCredentialProvider for SecretStore { } } +impl SshPassphraseProvider for SecretStore { + fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result { + self.retrieve(&SecretReference::ssh_key_passphrase(fingerprint.clone())) + .map_err(|error| match error { + SecretStoreError::Cancelled => GitError::SshKeyPassphraseCancelled { + fingerprint: fingerprint.clone(), + }, + SecretStoreError::Denied => GitError::SshKeyPassphraseDenied { + fingerprint: fingerprint.clone(), + }, + _ => GitError::SshKeyPassphraseUnavailable { + fingerprint: fingerprint.clone(), + }, + }) + } +} + fn provider_error(error: SecretStoreError) -> SecretProviderError { match error { SecretStoreError::Missing => SecretProviderError::Missing, @@ -878,6 +925,10 @@ fn encode_record( write_field(&mut encoded, application_id.as_bytes())?; write_field(&mut encoded, account.as_bytes())?; } + SecretReferenceKind::SshKeyPassphrase { fingerprint } => { + encoded.push(3); + write_field(&mut encoded, fingerprint.as_str().as_bytes())?; + } } let length = u32::try_from(value.expose().len()).map_err(|_| SecretStoreError::Corrupted)?; encoded.extend_from_slice(&length.to_be_bytes()); @@ -919,6 +970,14 @@ fn decode_record(encoded: SecretBytes) -> Result ) .map_err(|_| SecretStoreError::Corrupted)? } + 3 => { + let (fingerprint, rest) = read_field(remainder)?; + remainder = rest; + SecretReference::ssh_key_passphrase( + SshFingerprint::parse(read_text(fingerprint)?) + .map_err(|_| SecretStoreError::Corrupted)?, + ) + } _ => return Err(SecretStoreError::Corrupted), }; if remainder.len() < 4 { @@ -993,8 +1052,8 @@ fn copy_secret(value: &SecretBytes) -> SecretBytes { #[cfg(test)] mod tests { use super::{ - HTTPS_GIT_SERVICE, OPENPGP_PASSPHRASE_SERVICE, RECORD_MAGIC, RECORD_VERSION, SecretBytes, - SecretLocator, SecretStoreError, decode_record, + HTTPS_GIT_SERVICE, OPENPGP_PASSPHRASE_SERVICE, RECORD_MAGIC, RECORD_VERSION, + SSH_KEY_PASSPHRASE_SERVICE, SecretBytes, SecretLocator, SecretStoreError, decode_record, }; fn openpgp_record(fingerprint: &[u8], secret: &[u8]) -> SecretBytes { @@ -1033,6 +1092,12 @@ mod tests { server_id: "server".to_owned(), application_id: "application".to_owned(), }; + let ssh = SecretLocator::SshKeyPassphrase { + fingerprint: crate::config::SshFingerprint::parse( + "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ) + .unwrap(), + }; assert_eq!( openpgp.service_and_user(), ( @@ -1044,7 +1109,15 @@ mod tests { git.service_and_user(), (HTTPS_GIT_SERVICE, "server/application".to_owned()) ); + assert_eq!( + ssh.service_and_user(), + ( + SSH_KEY_PASSPHRASE_SERVICE, + "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned() + ) + ); assert!(OPENPGP_PASSPHRASE_SERVICE.starts_with("de.rfc1437.ironstorage.")); assert!(HTTPS_GIT_SERVICE.starts_with("de.rfc1437.ironstorage.")); + assert!(SSH_KEY_PASSPHRASE_SERVICE.starts_with("de.rfc1437.ironstorage.")); } } diff --git a/crates/storage/src/ssh.rs b/crates/storage/src/ssh.rs new file mode 100644 index 0000000..0f85acd --- /dev/null +++ b/crates/storage/src/ssh.rs @@ -0,0 +1,1148 @@ +//! Storage-owned SSH authentication and strict known-host verification. + +use std::{ + borrow::Cow, + fs, + future::Future, + io::{Read, Write}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::Duration, +}; + +use cap_std::{ambient_authority, fs::Dir}; +use cap_tempfile::TempFile; +use hmac::{Hmac, Mac as _}; +use russh::{ + Disconnect, client, + keys::{ + HashAlg, PrivateKey, PublicKey, agent::client::AgentClient, key::PrivateKeyWithHashAlg, + ssh_key::Algorithm, + }, +}; +use sha1::Sha1; + +use crate::{ + config::{GitRemote, RemoteEndpoint, SshFingerprint, SshIdentitySource}, + git::{GitError, GitOperationControl, GitProgressPhase, SshHostKey, SshPassphraseProvider}, +}; + +const MAX_IDENTITY_BYTES: u64 = 1024 * 1024; +const MAX_KNOWN_HOSTS_BYTES: u64 = 1024 * 1024; +const MAX_KNOWN_HOST_LINE_BYTES: usize = 16 * 1024; +const MAX_AGENT_IDENTITIES: usize = 64; +const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30); +const CANCELLATION_POLL: Duration = Duration::from_millis(25); +static KNOWN_HOSTS_WRITE_LOCK: Mutex<()> = Mutex::new(()); + +/// An authenticated SSH connection. Git protocol channels are opened by the +/// storage transport so callers cannot issue arbitrary commands. +pub struct SshSession { + runtime: tokio::runtime::Runtime, + handle: client::Handle, +} + +impl std::fmt::Debug for SshSession { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("SshSession([AUTHENTICATED])") + } +} + +impl SshSession { + pub fn connect( + remote: &GitRemote, + passphrases: &dyn SshPassphraseProvider, + control: &GitOperationControl, + ) -> Result { + let RemoteEndpoint::Ssh(endpoint) = remote.endpoint() else { + return Err(GitError::ForbiddenRemoteUrl); + }; + let authentication = remote + .ssh_authentication() + .ok_or(GitError::SshAuthenticationNotConfigured)?; + control.report(GitProgressPhase::Validating)?; + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|_| GitError::SshProtocolFailed)?; + let host = endpoint.host().to_owned(); + let port = endpoint.port(); + let known_hosts = authentication.known_hosts_file().to_owned(); + let verifier = HostVerifier { + host: host.clone(), + port, + known_hosts, + }; + let mut config = client::Config { + inactivity_timeout: Some(CONNECTION_TIMEOUT), + ..client::Config::default() + }; + config.preferred.key = Cow::Owned( + config + .preferred + .key + .iter() + .filter(|algorithm| !matches!(algorithm, Algorithm::Rsa { hash: None })) + .cloned() + .collect(), + ); + let handle = runtime.block_on(async { + controlled( + tokio::time::timeout( + CONNECTION_TIMEOUT, + client::connect(Arc::new(config), (host.as_str(), port), verifier), + ), + control, + ) + .await + })?; + let mut handle = match handle { + Ok(Ok(handle)) => handle, + Ok(Err(error)) => return Err(error.into_git()), + Err(_) => return Err(GitError::SshProtocolFailed), + }; + + control.report(GitProgressPhase::Authenticating)?; + let user = endpoint.user().unwrap_or("git"); + let authenticated = match authentication.identity() { + SshIdentitySource::KeyFile(path) => runtime.block_on(authenticate_key_file( + &mut handle, + user, + path, + passphrases, + control, + ))?, + SshIdentitySource::Agent { + fingerprint, + socket, + } => runtime.block_on(authenticate_agent( + &mut handle, + user, + fingerprint, + socket.as_deref(), + control, + ))?, + }; + if !authenticated { + return Err(GitError::SshAuthenticationRejected); + } + Ok(Self { runtime, handle }) + } + + pub fn close(self) -> Result<(), GitError> { + self.runtime + .block_on(self.handle.disconnect(Disconnect::ByApplication, "", "")) + .map_err(|_| GitError::SshProtocolFailed) + } +} + +/// Persist a host key only after the caller has confirmed an unknown-key error. +/// Existing changed keys are never replaced. +pub fn persist_confirmed_host( + known_hosts_file: &Path, + host_key: &SshHostKey, +) -> Result<(), GitError> { + let _write = KNOWN_HOSTS_WRITE_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let public_key = + PublicKey::from_openssh(host_key.encoded()).map_err(|_| GitError::SshProtocolFailed)?; + let observed = ssh_host_key(host_key.host(), host_key.port(), &public_key)?; + if &observed != host_key { + return Err(GitError::SshProtocolFailed); + } + match verify_known_host( + known_hosts_file, + host_key.host(), + host_key.port(), + &public_key, + )? { + HostStatus::Trusted => return Ok(()), + HostStatus::Changed { line } => { + return Err(GitError::ChangedSshHostKey { + host_key: Box::new(host_key.clone()), + line, + }); + } + HostStatus::Unknown => {} + } + + let parent = known_hosts_file + .parent() + .ok_or_else(|| GitError::SshKnownHostsUnavailable { + path: known_hosts_file.to_owned(), + })?; + fs::create_dir_all(parent).map_err(|_| GitError::SshKnownHostsUnavailable { + path: known_hosts_file.to_owned(), + })?; + set_private_directory(parent, known_hosts_file)?; + let mut contents = read_known_hosts(known_hosts_file)?; + if !contents.is_empty() && !contents.ends_with(b"\n") { + contents.push(b'\n'); + } + let host = if host_key.port() == 22 { + host_key.host().to_owned() + } else { + format!("[{}]:{}", host_key.host(), host_key.port()) + }; + let line = format!("{host} {}\n", host_key.encoded()); + if contents.len().saturating_add(line.len()) > MAX_KNOWN_HOSTS_BYTES as usize { + return Err(GitError::SshKnownHostsUnavailable { + path: known_hosts_file.to_owned(), + }); + } + contents.extend_from_slice(line.as_bytes()); + replace_known_hosts(known_hosts_file, &contents) +} + +#[derive(Debug)] +enum ClientError { + Git(GitError), + Protocol, +} + +impl ClientError { + fn into_git(self) -> GitError { + match self { + Self::Git(error) => error, + Self::Protocol => GitError::SshProtocolFailed, + } + } +} + +impl From for ClientError { + fn from(_: russh::Error) -> Self { + Self::Protocol + } +} + +struct HostVerifier { + host: String, + port: u16, + known_hosts: PathBuf, +} + +impl client::Handler for HostVerifier { + type Error = ClientError; + + async fn check_server_key( + &mut self, + server_key: &russh::keys::PublicKeyOrCertificate, + ) -> Result { + if server_key.certificate().is_some() { + return Err(ClientError::Git(GitError::SshUnsupportedAlgorithm { + algorithm: "OpenSSH host certificate".to_owned(), + })); + } + let public_key = server_key.public_key(); + ensure_supported_key(&public_key).map_err(ClientError::Git)?; + let host_key = + ssh_host_key(&self.host, self.port, &public_key).map_err(ClientError::Git)?; + match verify_known_host(&self.known_hosts, &self.host, self.port, &public_key) + .map_err(ClientError::Git)? + { + HostStatus::Trusted => Ok(true), + HostStatus::Unknown => Err(ClientError::Git(GitError::UnknownSshHostKey { + host_key: Box::new(host_key), + })), + HostStatus::Changed { line } => Err(ClientError::Git(GitError::ChangedSshHostKey { + host_key: Box::new(host_key), + line, + })), + } + } +} + +async fn authenticate_key_file( + handle: &mut client::Handle, + user: &str, + path: &Path, + passphrases: &dyn SshPassphraseProvider, + control: &GitOperationControl, +) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|_| GitError::SshIdentityMissing { + path: path.to_owned(), + })?; + if metadata.file_type().is_symlink() { + return Err(GitError::SshIdentityInvalid { + path: path.to_owned(), + }); + } + let encoded = + read_bounded_file(path, MAX_IDENTITY_BYTES).map_err(|_| GitError::SshIdentityInvalid { + path: path.to_owned(), + })?; + let mut key = PrivateKey::from_openssh(&encoded).map_err(|_| GitError::SshIdentityInvalid { + path: path.to_owned(), + })?; + ensure_supported_key(key.public_key())?; + let fingerprint = fingerprint(key.public_key())?; + if key.is_encrypted() { + let passphrase = passphrases.ssh_key_passphrase(&fingerprint)?; + key = key + .decrypt(passphrase.expose()) + .map_err(|_| GitError::SshKeyPassphraseRejected { + fingerprint: fingerprint.clone(), + })?; + } + let hash = rsa_hash(handle, key.public_key(), control).await?; + let result = controlled( + handle.authenticate_publickey(user, PrivateKeyWithHashAlg::new(Arc::new(key), hash)), + control, + ) + .await? + .map_err(|_| GitError::SshProtocolFailed)?; + Ok(result.success()) +} + +async fn authenticate_agent( + handle: &mut client::Handle, + user: &str, + selected: &SshFingerprint, + socket: Option<&Path>, + control: &GitOperationControl, +) -> Result { + let mut agent = connect_agent(socket).await?; + let identities = controlled(agent.request_identities(), control) + .await? + .map_err(|_| GitError::SshAgentUnavailable)?; + if identities.len() > MAX_AGENT_IDENTITIES { + return Err(GitError::SshAgentUnavailable); + } + let identity = identities + .into_iter() + .find(|identity| { + fingerprint(identity.public_key().as_ref()) + .as_ref() + .is_ok_and(|fingerprint| fingerprint == selected) + }) + .ok_or_else(|| GitError::SshAgentIdentityMissing { + fingerprint: selected.clone(), + })?; + let public_key = identity.public_key().into_owned(); + ensure_supported_key(&public_key)?; + let hash = rsa_hash(handle, &public_key, control).await?; + let result = controlled( + handle.authenticate_publickey_with(user, public_key, hash, &mut agent), + control, + ) + .await? + .map_err(|_| GitError::SshAgentUnavailable)?; + Ok(result.success()) +} + +#[cfg(unix)] +async fn connect_agent( + socket: Option<&Path>, +) -> Result>, GitError> +{ + let client = match socket { + Some(path) => AgentClient::connect_uds(path).await, + None => AgentClient::connect_env().await, + } + .map_err(|_| GitError::SshAgentUnavailable)?; + Ok(client.dynamic()) +} + +#[cfg(windows)] +async fn connect_agent( + socket: Option<&Path>, +) -> Result>, GitError> +{ + match socket { + Some(path) => AgentClient::connect_named_pipe(path) + .await + .map(AgentClient::dynamic), + None => AgentClient::connect_pageant() + .await + .map(AgentClient::dynamic), + } + .map_err(|_| GitError::SshAgentUnavailable) +} + +async fn rsa_hash( + handle: &client::Handle, + key: &PublicKey, + control: &GitOperationControl, +) -> Result, GitError> { + if !matches!(key.algorithm(), Algorithm::Rsa { .. }) { + return Ok(None); + } + let supported = controlled(handle.best_supported_rsa_hash(), control) + .await? + .map_err(|_| GitError::SshProtocolFailed)?; + match supported { + Some(Some(hash)) => Ok(Some(hash)), + Some(None) => Err(GitError::SshUnsupportedAlgorithm { + algorithm: "ssh-rsa/SHA-1".to_owned(), + }), + None => Ok(Some(HashAlg::Sha512)), + } +} + +async fn controlled( + future: F, + control: &GitOperationControl, +) -> Result, GitError> +where + F: Future>, +{ + tokio::pin!(future); + loop { + tokio::select! { + result = &mut future => return Ok(result), + () = tokio::time::sleep(CANCELLATION_POLL) => { + if control.is_cancelled() { + return Err(GitError::Cancelled); + } + } + } + } +} + +fn ensure_supported_key(key: &PublicKey) -> Result<(), GitError> { + match key.algorithm() { + Algorithm::Ed25519 | Algorithm::Ecdsa { .. } | Algorithm::Rsa { .. } => Ok(()), + algorithm => Err(GitError::SshUnsupportedAlgorithm { + algorithm: algorithm.to_string(), + }), + } +} + +fn fingerprint(key: &PublicKey) -> Result { + SshFingerprint::parse(key.fingerprint(HashAlg::Sha256).to_string()) + .map_err(|_| GitError::SshProtocolFailed) +} + +fn ssh_host_key(host: &str, port: u16, key: &PublicKey) -> Result { + Ok(SshHostKey::new( + host.to_owned(), + port, + key.algorithm().to_string(), + fingerprint(key)?, + key.to_openssh().map_err(|_| GitError::SshProtocolFailed)?, + )) +} + +enum HostStatus { + Trusted, + Unknown, + Changed { line: usize }, +} + +fn verify_known_host( + path: &Path, + host: &str, + port: u16, + presented: &PublicKey, +) -> Result { + let contents = read_known_hosts(path)?; + let text = std::str::from_utf8(&contents).map_err(|_| GitError::SshKnownHostsUnavailable { + path: path.to_owned(), + })?; + let target = if port == 22 { + host.to_owned() + } else { + format!("[{host}]:{port}") + }; + let mut changed = None; + for (index, line) in text.lines().enumerate() { + if line.len() > MAX_KNOWN_HOST_LINE_BYTES { + return Err(GitError::SshKnownHostsUnavailable { + path: path.to_owned(), + }); + } + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let mut fields = line.split_whitespace(); + let Some(first) = fields.next() else { continue }; + let (marker, hosts) = if first.starts_with('@') { + (Some(first), fields.next().unwrap_or_default()) + } else { + (None, first) + }; + let Some(algorithm) = fields.next() else { + continue; + }; + let Some(encoded) = fields.next() else { + continue; + }; + if !host_list_matches(&target, hosts) { + continue; + } + if !matches!(marker, None | Some("@revoked") | Some("@cert-authority")) { + return Err(GitError::SshKnownHostsUnavailable { + path: path.to_owned(), + }); + } + if marker == Some("@cert-authority") { + continue; + } + let recorded = + PublicKey::from_openssh(&format!("{algorithm} {encoded}")).map_err(|_| { + GitError::SshKnownHostsUnavailable { + path: path.to_owned(), + } + })?; + if &recorded == presented && marker != Some("@revoked") { + return Ok(HostStatus::Trusted); + } + if &recorded == presented || recorded.algorithm() == presented.algorithm() { + changed.get_or_insert(index + 1); + } + } + Ok(match changed { + Some(line) => HostStatus::Changed { line }, + None => HostStatus::Unknown, + }) +} + +fn host_list_matches(host: &str, patterns: &str) -> bool { + let mut matched = false; + for pattern in patterns.split(',') { + let (negated, pattern) = pattern + .strip_prefix('!') + .map_or((false, pattern), |pattern| (true, pattern)); + if host_matches(host, pattern) { + if negated { + return false; + } + matched = true; + } + } + matched +} + +fn host_matches(host: &str, pattern: &str) -> bool { + let Some(hashed) = pattern.strip_prefix("|1|") else { + return pattern == host; + }; + let mut fields = hashed.split('|'); + let (Some(salt), Some(expected), None) = (fields.next(), fields.next(), fields.next()) else { + return false; + }; + let Ok(salt) = data_encoding::BASE64.decode(salt.as_bytes()) else { + return false; + }; + let Ok(expected) = data_encoding::BASE64.decode(expected.as_bytes()) else { + return false; + }; + Hmac::::new_from_slice(&salt).is_ok_and(|mut mac| { + mac.update(host.as_bytes()); + mac.verify_slice(&expected).is_ok() + }) +} + +fn read_known_hosts(path: &Path) -> Result, GitError> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(_) => { + return Err(GitError::SshKnownHostsUnavailable { + path: path.to_owned(), + }); + } + }; + if metadata.file_type().is_symlink() { + return Err(GitError::SshKnownHostsUnavailable { + path: path.to_owned(), + }); + } + read_bounded_file(path, MAX_KNOWN_HOSTS_BYTES).map_err(|_| GitError::SshKnownHostsUnavailable { + path: path.to_owned(), + }) +} + +fn read_bounded_file(path: &Path, maximum: u64) -> std::io::Result> { + let file = fs::File::open(path)?; + let metadata = file.metadata()?; + if !metadata.is_file() || metadata.len() > maximum { + return Err(std::io::Error::other("file exceeds security bounds")); + } + let mut contents = Vec::with_capacity(metadata.len() as usize); + file.take(maximum + 1).read_to_end(&mut contents)?; + if contents.len() > maximum as usize { + return Err(std::io::Error::other("file changed while reading")); + } + Ok(contents) +} + +fn replace_known_hosts(path: &Path, contents: &[u8]) -> Result<(), GitError> { + let parent = path + .parent() + .ok_or_else(|| GitError::SshKnownHostsUnavailable { + path: path.to_owned(), + })?; + let name = path + .file_name() + .ok_or_else(|| GitError::SshKnownHostsUnavailable { + path: path.to_owned(), + })?; + let directory = Dir::open_ambient_dir(parent, ambient_authority()).map_err(|_| { + GitError::SshKnownHostsUnavailable { + path: path.to_owned(), + } + })?; + if let Ok(metadata) = directory.symlink_metadata(name) + && (metadata.file_type().is_symlink() || !metadata.is_file()) + { + return Err(GitError::SshKnownHostsUnavailable { + path: path.to_owned(), + }); + } + let mut temporary = + TempFile::new(&directory).map_err(|_| GitError::SshKnownHostsUnavailable { + path: path.to_owned(), + })?; + set_private_file(&temporary, path)?; + temporary + .write_all(contents) + .and_then(|()| temporary.as_file().sync_all()) + .and_then(|()| temporary.replace(name)) + .and_then(|()| directory.open(".").and_then(|file| file.sync_all())) + .map_err(|_| GitError::SshKnownHostsUnavailable { + path: path.to_owned(), + }) +} + +#[cfg(unix)] +fn set_private_file(temporary: &TempFile<'_>, path: &Path) -> Result<(), GitError> { + use cap_std::fs::{Permissions, PermissionsExt as _}; + + temporary + .as_file() + .set_permissions(Permissions::from_mode(0o600)) + .map_err(|_| GitError::SshKnownHostsUnavailable { + path: path.to_owned(), + }) +} + +#[cfg(not(unix))] +fn set_private_file(_temporary: &TempFile<'_>, _path: &Path) -> Result<(), GitError> { + Ok(()) +} + +#[cfg(unix)] +fn set_private_directory(directory: &Path, source: &Path) -> Result<(), GitError> { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).map_err(|_| { + GitError::SshKnownHostsUnavailable { + path: source.to_owned(), + } + }) +} + +#[cfg(not(unix))] +fn set_private_directory(_directory: &Path, _source: &Path) -> Result<(), GitError> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + thread, + time::Duration, + }; + + use russh::{ + keys::{ + PrivateKey, PublicKey, + agent::client::AgentClient, + ssh_key::{Algorithm, LineEnding}, + }, + server, + }; + + use crate::{ + config::{GitRemote, SshFingerprint, SshRemoteAuthentication}, + git::{GitError, GitOperationControl, SshPassphraseProvider}, + repository::SecretBytes, + }; + + use super::{SshSession, persist_confirmed_host, ssh_host_key}; + + struct Passphrase(Option<&'static [u8]>); + + impl SshPassphraseProvider for Passphrase { + fn ssh_key_passphrase( + &self, + fingerprint: &SshFingerprint, + ) -> Result { + self.0 + .map(|value| SecretBytes::new(value.to_vec())) + .ok_or_else(|| GitError::SshKeyPassphraseUnavailable { + fingerprint: fingerprint.clone(), + }) + } + } + + #[derive(Clone)] + struct TestServer { + user: &'static str, + key: PublicKey, + attempts: Arc, + } + + impl server::Handler for TestServer { + type Error = russh::Error; + + async fn auth_publickey( + &mut self, + user: &str, + public_key: &PublicKey, + ) -> Result { + self.attempts.fetch_add(1, Ordering::Relaxed); + if user == self.user && public_key == &self.key { + Ok(server::Auth::Accept) + } else { + Ok(server::Auth::reject()) + } + } + } + + fn key(algorithm: Algorithm) -> PrivateKey { + PrivateKey::random(&mut russh::keys::key::safe_rng(), algorithm).expect("generate key") + } + + fn write_key(path: &std::path::Path, key: &PrivateKey) { + fs::write( + path, + key.to_openssh(LineEnding::LF) + .expect("encode private key") + .as_bytes(), + ) + .expect("write identity"); + } + + fn start_server( + host_key: PrivateKey, + user_key: PublicKey, + connections: usize, + ) -> (u16, Arc, thread::JoinHandle<()>) { + let attempts = Arc::new(AtomicUsize::new(0)); + let handler = TestServer { + user: "git", + key: user_key, + attempts: attempts.clone(), + }; + let (port_tx, port_rx) = mpsc::channel(); + let join = thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("server runtime"); + runtime.block_on(async move { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("bind server"); + port_tx + .send(listener.local_addr().expect("server address").port()) + .expect("send port"); + let config = Arc::new(server::Config { + keys: vec![host_key], + auth_rejection_time: Duration::ZERO, + auth_rejection_time_initial: Some(Duration::ZERO), + ..server::Config::default() + }); + for _ in 0..connections { + let (stream, _) = listener.accept().await.expect("accept client"); + if let Ok(session) = + server::run_stream(config.clone(), stream, handler.clone()).await + { + let _ = session.await; + } + } + }); + }); + ( + port_rx + .recv_timeout(Duration::from_secs(5)) + .expect("server port"), + attempts, + join, + ) + } + + fn remote(port: u16, identity: &std::path::Path, known_hosts: &std::path::Path) -> GitRemote { + GitRemote::ssh_with_authentication( + "origin", + format!("ssh://git@127.0.0.1:{port}/team/store.git"), + SshRemoteAuthentication::key_file(identity.to_owned(), known_hosts.to_owned()) + .expect("SSH authentication"), + ) + .expect("SSH remote") + } + + #[cfg(unix)] + fn start_agent( + socket: &std::path::Path, + ) -> (tokio::sync::oneshot::Sender<()>, thread::JoinHandle<()>) { + let socket = socket.to_owned(); + let (ready_tx, ready_rx) = mpsc::channel(); + let (stop_tx, stop_rx) = tokio::sync::oneshot::channel(); + let join = thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("agent runtime"); + runtime.block_on(async move { + let listener = tokio::net::UnixListener::bind(socket).expect("bind agent"); + ready_tx.send(()).expect("agent ready"); + let incoming = tokio_stream::wrappers::UnixListenerStream::new(listener); + tokio::select! { + result = russh::keys::agent::server::serve(incoming, ()) => { + result.expect("serve agent"); + } + _ = stop_rx => {} + } + }); + }); + ready_rx + .recv_timeout(Duration::from_secs(5)) + .expect("agent ready"); + (stop_tx, join) + } + + #[test] + fn key_authentication_supports_ed25519_ecdsa_and_rsa() { + for algorithm in [ + Algorithm::Ed25519, + Algorithm::Ecdsa { + curve: russh::keys::ssh_key::EcdsaCurve::NistP256, + }, + Algorithm::Rsa { + hash: Some(russh::keys::HashAlg::Sha512), + }, + ] { + let temporary = tempfile::tempdir().expect("temporary directory"); + let identity = key(algorithm); + let host_key = key(Algorithm::Ed25519); + let identity_path = temporary.path().join("identity"); + let known_hosts = temporary.path().join("known_hosts"); + write_key(&identity_path, &identity); + let (port, attempts, server) = + start_server(host_key.clone(), identity.public_key().clone(), 1); + persist_confirmed_host( + &known_hosts, + &ssh_host_key("127.0.0.1", port, host_key.public_key()).expect("host key"), + ) + .expect("trust host"); + let session = SshSession::connect( + &remote(port, &identity_path, &known_hosts), + &Passphrase(None), + &GitOperationControl::default(), + ) + .expect("authenticate"); + session.close().expect("close session"); + server.join().expect("join server"); + assert_eq!(attempts.load(Ordering::Relaxed), 1); + } + } + + #[test] + fn encrypted_key_requires_the_matching_protected_passphrase() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let identity = key(Algorithm::Ed25519); + let encrypted = identity + .encrypt(&mut russh::keys::key::safe_rng(), b"correct") + .expect("encrypt key"); + let host_key = key(Algorithm::Ed25519); + let identity_path = temporary.path().join("identity"); + let known_hosts = temporary.path().join("known_hosts"); + write_key(&identity_path, &encrypted); + let (port, attempts, server) = + start_server(host_key.clone(), identity.public_key().clone(), 2); + persist_confirmed_host( + &known_hosts, + &ssh_host_key("127.0.0.1", port, host_key.public_key()).expect("host key"), + ) + .expect("trust host"); + let remote = remote(port, &identity_path, &known_hosts); + let error = SshSession::connect( + &remote, + &Passphrase(Some(b"wrong")), + &GitOperationControl::default(), + ) + .expect_err("reject wrong passphrase"); + assert!(matches!(error, GitError::SshKeyPassphraseRejected { .. })); + let session = SshSession::connect( + &remote, + &Passphrase(Some(b"correct")), + &GitOperationControl::default(), + ) + .expect("accept protected passphrase"); + session.close().expect("close session"); + server.join().expect("join server"); + assert_eq!(attempts.load(Ordering::Relaxed), 1); + } + + #[test] + fn unknown_host_is_confirmed_explicitly_before_authentication() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let identity = key(Algorithm::Ed25519); + let host_key = key(Algorithm::Ed25519); + let identity_path = temporary.path().join("identity"); + let known_hosts = temporary.path().join("known_hosts"); + write_key(&identity_path, &identity); + let (port, attempts, server) = start_server(host_key, identity.public_key().clone(), 2); + let remote = remote(port, &identity_path, &known_hosts); + let error = + SshSession::connect(&remote, &Passphrase(None), &GitOperationControl::default()) + .expect_err("unknown host"); + let GitError::UnknownSshHostKey { host_key } = error else { + panic!("unexpected error: {error:?}"); + }; + assert_eq!(attempts.load(Ordering::Relaxed), 0); + persist_confirmed_host(&known_hosts, &host_key).expect("persist confirmation"); + let session = + SshSession::connect(&remote, &Passphrase(None), &GitOperationControl::default()) + .expect("authenticate trusted host"); + session.close().expect("close session"); + server.join().expect("join server"); + assert_eq!(attempts.load(Ordering::Relaxed), 1); + } + + #[test] + fn changed_host_fails_before_authentication_and_is_not_replaced() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let identity = key(Algorithm::Ed25519); + let host_key = key(Algorithm::Ed25519); + let old_host_key = key(Algorithm::Ed25519); + let identity_path = temporary.path().join("identity"); + let known_hosts = temporary.path().join("known_hosts"); + write_key(&identity_path, &identity); + let (port, attempts, server) = start_server(host_key, identity.public_key().clone(), 1); + persist_confirmed_host( + &known_hosts, + &ssh_host_key("127.0.0.1", port, old_host_key.public_key()).expect("old host key"), + ) + .expect("trust old host"); + let before = fs::read(&known_hosts).expect("known hosts"); + let error = SshSession::connect( + &remote(port, &identity_path, &known_hosts), + &Passphrase(None), + &GitOperationControl::default(), + ) + .expect_err("changed host"); + assert!(matches!(error, GitError::ChangedSshHostKey { .. })); + assert_eq!(fs::read(&known_hosts).expect("known hosts"), before); + assert_eq!(attempts.load(Ordering::Relaxed), 0); + server.join().expect("join server"); + } + + #[test] + fn hashed_nondefault_host_entry_matches() { + use hmac::{Hmac, Mac as _}; + use sha1::Sha1; + + let temporary = tempfile::tempdir().expect("temporary directory"); + let host_key = key(Algorithm::Ed25519); + let target = "[example.test]:2222"; + let salt = b"01234567890123456789"; + let mut mac = Hmac::::new_from_slice(salt).expect("HMAC"); + mac.update(target.as_bytes()); + let hash = mac.finalize().into_bytes(); + let line = format!( + "|1|{}|{} {}\n", + data_encoding::BASE64.encode(salt), + data_encoding::BASE64.encode(&hash), + host_key.public_key().to_openssh().expect("public key") + ); + let path = temporary.path().join("known_hosts"); + fs::write(&path, line).expect("known hosts"); + assert!(matches!( + super::verify_known_host(&path, "example.test", 2222, host_key.public_key()) + .expect("verify"), + super::HostStatus::Trusted + )); + let typed = ssh_host_key("example.test", 2222, host_key.public_key()).expect("host key"); + let rendered = format!("{typed:?}"); + assert!(rendered.contains(typed.fingerprint().as_str())); + assert!(!rendered.contains(typed.encoded())); + + fs::write(&path, format!("@revoked {target} {}\n", typed.encoded())).expect("revoked host"); + assert!(matches!( + super::verify_known_host(&path, "example.test", 2222, host_key.public_key()) + .expect("verify revoked"), + super::HostStatus::Changed { .. } + )); + fs::write(&path, format!("{target},!{target} {}\n", typed.encoded())) + .expect("negated host"); + assert!(matches!( + super::verify_known_host(&path, "example.test", 2222, host_key.public_key()) + .expect("verify negated"), + super::HostStatus::Unknown + )); + } + + #[test] + fn cancellation_does_not_touch_known_hosts() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let identity = key(Algorithm::Ed25519); + let identity_path = temporary.path().join("identity"); + let known_hosts = temporary.path().join("known_hosts"); + write_key(&identity_path, &identity); + fs::write(&known_hosts, b"unchanged\n").expect("known hosts"); + let control = GitOperationControl::default(); + control.cancel(); + assert_eq!( + SshSession::connect( + &remote(9, &identity_path, &known_hosts), + &Passphrase(None), + &control, + ) + .expect_err("cancelled"), + GitError::Cancelled + ); + assert_eq!(fs::read(&known_hosts).expect("known hosts"), b"unchanged\n"); + } + + #[cfg(unix)] + #[test] + fn agent_authentication_selects_one_configured_fingerprint() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let socket = temporary.path().join("agent.sock"); + let known_hosts = temporary.path().join("known_hosts"); + let identity = key(Algorithm::Ed25519); + let host_key = key(Algorithm::Ed25519); + let (stop_agent, agent_thread) = start_agent(&socket); + tokio::runtime::Runtime::new() + .expect("client runtime") + .block_on(async { + let mut agent = AgentClient::connect_uds(&socket) + .await + .expect("connect agent"); + agent + .add_identity(&identity, &[]) + .await + .expect("add identity"); + }); + let selected = super::fingerprint(identity.public_key()).expect("fingerprint"); + let missing = + super::fingerprint(key(Algorithm::Ed25519).public_key()).expect("missing fingerprint"); + let (port, attempts, server) = + start_server(host_key.clone(), identity.public_key().clone(), 2); + persist_confirmed_host( + &known_hosts, + &ssh_host_key("127.0.0.1", port, host_key.public_key()).expect("host key"), + ) + .expect("trust host"); + + let remote_for = |fingerprint| { + GitRemote::ssh_with_authentication( + "origin", + format!("ssh://git@127.0.0.1:{port}/team/store.git"), + SshRemoteAuthentication::agent( + fingerprint, + Some(socket.clone()), + known_hosts.clone(), + ) + .expect("agent authentication"), + ) + .expect("SSH remote") + }; + assert!(matches!( + SshSession::connect( + &remote_for(missing.clone()), + &Passphrase(None), + &GitOperationControl::default(), + ) + .expect_err("do not spray another agent key"), + GitError::SshAgentIdentityMissing { fingerprint } if fingerprint == missing + )); + let session = SshSession::connect( + &remote_for(selected), + &Passphrase(None), + &GitOperationControl::default(), + ) + .expect("authenticate with agent"); + session.close().expect("close session"); + server.join().expect("join server"); + assert_eq!(attempts.load(Ordering::Relaxed), 1); + let _ = stop_agent.send(()); + agent_thread.join().expect("join agent"); + } + + #[cfg(unix)] + #[test] + fn unavailable_agent_missing_key_and_rejected_identities_are_typed_failures() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let identity = key(Algorithm::Ed25519); + let host_key = key(Algorithm::Ed25519); + let identity_path = temporary.path().join("identity"); + let known_hosts = temporary.path().join("known_hosts"); + write_key(&identity_path, &identity); + let (port, attempts, server) = + start_server(host_key.clone(), identity.public_key().clone(), 4); + persist_confirmed_host( + &known_hosts, + &ssh_host_key("127.0.0.1", port, host_key.public_key()).expect("host key"), + ) + .expect("trust host"); + let agent_remote = GitRemote::ssh_with_authentication( + "origin", + format!("ssh://git@127.0.0.1:{port}/team/store.git"), + SshRemoteAuthentication::agent( + super::fingerprint(identity.public_key()).expect("fingerprint"), + Some(temporary.path().join("missing-agent.sock")), + known_hosts.clone(), + ) + .expect("agent authentication"), + ) + .expect("SSH remote"); + assert_eq!( + SshSession::connect( + &agent_remote, + &Passphrase(None), + &GitOperationControl::default(), + ) + .expect_err("missing agent"), + GitError::SshAgentUnavailable + ); + let wrong_user = GitRemote::ssh_with_authentication( + "origin", + format!("ssh://wrong@127.0.0.1:{port}/team/store.git"), + SshRemoteAuthentication::key_file(identity_path.clone(), known_hosts.clone()) + .expect("key authentication"), + ) + .expect("SSH remote"); + assert_eq!( + SshSession::connect( + &wrong_user, + &Passphrase(None), + &GitOperationControl::default(), + ) + .expect_err("wrong user"), + GitError::SshAuthenticationRejected + ); + let wrong_identity_path = temporary.path().join("wrong-identity"); + write_key(&wrong_identity_path, &key(Algorithm::Ed25519)); + assert_eq!( + SshSession::connect( + &remote(port, &wrong_identity_path, &known_hosts), + &Passphrase(None), + &GitOperationControl::default(), + ) + .expect_err("wrong key"), + GitError::SshAuthenticationRejected + ); + let missing_identity = temporary.path().join("missing-identity"); + assert_eq!( + SshSession::connect( + &remote(port, &missing_identity, &known_hosts), + &Passphrase(None), + &GitOperationControl::default(), + ) + .expect_err("missing key"), + GitError::SshIdentityMissing { + path: missing_identity + } + ); + server.join().expect("join server"); + assert_eq!(attempts.load(Ordering::Relaxed), 2); + } +} diff --git a/crates/storage/tests/config_contract.rs b/crates/storage/tests/config_contract.rs index da7f81a..66e56a4 100644 --- a/crates/storage/tests/config_contract.rs +++ b/crates/storage/tests/config_contract.rs @@ -701,7 +701,7 @@ url = "https://example.test/team/store.git" } #[test] -fn ssh_remote_configuration_round_trips_without_https_credentials() -> TestResult { +fn ssh_remote_configuration_round_trips_with_secret_free_authentication() -> TestResult { let fixture = ConfigurationFixture::new()?; fixture.write_explicit( r#" @@ -712,12 +712,27 @@ key_material = "keys" [[git.remotes]] name = "origin" url = "git@example.test:team/store.git" +ssh_identity_file = "keys/id_ed25519" +ssh_known_hosts_file = "known_hosts" "#, )?; let config = fixture.loader().load(Some(&fixture.explicit_path()))?; let remote = &config.git_remotes()[0]; assert_eq!(remote.url(), "git@example.test:team/store.git"); assert!(remote.https_credentials().is_none()); + let authentication = remote.ssh_authentication().expect("SSH authentication"); + assert_eq!( + authentication.identity().key_file(), + Some( + fs::canonicalize(fixture.temporary.path())? + .join("cwd/config/keys/id_ed25519") + .as_path() + ) + ); + assert_eq!( + authentication.known_hosts_file(), + fs::canonicalize(fixture.temporary.path())?.join("cwd/config/known_hosts") + ); config.update_git_identity(&GitIdentity::new("Alice", "alice@example.test")?)?; let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?; @@ -725,6 +740,70 @@ url = "git@example.test:team/store.git" let persisted = fs::read_to_string(fixture.explicit_path())?; assert!(!persisted.contains("server_id")); assert!(!persisted.contains("application_id")); + assert!(!persisted.contains("passphrase")); + assert!(persisted.contains("ssh_identity_file")); + assert!(persisted.contains("ssh_known_hosts_file")); + Ok(()) +} + +#[test] +fn ssh_authentication_requires_exactly_one_identity_source() -> TestResult { + let fixture = ConfigurationFixture::new()?; + fixture.write_explicit( + r#" +vault = "vault" +default_key = "alice" +key_material = "keys" + +[[git.remotes]] +name = "origin" +url = "ssh://git@example.test/team/store.git" +"#, + )?; + assert_eq!( + fixture + .loader() + .load(Some(&fixture.explicit_path())) + .expect_err("SSH identity is required"), + ConfigError::InvalidField { + field: "git.remotes.ssh_authentication" + } + ); + + fixture.write_explicit( + r#" +vault = "vault" +default_key = "alice" +key_material = "keys" + +[[git.remotes]] +name = "origin" +url = "ssh://git@example.test/team/store.git" +ssh_agent_fingerprint = "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +ssh_agent_socket = "agent.sock" +ssh_known_hosts_file = "known_hosts" +"#, + )?; + let config = fixture.loader().load(Some(&fixture.explicit_path()))?; + let authentication = config.git_remotes()[0] + .ssh_authentication() + .expect("SSH authentication"); + assert_eq!( + authentication + .identity() + .agent_fingerprint() + .expect("agent fingerprint") + .as_str(), + "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + ); + assert_eq!( + authentication.identity().agent_socket(), + Some( + fs::canonicalize(fixture.temporary.path())? + .join("cwd/config/agent.sock") + .as_path() + ) + ); Ok(()) } diff --git a/crates/storage/tests/secret_store.rs b/crates/storage/tests/secret_store.rs index 66b2861..ecbb6da 100644 --- a/crates/storage/tests/secret_store.rs +++ b/crates/storage/tests/secret_store.rs @@ -11,9 +11,9 @@ use std::{ }; use ironstorage::{ - config::{ConfigLoader, GitRemote}, + config::{ConfigLoader, GitRemote, SshFingerprint}, crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider as _, SecretProviderError}, - git::{GitCredentialProvider as _, GitError}, + git::{GitCredentialProvider as _, GitError, SshPassphraseProvider as _}, repository::{EncryptedEntry, SecretBytes}, secret_store::{ OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, SecretCachePolicy, SecretLocator, @@ -274,6 +274,41 @@ fn denied_cancelled_unavailable_and_corrupted_are_typed_and_redacted() -> TestRe Ok(()) } +#[test] +fn ssh_passphrases_are_retrieved_by_fingerprint_with_typed_access_failures() -> TestResult { + let backend = MemoryBackend::default(); + let store = store(backend.clone()); + store.unlock()?; + let fingerprint = SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")?; + let reference = SecretReference::ssh_key_passphrase(fingerprint.clone()); + store.create( + &reference, + SecretBytes::new(b"protected-passphrase".to_vec()), + )?; + assert_eq!( + store.ssh_key_passphrase(&fingerprint)?.expose(), + b"protected-passphrase" + ); + backend.fail_next(SecretStoreError::Denied); + assert!(matches!( + store.ssh_key_passphrase(&fingerprint), + Err(GitError::SshKeyPassphraseDenied { fingerprint: denied }) if denied == fingerprint + )); + backend.fail_next(SecretStoreError::Cancelled); + assert!(matches!( + store.ssh_key_passphrase(&fingerprint), + Err(GitError::SshKeyPassphraseCancelled { fingerprint: cancelled }) + if cancelled == fingerprint + )); + let missing = SshFingerprint::parse("SHA256:AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE")?; + assert!(matches!( + store.ssh_key_passphrase(&missing), + Err(GitError::SshKeyPassphraseUnavailable { fingerprint }) if fingerprint == missing + )); + assert!(!format!("{reference:?}").contains("protected-passphrase")); + Ok(()) +} + #[test] fn bounded_cache_is_cleared_by_lock_and_never_aliases_git_accounts() -> TestResult { let backend = MemoryBackend::default(); diff --git a/docs/configuration.md b/docs/configuration.md index fda353e..d39a332 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -70,16 +70,35 @@ The HTTPS account name is stored inside the protected credential record, not in TOML. OpenPGP passphrases are addressed by the resolved primary fingerprint. SSH remotes use either `ssh://[user@]host[:port]/path` or scp-like -`[user@]host:path` syntax and omit the HTTPS credential fields: +`[user@]host:path` syntax and omit the HTTPS credential fields. They select +exactly one private-key file or one already-running SSH-agent identity: ```toml [[git.remotes]] name = "origin" url = "git@git.example.test:alice/password-store.git" +ssh_identity_file = "keys/id_ed25519" +ssh_known_hosts_file = "known_hosts" ``` +Relative paths are resolved against `config.toml`. If +`ssh_known_hosts_file` is omitted, `~/.ssh/known_hosts` is used. Agent +authentication replaces `ssh_identity_file` with an exact SHA-256 fingerprint +and may name a socket explicitly: + +```toml +ssh_agent_fingerprint = "SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU" +ssh_agent_socket = "/run/user/1000/ssh-agent.socket" +``` + +Private-key passphrases are stored by fingerprint in the operating-system +secret store and never appear in TOML. IronStorage does not read OpenSSH +configuration, try additional keys, prompt for passwords or +keyboard-interactive authentication, launch an agent, or invoke proxy/helper +commands. + The typed endpoint model is always available so an SSH remote remains readable -in configuration even when the binary was built without SSH. Such a build +through the Rust API even when the binary was built without SSH. Such a build returns a typed unsupported-transport error before connection or repository mutation. The optional storage `ssh` feature contains `russh` 0.63.1 and Tokio; `russh` default features are disabled and the Ring backend plus RSA key support @@ -94,6 +113,12 @@ bytes, credentials, queries, fragments, ambiguous unbracketed IPv6 or colon paths, leading-option paths, local paths, URL rewrites, separate push URLs, helper transports, and unknown schemes fail closed. +Server identity is checked before authentication against the configured +known-hosts file. Exact, hashed, and non-default-port host entries are +supported. Unknown keys return their host, algorithm, and SHA-256 fingerprint +for explicit confirmation; confirming appends the key atomically. A changed +key is a hard failure and is never replaced by that confirmation API. + `clipboard_timeout_seconds` controls the native clipboard presentation lease. It defaults to 45 seconds for upstream `pass` compatibility and must be between 1 and 300 seconds. The CLI remains alive for the lease so Linux can serve its diff --git a/docs/git-synchronization.md b/docs/git-synchronization.md index 27017a2..84ab2a9 100644 --- a/docs/git-synchronization.md +++ b/docs/git-synchronization.md @@ -32,10 +32,7 @@ repository mutation instead of treating its configuration as malformed. ## HTTPS transport HTTPS credentials are requested with the configured server ID and application -ID and remain outside Git configuration. The SSH session, host-verification, -authentication, and pack-protocol implementations are separate milestone work; -until those layers are present, network operations on SSH endpoints return the -typed unsupported-transport result. +ID and remain outside Git configuration. Fetch uses the embedded Rust smart-HTTP client with an explicit credential callback, so Git's credential cascade is never entered. Push implements the @@ -45,6 +42,24 @@ requests `report-status`, and accepts the update only after both unpack and ref status succeed. HTTP redirects are disabled so authorization cannot cross an origin boundary. +## SSH authentication + +With the optional `ssh` feature, storage opens an in-process Russh client and +verifies the server key before any authentication or command. Known-host files +are read with size and line bounds; exact, hashed, and bracketed non-default +port entries are supported. Unknown keys require an explicit call to persist +the confirmed key atomically. Changed keys always fail and are never replaced. + +Authentication uses one configured OpenSSH private-key file (Ed25519, ECDSA, +or RSA) or one exact SHA-256 fingerprint from an already-running SSH agent. +Encrypted-key passphrases come from `SecretBytes` in the operating-system +secret store. Identity attempts are bounded and deterministic; IronStorage +does not spray keys, use passwords or keyboard-interactive authentication, +read OpenSSH configuration, start/probe an agent process, or run proxy/helper +commands. SHA-1 host signatures and `ssh-rsa` authentication are excluded. +Cancellation interrupts connection and authentication without changing Git, +known hosts, or secure storage. + Pull refuses a dirty worktree. It fast-forwards when possible and otherwise uses the embedded three-way tree merge. Unresolved paths are returned as typed `MergeConflicts`; no conflict markers or partial checkout are written. Checkout diff --git a/docs/secure-secret-storage.md b/docs/secure-secret-storage.md index d022d23..2b2f4b5 100644 --- a/docs/secure-secret-storage.md +++ b/docs/secure-secret-storage.md @@ -5,12 +5,13 @@ password-store repositories, Git configuration, command arguments, and logs contain only opaque identifiers; passphrases, tokens, and HTTPS passwords are stored by the operating system. -`SecretReference` has two validated forms. OpenPGP passphrases are keyed by the +`SecretReference` has three validated forms. OpenPGP passphrases are keyed by the primary fingerprint. HTTPS Git credentials are keyed by purpose, server ID, application ID, and account. The account is kept inside the protected record, so a configured server/application pair can retrieve it without adding an -account or secret value to TOML. References, locators, store state, and errors -all use redacted `Debug` output. +account or secret value to TOML. SSH private-key passphrases are keyed by the +key's SHA-256 fingerprint. References, locators, store state, and errors all use +redacted `Debug` output. Stored values use a small versioned binary envelope containing their reference and secret bytes. Retrieval validates the envelope and exact reference before @@ -47,8 +48,8 @@ if the platform lock operation reports an error. Caching is disabled unless a caller explicitly selects `SecretCachePolicy::Timed`. Timed policies are capped at 128 entries and 15 minutes, expire lazily, and are always cleared on lock. -The same unlocked store implements the OpenPGP `SecretProvider` and HTTPS Git -`GitCredentialProvider`. The CLI uses it for terminal `show` and embedded `git -fetch`, proving that protected keys and remote authentication are resolved only -through opaque references. Tests inject a memory backend and never access a -developer or CI user keyring. +The same unlocked store implements the OpenPGP `SecretProvider`, HTTPS Git +`GitCredentialProvider`, and SSH `SshPassphraseProvider`. The CLI uses it for +terminal `show` and embedded Git, proving that protected keys and remote +authentication are resolved only through opaque references. Tests inject a +memory backend and never access a developer or CI user keyring.