a73x

38b8729c

Answer rejected SSH auth attempts at once instead of after a second

a73x   2026-09-05 17:09

Commit message
Answer rejected SSH auth attempts at once instead of after a second

russh sleeps `auth_rejection_time`, one second by default, before answering
any rejected auth attempt. The server left it at the default, so every
OpenSSH client paid it at least once per connection: the "none" probe a
client sends to learn the method list is always rejected, and so is each
identity it offers ahead of the enrolled one. A sync opens two connections,
and every write command auto-syncs, so a user with a few keys in their agent
waited many seconds on each issue or patch write. Measured on loopback:
1.08 s to log in with the enrolled key alone, plus one second per key tried
before it.

The delay exists to slow password guessing. This server accepts only public
keys, where a rejection tells an attacker nothing they can use, so it bought
no security. Both waits drop to zero; the test times a login with one stray
key ahead of the enrolled one and now sees it finish in about a tenth of a
second.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

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 }