Implement SSH identity authentication

This commit is contained in:
2026-08-25 19:52:47 +02:00
parent f636f3b551
commit 5dbda4bbd2
14 changed files with 1964 additions and 60 deletions

81
Cargo.lock generated
View File

@@ -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]]

View File

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

View File

@@ -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,

View File

@@ -94,3 +94,4 @@ nix = { version = "0.31", features = ["fs"] }
rand_chacha = "0.3"
smallvec = "1.15"
tempfile = "3"
tokio-stream.workspace = true

View File

@@ -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<toml::Table, ConfigError> {
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<String>) -> Result<Self, InvalidRemoteEndpoint> {
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<PathBuf>,
},
}
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<Self, ConfigError> {
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<PathBuf>,
known_hosts_file: PathBuf,
) -> Result<Self, ConfigError> {
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<SshRemoteAuthentication>),
}
impl GitRemote {
@@ -1132,25 +1272,49 @@ impl GitRemote {
server_id: impl Into<String>,
application_id: impl Into<String>,
) -> Result<Self, ConfigError> {
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<String>, url: impl Into<String>) -> Result<Self, ConfigError> {
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<String>,
url: impl Into<String>,
authentication: SshRemoteAuthentication,
) -> Result<Self, ConfigError> {
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<String>,
application_id: Option<String>,
ssh_identity_file: Option<PathBuf>,
ssh_agent_fingerprint: Option<String>,
ssh_agent_socket: Option<PathBuf>,
ssh_known_hosts_file: Option<PathBuf>,
}
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<RawGitRemote>) -> Result<Vec<GitRemote>, ConfigError> {
fn validate_remotes(
raw: Vec<RawGitRemote>,
config_base: Option<&Path>,
) -> Result<Vec<GitRemote>, 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<RawGitRemote>) -> Result<Vec<GitRemote>, 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<RawGitRemote>) -> Result<Vec<GitRemote>, 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<RawGitRemote>) -> Result<Vec<GitRemote>, ConfigErro
Ok(remotes)
}
fn resolve_ssh_path(
base: &Path,
value: PathBuf,
field: &'static str,
) -> Result<PathBuf, ConfigError> {
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<PathBuf> {
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<PathBuf> {
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<String, ConfigError> {
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",
],
)?;
}
}

View File

@@ -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<SshHostKey>,
},
ChangedSshHostKey {
host_key: Box<SshHostKey>,
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<GitCredential, GitError>;
}
pub trait SshPassphraseProvider {
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError>;
}
pub trait GitSmartHttpTransport {
fn advertise_receive_pack(
&self,

View File

@@ -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;

View File

@@ -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<B: SecretStoreBackend> GitCredentialProvider for SecretStore<B> {
}
}
impl<B: SecretStoreBackend> SshPassphraseProvider for SecretStore<B> {
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> {
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<SecretRecord, SecretStoreError>
)
.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."));
}
}

1148
crates/storage/src/ssh.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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(())
}

View File

@@ -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();

View File

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

View File

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

View File

@@ -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.