38b8729c
Answer rejected SSH auth attempts at once instead of after a second
a73x 2026-09-05 17:09
Commit message
src/server/ssh/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -86,9 +86,20 @@ pub async fn serve( | |||
| 86 | // with the post-quantum `mlkem768x25519-sha256` and keeps curve25519 and | 86 | // with the post-quantum `mlkem768x25519-sha256` and keeps curve25519 and |
| 87 | // the DH groups behind it for older clients. Naming the list explicitly | 87 | // the DH groups behind it for older clients. Naming the list explicitly |
| 88 | // here would only freeze it at today's algorithms. | 88 | // here would only freeze it at today's algorithms. |
| 89 | // russh sleeps `auth_rejection_time` (a full second by default) before | ||
| 90 | // answering any rejected auth attempt, to make password guessing slow. | ||
| 91 | // This server accepts only public keys, where a rejection leaks nothing | ||
| 92 | // an attacker can use — and every OpenSSH client is rejected at least | ||
| 93 | // once per connection, on the "none" probe it sends to learn the method | ||
| 94 | // list, then once more for each identity it tries ahead of the enrolled | ||
| 95 | // one. Each sync opens two connections, so the default cost a user with | ||
| 96 | // a few keys in their agent many seconds per write. Both waits are | ||
| 97 | // dropped to zero. | ||
| 89 | let russh_config = russh::server::Config { | 98 | let russh_config = russh::server::Config { |
| 90 | keys: vec![host_key], | 99 | keys: vec![host_key], |
| 91 | methods: russh::MethodSet::from(&[russh::MethodKind::PublicKey][..]), | 100 | methods: russh::MethodSet::from(&[russh::MethodKind::PublicKey][..]), |
| 101 | auth_rejection_time: std::time::Duration::ZERO, | ||
| 102 | auth_rejection_time_initial: Some(std::time::Duration::ZERO), | ||
| 92 | ..Default::default() | 103 | ..Default::default() |
| 93 | }; | 104 | }; |
| 94 | 105 | ||
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -1678,6 +1678,12 @@ impl ServerHarness { | |||
| 1678 | self.ssh_url_for(&self.repo_name) | 1678 | self.ssh_url_for(&self.repo_name) |
| 1679 | } | 1679 | } |
| 1680 | 1680 | ||
| 1681 | /// The port the server's SSH listener is bound to on loopback, for a test | ||
| 1682 | /// that speaks to it with `ssh` directly rather than through git. | ||
| 1683 | pub fn ssh_port(&self) -> u16 { | ||
| 1684 | self.ssh_addr.port() | ||
| 1685 | } | ||
| 1686 | |||
| 1681 | /// ssh:// URL for any repository name on this server, including one the | 1687 | /// ssh:// URL for any repository name on this server, including one the |
| 1682 | /// server has never seen — which is how a test reaches the create-on-push | 1688 | /// server has never seen — which is how a test reaches the create-on-push |
| 1683 | /// path. | 1689 | /// path. |
tests/ssh_auth_latency_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,59 @@ | |||
| 1 | mod common; | ||
| 2 | |||
| 3 | use std::process::Command; | ||
| 4 | use std::time::{Duration, Instant}; | ||
| 5 | |||
| 6 | use common::ServerHarness; | ||
| 7 | |||
| 8 | /// OpenSSH probes with a "none" attempt and then offers each identity in | ||
| 9 | /// turn, so a client with one stray key ahead of the enrolled one is rejected | ||
| 10 | /// twice before it is accepted. The server must answer every rejection | ||
| 11 | /// promptly: it only accepts public keys, so a delay on rejection slows every | ||
| 12 | /// legitimate connection and protects nothing. | ||
| 13 | /// | ||
| 14 | /// Measured before the fix: about one second per rejection, so a little over | ||
| 15 | /// two seconds here. After it: well under one. | ||
| 16 | #[test] | ||
| 17 | fn rejected_keys_are_answered_without_a_delay() { | ||
| 18 | let harness = ServerHarness::new("latency"); | ||
| 19 | let stray = harness.named_key("stray"); | ||
| 20 | harness.authorize_named_keys(&["enrolled"]); | ||
| 21 | let enrolled = harness.named_key("enrolled"); | ||
| 22 | |||
| 23 | let started = Instant::now(); | ||
| 24 | let output = Command::new("ssh") | ||
| 25 | .args([ | ||
| 26 | "-p", | ||
| 27 | &harness.ssh_port().to_string(), | ||
| 28 | "-i", | ||
| 29 | stray.to_str().unwrap(), | ||
| 30 | "-i", | ||
| 31 | enrolled.to_str().unwrap(), | ||
| 32 | "-o", | ||
| 33 | "StrictHostKeyChecking=no", | ||
| 34 | "-o", | ||
| 35 | "UserKnownHostsFile=/dev/null", | ||
| 36 | "-o", | ||
| 37 | "IdentitiesOnly=yes", | ||
| 38 | "-o", | ||
| 39 | "BatchMode=yes", | ||
| 40 | "-o", | ||
| 41 | "ConnectTimeout=5", | ||
| 42 | "-v", | ||
| 43 | "git@127.0.0.1", | ||
| 44 | "true", | ||
| 45 | ]) | ||
| 46 | .output() | ||
| 47 | .expect("failed to run ssh"); | ||
| 48 | let elapsed = started.elapsed(); | ||
| 49 | |||
| 50 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 51 | assert!( | ||
| 52 | stderr.contains("Authenticated to"), | ||
| 53 | "the enrolled key must be accepted; ssh said:\n{stderr}" | ||
| 54 | ); | ||
| 55 | assert!( | ||
| 56 | elapsed < Duration::from_secs(1), | ||
| 57 | "two rejections then an accept took {elapsed:?}; the server is delaying rejections" | ||
| 58 | ); | ||
| 59 | } | ||