diff --git a/linux/crates/ssh/src/argv.rs b/linux/crates/ssh/src/argv.rs index f07de267df..ff3453e4c3 100644 --- a/linux/crates/ssh/src/argv.rs +++ b/linux/crates/ssh/src/argv.rs @@ -46,6 +46,7 @@ pub(crate) fn master_args( liveness: &LivenessPolicy, host_keys: Option, config_file: Option<&Path>, + identities_only: bool, ) -> Vec { let mut args: Vec = Vec::new(); if let Some(config_file) = config_file { @@ -87,7 +88,9 @@ pub(crate) fn master_args( args.push("-i".into()); args.push(path.as_os_str().to_owned()); // ssh offers the agent's other keys before a file-only key; MaxAuthTries can end the login first. - push_option(&mut args, "IdentitiesOnly=yes".into()); + if identities_only { + push_option(&mut args, "IdentitiesOnly=yes".into()); + } } } SshAuth::Password { .. } => { @@ -185,6 +188,7 @@ mod tests { &LivenessPolicy::DESKTOP, None, None, + false, ); assert_eq!( strings(&args), @@ -262,6 +266,7 @@ mod tests { &LivenessPolicy::DESKTOP, host_keys, None, + false, ); let strict: Vec<&str> = strings(&args) .into_iter() @@ -281,6 +286,7 @@ mod tests { &LivenessPolicy::DESKTOP, None, None, + false, )) .join(" "); assert!(!without.contains(" -p ") && !without.contains(" -l ")); @@ -290,6 +296,7 @@ mod tests { &LivenessPolicy::DESKTOP, None, None, + false, ); assert_eq!( strings(&with[with.len() - 6..]), @@ -307,6 +314,7 @@ mod tests { &LivenessPolicy::DESKTOP, None, None, + false, ); let args = strings(&args); assert_eq!(args.iter().filter(|arg| **arg == "-J").count(), 1); @@ -322,6 +330,7 @@ mod tests { &LivenessPolicy::DESKTOP, None, Some(Path::new("/run/user/1000/m/ssh_config")), + false, ); assert_eq!(strings(&args)[..3], ["-F", "/run/user/1000/m/ssh_config", "-M"]); @@ -330,21 +339,30 @@ mod tests { #[test] fn private_key_path_adds_minus_i() { - let auth = SshAuth::PrivateKey { - path: Some(PathBuf::from("/home/deploy/.ssh/id_ed25519")), - passphrase: None, + let key_file = |identities_only| { + let auth = SshAuth::PrivateKey { + path: Some(PathBuf::from("/home/deploy/.ssh/id_ed25519")), + passphrase: None, + }; + let args = master_args( + &config(None, None, auth), + Path::new("/tmp/control"), + &LivenessPolicy::DESKTOP, + None, + None, + identities_only, + ); + strings(&args).join(" ") }; - let args = master_args( - &config(None, None, auth), - Path::new("/tmp/control"), - &LivenessPolicy::DESKTOP, - None, - None, + let pinned = key_file(true); + let unpinned = key_file(false); + assert!( + pinned.contains(" -i /home/deploy/.ssh/id_ed25519 -o IdentitiesOnly=yes -- db-bastion"), + "{pinned}" ); - let joined = strings(&args).join(" "); assert!( - joined.contains(" -i /home/deploy/.ssh/id_ed25519 -o IdentitiesOnly=yes -- db-bastion"), - "{joined}" + unpinned.contains(" -i /home/deploy/.ssh/id_ed25519 -- db-bastion"), + "{unpinned}" ); let without_path = SshAuth::PrivateKey { @@ -357,6 +375,7 @@ mod tests { &LivenessPolicy::DESKTOP, None, None, + true, ); assert!(!strings(&args).contains(&"-i")); assert!(!strings(&args).contains(&"IdentitiesOnly=yes")); @@ -384,6 +403,7 @@ mod tests { &LivenessPolicy::DESKTOP, None, None, + false, ); let preferred = strings(&args) .into_iter() diff --git a/linux/crates/ssh/src/identity_pin.rs b/linux/crates/ssh/src/identity_pin.rs new file mode 100644 index 0000000000..60b2a314c4 --- /dev/null +++ b/linux/crates/ssh/src/identity_pin.rs @@ -0,0 +1,168 @@ +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use tokio::process::Command; + +const PROBE_LIMIT: Duration = Duration::from_secs(5); + +/// Whether the master gets `IdentitiesOnly=yes` for the key file `key`. +/// ssh puts a key file the agent holds first, with the agent's +/// certificates for it after, so only a key the agent lacks needs the +/// agent's other keys held back. ssh matches the file to the agent by +/// its public half, which an encrypted PEM file without `.pub` hides. +pub(crate) async fn pin_key_file(ssh_keygen: &Path, key: &Path, ssh_g_output: Option<&str>) -> bool { + let Some(fingerprint) = fingerprint(ssh_keygen, key).await else { + return false; + }; + let Some(socket) = agent_socket(ssh_g_output, std::env::var_os("SSH_AUTH_SOCK")) else { + return true; + }; + // ssh-add ships beside ssh-keygen in every OpenSSH client package. + match agent_fingerprints(&ssh_keygen.with_file_name("ssh-add"), &socket).await { + Some(held) => !held.contains(&fingerprint), + None => true, + } +} + +/// The SHA256 fingerprint `ssh-keygen -l` prints when it can read the +/// public half without the passphrase, as ssh must. +async fn fingerprint(ssh_keygen: &Path, key: &Path) -> Option { + let output = Command::new(ssh_keygen) + .arg("-l") + .arg("-f") + .arg(key) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .output(); + // A FIFO or a stalled mount never answers; the key then goes to ssh as before. + let output = tokio::time::timeout(PROBE_LIMIT, output).await.ok()?.ok()?; + if !output.status.success() { + return None; + } + second_field(&String::from_utf8_lossy(&output.stdout)) +} + +/// The agent ssh would ask, from the `identityagent` line `ssh -G` +/// prints with its tokens expanded, or `SSH_AUTH_SOCK` when it prints +/// none. +fn agent_socket(ssh_g_output: Option<&str>, environment: Option) -> Option { + let configured = ssh_g_output + .and_then(|output| output.lines().find_map(|line| line.strip_prefix("identityagent "))) + .map(str::trim); + let socket = match configured { + Some("none") => return None, + Some("SSH_AUTH_SOCK") | None => environment, + // ssh_config(5): a value starting with `$` names the variable that holds the socket. + Some(value) => match value.strip_prefix('$') { + Some(variable) if !variable.starts_with('{') => std::env::var_os(variable), + _ => Some(OsString::from(value)), + }, + }; + socket.filter(|socket| !socket.is_empty()).map(PathBuf::from) +} + +/// What `ssh-add -l` lists, or `None` when the agent could not be asked. +async fn agent_fingerprints(ssh_add: &Path, socket: &Path) -> Option> { + let output = Command::new(ssh_add) + .arg("-l") + .env("SSH_AUTH_SOCK", socket) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .output(); + let output = tokio::time::timeout(PROBE_LIMIT, output).await.ok()?.ok()?; + match output.status.code() { + Some(0) => Some( + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(second_field) + .collect(), + ), + // An agent with no keys exits 1. + Some(1) => Some(Vec::new()), + _ => None, + } +} + +fn second_field(line: &str) -> Option { + line.split_whitespace().nth(1).map(str::to_owned) +} + +#[cfg(test)] +mod tests { + use tablepro_test_fixtures::{SshAgent, SshKeyPair, SshKeyType}; + + use super::*; + + #[test] + fn the_agent_is_the_one_ssh_g_names_or_ssh_auth_sock() { + let environment = || Some(OsString::from("/run/user/1000/agent.sock")); + let from_env = Some(PathBuf::from("/run/user/1000/agent.sock")); + + assert_eq!(agent_socket(None, environment()), from_env); + assert_eq!(agent_socket(Some("user deploy\nport 22\n"), environment()), from_env); + assert_eq!( + agent_socket(Some("identityagent SSH_AUTH_SOCK\n"), environment()), + from_env + ); + assert_eq!( + agent_socket(Some("identityagent /home/u/.1password/agent.sock\n"), environment()), + Some(PathBuf::from("/home/u/.1password/agent.sock")) + ); + assert_eq!(agent_socket(Some("identityagent none\n"), environment()), None); + assert_eq!(agent_socket(None, Some(OsString::new())), None); + assert_eq!(agent_socket(None, None), None); + } + + /// ssh reads an OpenSSH key's public half from the file itself and a + /// PEM key's from its `.pub`; an encrypted PEM key alone hides it. + #[tokio::test] + async fn a_fingerprint_needs_the_public_half_in_the_clear() { + let dir = tempfile::tempdir().unwrap(); + let generate = |name: &str, format: &[&str]| { + let path = dir.path().join(name); + let status = std::process::Command::new("ssh-keygen") + .args(["-q", "-t", "rsa", "-b", "2048", "-N", "secret phrase", "-f"]) + .arg(&path) + .args(format) + .status() + .unwrap(); + assert!(status.success()); + path + }; + let openssh = generate("openssh", &[]); + let pem = generate("pem", &["-m", "PEM"]); + std::fs::remove_file(openssh.with_extension("pub")).unwrap(); + let keygen = Path::new("ssh-keygen"); + + assert!( + fingerprint(keygen, &openssh) + .await + .is_some_and(|print| print.starts_with("SHA256:")) + ); + assert!(fingerprint(keygen, &pem).await.is_some()); + std::fs::remove_file(pem.with_extension("pub")).unwrap(); + assert_eq!(fingerprint(keygen, &pem).await, None); + assert_eq!(fingerprint(keygen, &dir.path().join("missing")).await, None); + } + + #[tokio::test] + async fn a_key_file_is_pinned_unless_the_agent_already_holds_it() { + let agent = SshAgent::start().await.unwrap(); + let held = SshKeyPair::generate("tablepro-held").await.unwrap(); + let other = SshKeyPair::generate_with(SshKeyType::EcdsaP256, None, "tablepro-other") + .await + .unwrap(); + agent.add(&held).await.unwrap(); + let ssh_g = format!("identityagent {}\n", agent.socket().display()); + let keygen = Path::new("ssh-keygen"); + + assert!(!pin_key_file(keygen, held.private_key(), Some(&ssh_g)).await); + assert!(pin_key_file(keygen, other.private_key(), Some(&ssh_g)).await); + assert!(pin_key_file(keygen, held.private_key(), Some("identityagent none\n")).await); + assert!(!pin_key_file(keygen, &held.private_key().with_extension("missing"), Some(&ssh_g)).await); + } +} diff --git a/linux/crates/ssh/src/lib.rs b/linux/crates/ssh/src/lib.rs index 1659c3e376..62493bad5e 100644 --- a/linux/crates/ssh/src/lib.rs +++ b/linux/crates/ssh/src/lib.rs @@ -7,6 +7,7 @@ mod destination; mod forward; mod hop_config; mod host_key_checking; +mod identity_pin; mod known_hosts; mod password_owner; mod prompt; diff --git a/linux/crates/ssh/src/session.rs b/linux/crates/ssh/src/session.rs index f45b410499..2074e33db7 100644 --- a/linux/crates/ssh/src/session.rs +++ b/linux/crates/ssh/src/session.rs @@ -20,6 +20,7 @@ use crate::argv::{ControlOp, master_args}; use crate::askpass_bridge::AskpassBridge; use crate::hop_config; use crate::host_key_checking::HostKeyChecking; +use crate::identity_pin::pin_key_file; use crate::known_hosts::{known_hosts_lookup, spawn_failure}; use crate::password_owner::password_owner; use crate::route::{JumpHop, Route, lookup, trace_route}; @@ -71,6 +72,7 @@ struct MasterPlan { strict_hosts: Vec, route: Route, master_config: Option, + identities_only: bool, } impl MasterPlan { @@ -93,6 +95,16 @@ impl MasterPlan { SshAuth::Agent | SshAuth::PrivateKey { .. } | SshAuth::KeyboardInteractive => None, }; let host_keys = resolved.as_deref().and_then(HostKeyChecking::from_ssh_g); + let identities_only = match &config.auth { + SshAuth::PrivateKey { path: Some(key), .. } => tokio::select! { + pin = pin_key_file(&services.ssh_keygen_program, key, resolved.as_deref()) => pin, + () = cancel.cancelled() => return Err(SshFailure::InteractionCancelled.into()), + }, + SshAuth::PrivateKey { path: None, .. } + | SshAuth::Agent + | SshAuth::Password { .. } + | SshAuth::KeyboardInteractive => false, + }; let route = tokio::select! { route = trace_route(&services.ssh_program, config_file, resolved.as_deref()) => route, () = cancel.cancelled() => return Err(SshFailure::InteractionCancelled.into()), @@ -106,6 +118,7 @@ impl MasterPlan { strict_hosts: strict_hosts(resolved.as_deref(), &route.hops), route, master_config, + identities_only, }) } @@ -124,6 +137,7 @@ impl MasterPlan { liveness, self.host_keys, self.master_config.as_deref(), + self.identities_only, )) .env("SSH_ASKPASS", &services.askpass_program) .env("SSH_ASKPASS_REQUIRE", "force") diff --git a/linux/crates/ssh/tests/openssh_matrix.rs b/linux/crates/ssh/tests/openssh_matrix.rs index bd2ab373b9..7b86311ce7 100644 --- a/linux/crates/ssh/tests/openssh_matrix.rs +++ b/linux/crates/ssh/tests/openssh_matrix.rs @@ -26,7 +26,11 @@ struct Server { impl Server { // The client reads `client_config` after a known_hosts that already trusts the server, and nothing else. async fn start(key: SshKeyPair, options: OpenSshOptions, client_config: &str) -> TestResult { - let fixture = OpenSshFixture::start_with(SshAuthVariant::PublicKey(key), options, None).await?; + Self::start_trusting(SshAuthVariant::PublicKey(key), options, client_config).await + } + + async fn start_trusting(variant: SshAuthVariant, options: OpenSshOptions, client_config: &str) -> TestResult { + let fixture = OpenSshFixture::start_with(variant, options, None).await?; let dir = tempfile::tempdir()?; let known_hosts = dir.path().join("known_hosts"); let host_key = fixture.host_public_key().await?; @@ -114,6 +118,14 @@ fn key_file(key: &SshKeyPair) -> SshAuth { } } +// No passphrase is stored, so only the agent's copy of the key can sign without a prompt. +fn key_file_held_by_the_agent(key: &SshKeyPair) -> SshAuth { + SshAuth::PrivateKey { + path: Some(key.private_key().to_owned()), + passphrase: None, + } +} + async fn agent_holding_decoys(count: usize) -> TestResult { let agent = SshAgent::start().await?; for index in 1..=count { @@ -254,6 +266,74 @@ async fn a_chosen_key_file_is_offered_before_the_agents_keys() -> TestResult { assert_tunnel_answers(&session).await } +// The key file is not pinned when the agent holds it, so the agent signs and its passphrase is never asked for (#2583). +#[tokio::test] +#[ignore = "requires docker"] +async fn a_key_file_the_agent_holds_asks_for_no_passphrase() -> TestResult { + let agent = SshAgent::start().await?; + let key = SshKeyPair::generate_with(SshKeyType::Ed25519, Some(PASSPHRASE), "tablepro-matrix").await?; + agent.add(&key).await?; + let server = Server::start(key, OpenSshOptions::selected()?, &agent_config(&agent)).await?; + + let session = server.connect(key_file_held_by_the_agent(server.key()?)).await??; + + assert_tunnel_answers(&session).await +} + +// An encrypted PEM file without its .pub hides its public half, so with IdentitiesOnly ssh could not match it to the +// agent's copy and would ask for the passphrase. +#[tokio::test] +#[ignore = "requires docker"] +async fn an_encrypted_pem_key_the_agent_holds_asks_for_no_passphrase() -> TestResult { + let agent = SshAgent::start().await?; + let key = SshKeyPair::generate_with(SshKeyType::Rsa, Some(PASSPHRASE), "tablepro-matrix").await?; + agent.add(&key).await?; + let rewritten = std::process::Command::new("ssh-keygen") + .args(["-q", "-p", "-m", "PEM", "-P", PASSPHRASE, "-N", PASSPHRASE, "-f"]) + .arg(key.private_key()) + .status()?; + assert!(rewritten.success()); + std::fs::remove_file(key.public_key_file())?; + let server = Server::start(key, OpenSshOptions::selected()?, &agent_config(&agent)).await?; + + let session = server.connect(key_file_held_by_the_agent(server.key()?)).await??; + + assert_tunnel_answers(&session).await +} + +// The server takes only the certificate, which is in the agent and on no disk. IdentitiesOnly would drop it. +#[tokio::test] +#[ignore = "requires docker"] +async fn an_agent_certificate_for_the_chosen_key_file_is_still_offered() -> TestResult { + let agent = SshAgent::start().await?; + let authority = SshKeyPair::generate("tablepro-user-ca").await?; + let variant = SshAuthVariant::UserCertificateAuthority(authority); + let server = Server::start_trusting(variant, OpenSshOptions::selected()?, &agent_config(&agent)).await?; + let SshAuthVariant::UserCertificateAuthority(authority) = server.fixture.variant() else { + return Err("the server lost its certificate authority".into()); + }; + let key = SshKeyPair::generate("tablepro-matrix").await?; + let signed = std::process::Command::new("ssh-keygen") + .args(["-q", "-s"]) + .arg(authority.private_key()) + .args(["-I", "tablepro-matrix", "-n"]) + .arg(server.fixture.password_credentials().username) + .arg(key.public_key_file()) + .status()?; + assert!(signed.success()); + agent.add(&key).await?; + std::fs::remove_file(format!("{}-cert.pub", key.private_key().display()))?; + assert_eq!( + agent.identities().await?.len(), + 2, + "the agent holds the key and its certificate" + ); + + let session = server.connect(key_file(&key)).await??; + + assert_tunnel_answers(&session).await +} + async fn client_key_exchanges() -> TestResult> { let output = tokio::process::Command::new("ssh").args(["-Q", "kex"]).output().await?; Ok(String::from_utf8_lossy(&output.stdout) diff --git a/linux/crates/test-fixtures/src/openssh_fixture.rs b/linux/crates/test-fixtures/src/openssh_fixture.rs index 775bd79cdc..79e3804a69 100644 --- a/linux/crates/test-fixtures/src/openssh_fixture.rs +++ b/linux/crates/test-fixtures/src/openssh_fixture.rs @@ -34,6 +34,7 @@ impl OpenSshFixture { pub const DROP_IN: &'static str = "/config/sshd/sshd_config.d/tablepro.conf"; pub const SSHD_CONFIG: &'static str = "/config/sshd/sshd_config"; pub const CONTAINER_PORT: u16 = 2222; + const USER_CA: &'static str = "/tablepro/user_ca.pub"; pub async fn start(variant: SshAuthVariant) -> Result { Self::start_with(variant, OpenSshOptions::selected()?, None).await @@ -54,7 +55,10 @@ impl OpenSshFixture { let password = FixtureCredentials::generate_password(); let host_certificate = match variant { SshAuthVariant::HostCertificate => Some(HostCertificate::generate().await?), - SshAuthVariant::Password | SshAuthVariant::PublicKey(_) | SshAuthVariant::KeyboardInteractive => None, + SshAuthVariant::Password + | SshAuthVariant::PublicKey(_) + | SshAuthVariant::UserCertificateAuthority(_) + | SshAuthVariant::KeyboardInteractive => None, }; let mut request = options .version @@ -75,6 +79,12 @@ impl OpenSshFixture { Self::DROP_IN, Self::drop_in(&variant, &options), )]; + if let SshAuthVariant::UserCertificateAuthority(authority) = &variant { + files.push(ContainerFile::readable( + Self::USER_CA, + format!("{}\n", authority.public_key()), + )); + } if let Some(certificate) = &host_certificate { files.extend(certificate.files()?); } @@ -115,6 +125,9 @@ impl OpenSshFixture { SshAuthVariant::HostCertificate => { configuration.push_str(&format!("HostCertificate {}\n", HostCertificate::CERTIFICATE)); } + SshAuthVariant::UserCertificateAuthority(_) => { + configuration.push_str(&format!("TrustedUserCAKeys {}\n", Self::USER_CA)); + } SshAuthVariant::Password | SshAuthVariant::PublicKey(_) => {} } configuration.push_str(&options.sshd_lines()); @@ -190,6 +203,7 @@ async fn banner_received(host: &str, port: u16) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::SshKeyPair; #[test] fn every_variant_enables_tcp_forwarding_first_and_the_options_last() { @@ -227,4 +241,18 @@ mod tests { .contains(HostCertificate::CERTIFICATE) ); } + + #[tokio::test] + async fn a_user_certificate_authority_is_the_only_way_in() { + let authority = SshKeyPair::generate("tablepro-user-ca").await.unwrap(); + let variant = SshAuthVariant::UserCertificateAuthority(authority); + + let drop_in = OpenSshFixture::drop_in(&variant, &OpenSshOptions::default()); + + assert!( + drop_in.contains("TrustedUserCAKeys /tablepro/user_ca.pub\n"), + "{drop_in}" + ); + assert!(!variant.password_access()); + } } diff --git a/linux/crates/test-fixtures/src/ssh_agent.rs b/linux/crates/test-fixtures/src/ssh_agent.rs index b856b307c0..3d3a5ada0d 100644 --- a/linux/crates/test-fixtures/src/ssh_agent.rs +++ b/linux/crates/test-fixtures/src/ssh_agent.rs @@ -1,13 +1,12 @@ -use std::ffi::OsStr; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Output, Stdio}; +use std::process::{Child, Command, Stdio}; use std::time::Duration; use tempfile::TempDir; use tokio::time::Instant; use crate::host_command::run_host_command_with_env; -use crate::{FixtureError, SshKeyPair}; +use crate::{FixtureError, ScriptedAskpass, SshKeyPair}; const SOCKET_TIMEOUT: Duration = Duration::from_secs(10); const SOCKET_RETRY: Duration = Duration::from_millis(20); @@ -50,9 +49,26 @@ impl SshAgent { } /// Load a key, and its `-cert.pub` certificate when one sits beside - /// it, the way `ssh-add` does. + /// it, the way `ssh-add` does. A key with a passphrase is unlocked + /// with it. pub async fn add(&self, key: &SshKeyPair) -> Result<(), FixtureError> { - self.ssh_add([key.private_key().as_os_str()]).await.map(drop) + let mut environment = vec![("SSH_AUTH_SOCK", self.socket.as_os_str().to_owned())]; + // Kept until ssh-add has run, since dropping it deletes the script. + let _askpass = match key.passphrase() { + Some(passphrase) => { + let askpass = ScriptedAskpass::new()?; + environment.extend([ + ("SSH_ASKPASS", askpass.path().as_os_str().to_owned()), + ("SSH_ASKPASS_REQUIRE", "force".into()), + (ScriptedAskpass::ANSWER_VARIABLE, passphrase.into()), + ]); + Some(askpass) + } + None => None, + }; + run_host_command_with_env("ssh-add", [key.private_key().as_os_str()], environment) + .await + .map(drop) } /// The public keys the agent holds, one OpenSSH line each. @@ -79,11 +95,6 @@ impl SshAgent { } } - async fn ssh_add<'a>(&self, arguments: impl IntoIterator) -> Result { - let environment = [("SSH_AUTH_SOCK", self.socket.as_os_str())]; - run_host_command_with_env("ssh-add", arguments, environment).await - } - async fn wait_for_socket(&self) -> Result<(), FixtureError> { let deadline = Instant::now() + SOCKET_TIMEOUT; while Instant::now() < deadline { @@ -122,6 +133,18 @@ mod tests { assert_eq!(agent.identities().await.unwrap(), vec![key.public_key().to_owned()]); } + #[tokio::test] + async fn a_key_with_a_passphrase_is_unlocked_into_the_agent() { + let agent = SshAgent::start().await.unwrap(); + let key = SshKeyPair::generate_with(crate::SshKeyType::Ed25519, Some("open sesame"), "tablepro-agent-test") + .await + .unwrap(); + + agent.add(&key).await.unwrap(); + + assert_eq!(agent.identities().await.unwrap(), vec![key.public_key().to_owned()]); + } + #[tokio::test] async fn dropping_the_agent_stops_it() { let agent = SshAgent::start().await.unwrap(); diff --git a/linux/crates/test-fixtures/src/ssh_auth_variant.rs b/linux/crates/test-fixtures/src/ssh_auth_variant.rs index 229e706477..68a5fb0741 100644 --- a/linux/crates/test-fixtures/src/ssh_auth_variant.rs +++ b/linux/crates/test-fixtures/src/ssh_auth_variant.rs @@ -4,12 +4,15 @@ use crate::SshKeyPair; pub enum SshAuthVariant { Password, PublicKey(SshKeyPair), + /// TrustedUserCAKeys names this key, so only a certificate it signed + /// logs in. + UserCertificateAuthority(SshKeyPair), KeyboardInteractive, HostCertificate, } impl SshAuthVariant { pub fn password_access(&self) -> bool { - !matches!(self, Self::PublicKey(_)) + !matches!(self, Self::PublicKey(_) | Self::UserCertificateAuthority(_)) } } diff --git a/linux/docs/decisions/0019-ssh-key-file-identities-only.md b/linux/docs/decisions/0019-ssh-key-file-identities-only.md index d7c6a17715..2934df1fd1 100644 --- a/linux/docs/decisions/0019-ssh-key-file-identities-only.md +++ b/linux/docs/decisions/0019-ssh-key-file-identities-only.md @@ -1,6 +1,6 @@ # 0019: A chosen SSH key file limits the keys ssh offers -- **Status**: Accepted +- **Status**: Superseded by 0020 - **Date**: 2026-10-20 ## Context diff --git a/linux/docs/decisions/0020-pin-only-a-key-file-the-agent-lacks.md b/linux/docs/decisions/0020-pin-only-a-key-file-the-agent-lacks.md new file mode 100644 index 0000000000..2be548af79 --- /dev/null +++ b/linux/docs/decisions/0020-pin-only-a-key-file-the-agent-lacks.md @@ -0,0 +1,24 @@ +# 0020: Pin only a key file the agent does not hold + +- **Status**: Accepted +- **Date**: 2026-10-21 + +## Context + +[0019](0019-ssh-key-file-identities-only.md) gives the master `-o IdentitiesOnly=yes` whenever the connection names a key file. Two setups that connected before stop connecting under it: + +- The agent holds a certificate for the chosen key and no `-cert.pub` sits beside the file, as with certificates an SSO tool loads into the agent. `IdentitiesOnly` keeps the agent's plain key and drops the certificate, so a server that takes only certificates refuses the login. +- The chosen file is an encrypted PEM key with no `.pub`, and the agent holds it. ssh cannot read the file's public half to match it to the agent's copy, so it asks for the passphrase instead of letting the agent sign. + +When the agent holds the chosen key, ssh already offers it first, with the agent's certificates for it after. `MaxAuthTries` cannot run out before it. + +## Decision + +The master gets `-o IdentitiesOnly=yes` only when `ssh-keygen -l` reads the key file's fingerprint and the agent `ssh -G` names does not list it in `ssh-add -l`. This supersedes 0019. + +## Consequences + +- An agent full of other keys still cannot use up `MaxAuthTries` before a key only in the file. +- A key the agent holds, a certificate only the agent holds and an encrypted PEM key the agent holds sign through the agent with no prompt. +- Each connection with a key file runs `ssh-keygen -l` and `ssh-add -l` first, each bounded at 5 seconds. A file whose fingerprint cannot be read is not pinned. An agent that cannot be asked counts as not holding the key. +- A jump host still gets none of the master's `-o` options, as 0019 says. diff --git a/linux/docs/decisions/README.md b/linux/docs/decisions/README.md index d29dd68b6a..a6557acac9 100644 --- a/linux/docs/decisions/README.md +++ b/linux/docs/decisions/README.md @@ -66,7 +66,8 @@ A fact the repository keeps elsewhere, such as a workflow trigger, a branch, a v | [0016](0016-driver-contract-v2.md) | Driver contract v2 | Accepted | Owned calls with engine stops, phase-aware write outcomes, server-side row limits; PostgreSQL on tokio-postgres; no forks. | | [0017](0017-distribution-channels.md) | Distribution channels | Superseded by 0018 | Flathub, a .deb for Ubuntu 26.04 on GitHub Releases built with rustup, and an .rpm for Fedora 44 on COPR, all from `meson install` and one vendor tarball; no AppImage. | | [0018](0018-ppa-copr-and-flathub-later.md) | A PPA and COPR first, Flathub later | Accepted | A Launchpad PPA built with `rustc-1.97` and a COPR project built on each release are the primary channels; Flathub follows as a submission a maintainer writes; Rust 1.97 is the MSRV. | -| [0019](0019-ssh-key-file-identities-only.md) | A chosen SSH key file limits the keys ssh offers | Accepted | A key file comes with `IdentitiesOnly=yes`, so the agent's other keys cannot use up `MaxAuthTries` first. | +| [0019](0019-ssh-key-file-identities-only.md) | A chosen SSH key file limits the keys ssh offers | Superseded by 0020 | A key file comes with `IdentitiesOnly=yes`, so the agent's other keys cannot use up `MaxAuthTries` first. | +| [0020](0020-pin-only-a-key-file-the-agent-lacks.md) | Pin only a key file the agent does not hold | Accepted | `IdentitiesOnly=yes` only for a key file the agent lacks, so an agent-held certificate or encrypted PEM key still signs. | 0011 is not used. It was reserved for the DDL decision, which was recorded as 0010, and docs cite ADRs by number, so the later ones keep theirs. diff --git a/linux/docs/testing.md b/linux/docs/testing.md index 7f48c207d8..9de771010a 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -112,7 +112,7 @@ Every TLS fixture has two principals: SQL Server has only the `sa` principal, because its TLS contract carries no client identity. -`HbaMode` picks what the PostgreSQL fixture accepts: `Password` (the image defaults, no TLS), `HostSslOnly`, or `ClientCertificate`. Docker maps a published port anew each time a container starts, so a test that restarts the server starts it with `PostgresFixture::start_restartable`, which picks the host port itself; `stop` and `start_again` then bring it back where the session's connection points, with its data. `OpenSshFixture` takes an `SshAuthVariant`: `Password`, `PublicKey`, `KeyboardInteractive` or `HostCertificate`. `OpenSshFixture::start_with` also takes `OpenSshOptions`, which picks the release and sets sshd's `KexAlgorithms`, `MaxAuthTries` and `PubkeyAcceptedAlgorithms`. `SshKeyPair::generate_with` makes an Ed25519, RSA or ECDSA P-256 key, with a passphrase when given one, and `SshAgent` runs a private `ssh-agent`. `HostKeyRevocation` builds a KRL from the running server's host key, and `ScriptedAskpass` writes a 0700 script for `SSH_ASKPASS`. +`HbaMode` picks what the PostgreSQL fixture accepts: `Password` (the image defaults, no TLS), `HostSslOnly`, or `ClientCertificate`. Docker maps a published port anew each time a container starts, so a test that restarts the server starts it with `PostgresFixture::start_restartable`, which picks the host port itself; `stop` and `start_again` then bring it back where the session's connection points, with its data. `OpenSshFixture` takes an `SshAuthVariant`: `Password`, `PublicKey`, `UserCertificateAuthority` (only certificates that key signed log in), `KeyboardInteractive` or `HostCertificate`. `OpenSshFixture::start_with` also takes `OpenSshOptions`, which picks the release and sets sshd's `KexAlgorithms`, `MaxAuthTries` and `PubkeyAcceptedAlgorithms`. `SshKeyPair::generate_with` makes an Ed25519, RSA or ECDSA P-256 key, with a passphrase when given one, and `SshAgent` runs a private `ssh-agent` and loads a key with its passphrase. `HostKeyRevocation` builds a KRL from the running server's host key, and `ScriptedAskpass` writes a 0700 script for `SSH_ASKPASS`. ### Conformance