Skip to content

Commit faa635a

Browse files
author
Lucas RAVAGNIER
committed
add SSH feature coverage for the OpenSSH update
Add regression/feature tests for the new OpenSSH package (post-quantum KEX, FIDO/security-key support, scp/sftp behavior, ssh_config.d inclusion) - test_ssh_config_include.py: extends the sshd_config.d include regression test with the client-side ssh_config.d equivalent. - test_ssh_algorithms.py: hardcoded, exhaustive coverage of the KEX/cipher/MAC/host-key algorithms the package supports, so any future change to that list has to be a conscious update of this file. - test_ssh_post_quantum.py: checks the hybrid post-quantum KEX is negotiated by default. - test_ssh_fido.py: checks FIDO/security-key (sk-*) support is advertised and wired in. - test_ssh_file_transfer.py: checks scp (SFTP and legacy protocols) and sftp file transfers. Signed-off-by: Lucas RAVAGNIER <lucas.ravagnier@vates.tech>
1 parent c55e3df commit faa635a

7 files changed

Lines changed: 403 additions & 28 deletions

tests/system/conftest.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import pytest
2+
3+
import lib.config
4+
5+
# These hosts print a root-login warning on every ssh session, which
6+
# pollutes command output and breaks the many places across this codebase
7+
# (Host.is_master(), etc.) that assume clean output. Strip it like
8+
# --ignore-ssh-banner would, without requiring that flag, for every test
9+
# in this directory.
10+
@pytest.hookimpl(trylast=True)
11+
def pytest_configure(config: pytest.Config) -> None:
12+
lib.config.ignore_ssh_banner = True
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import pytest
2+
3+
from lib.commands import SSHCommandFailed, ssh
4+
from lib.host import Host
5+
6+
# Exhaustive coverage of the KEX/cipher/MAC/host-key algorithms OpenSSH
7+
# supports, hardcoded from `ssh -Q kex|cipher|mac|key` on the reference
8+
# build (openssh-9.9p1-27.2.xcpng8.3). The lists are deliberately not
9+
# queried live: the point of test_*_list_matches_hardcoded is to fail the
10+
# day the compiled-in algorithm support changes, forcing a conscious
11+
# update of this file instead of the change going unnoticed.
12+
#
13+
# Requirements:
14+
# - an XCP-ng host (--hosts) >= 8.3, with the OpenSSH 9.9p1
15+
16+
# algorithm -> enabled by default (sshd -T) on the reference build
17+
KEX_ALGORITHMS = {
18+
# hybrid post-quantum, new in this OpenSSH update
19+
"mlkem1024nistp384-sha384": True,
20+
"mlkem768x25519-sha256": True,
21+
"mlkem768nistp256-sha256": True,
22+
"sntrup761x25519-sha512": True,
23+
"sntrup761x25519-sha512@openssh.com": True,
24+
# classical, enabled
25+
"curve25519-sha256": True,
26+
"curve25519-sha256@libssh.org": True,
27+
"ecdh-sha2-nistp521": True,
28+
"ecdh-sha2-nistp384": True,
29+
"ecdh-sha2-nistp256": True,
30+
"diffie-hellman-group16-sha512": True,
31+
"diffie-hellman-group18-sha512": True,
32+
# compiled in, but disabled
33+
"diffie-hellman-group1-sha1": False,
34+
"diffie-hellman-group14-sha1": False,
35+
"diffie-hellman-group14-sha256": False,
36+
"diffie-hellman-group-exchange-sha1": False,
37+
"diffie-hellman-group-exchange-sha256": False,
38+
}
39+
40+
CIPHERS = {
41+
"chacha20-poly1305@openssh.com": True,
42+
"aes256-gcm@openssh.com": True,
43+
"aes128-gcm@openssh.com": True,
44+
"aes256-ctr": True,
45+
"aes128-ctr": True,
46+
"3des-cbc": False,
47+
"aes128-cbc": False,
48+
"aes192-cbc": False,
49+
"aes256-cbc": False,
50+
"aes192-ctr": False,
51+
}
52+
53+
MACS = {
54+
"hmac-sha2-512-etm@openssh.com": True,
55+
"hmac-sha2-256-etm@openssh.com": True,
56+
"umac-128-etm@openssh.com": True,
57+
"hmac-sha2-512": True,
58+
"hmac-sha2-256": True,
59+
"umac-128@openssh.com": True,
60+
"hmac-sha1": False,
61+
"hmac-sha1-96": False,
62+
"hmac-md5": False,
63+
"hmac-md5-96": False,
64+
"umac-64@openssh.com": False,
65+
"hmac-sha1-etm@openssh.com": False,
66+
"hmac-sha1-96-etm@openssh.com": False,
67+
"hmac-md5-etm@openssh.com": False,
68+
"hmac-md5-96-etm@openssh.com": False,
69+
"umac-64-etm@openssh.com": False,
70+
}
71+
72+
# Server host identity algorithms actually exercisable end to end: the host
73+
# only carries one host key per type (ed25519, ecdsa on nistp256, rsa), so
74+
# only algorithms with matching key material are forced here. Certificate
75+
# variants would need a CA, and the sk-* types from `ssh -Q key` are not
76+
# host identity keys at all (they're for user authentication via a
77+
# hardware security key, see test_ssh_fido.py) so they don't belong in a
78+
# HostKeyAlgorithms test.
79+
HOSTKEY_ALGORITHMS = {
80+
"ssh-ed25519": True,
81+
"ecdsa-sha2-nistp256": True,
82+
"rsa-sha2-256": True,
83+
"rsa-sha2-512": True,
84+
"ssh-rsa": False, # raw SHA-1 RSA signature, disabled by crypto-policy
85+
}
86+
87+
# Full compiled-in key type support (`ssh -Q key`), used only to detect if
88+
# that list ever drifts (e.g. FIDO/security-key support silently regressing,
89+
# as it did between the previous and this OpenSSH build).
90+
ALL_COMPILED_KEY_TYPES = {
91+
"ssh-ed25519",
92+
"ssh-ed25519-cert-v01@openssh.com",
93+
"sk-ssh-ed25519@openssh.com",
94+
"sk-ssh-ed25519-cert-v01@openssh.com",
95+
"ecdsa-sha2-nistp256",
96+
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
97+
"ecdsa-sha2-nistp384",
98+
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
99+
"ecdsa-sha2-nistp521",
100+
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
101+
"sk-ecdsa-sha2-nistp256@openssh.com",
102+
"sk-ecdsa-sha2-nistp256-cert-v01@openssh.com",
103+
"ssh-rsa",
104+
"ssh-rsa-cert-v01@openssh.com",
105+
}
106+
107+
def _assert_list_matches(host: Host, query: str, expected: set) -> None:
108+
actual = {line for line in host.ssh(f"ssh -Q {query}").splitlines() if line and ' ' not in line}
109+
assert actual == expected, (
110+
f"`ssh -Q {query}` no longer matches the hardcoded list in this test file "
111+
f"(missing={sorted(expected - actual)}, new={sorted(actual - expected)}); "
112+
"update the hardcoded list after reviewing the change"
113+
)
114+
115+
def test_kex_algorithms_list_matches_hardcoded(host: Host) -> None:
116+
_assert_list_matches(host, "kex", set(KEX_ALGORITHMS))
117+
118+
def test_ciphers_list_matches_hardcoded(host: Host) -> None:
119+
_assert_list_matches(host, "cipher", set(CIPHERS))
120+
121+
def test_macs_list_matches_hardcoded(host: Host) -> None:
122+
_assert_list_matches(host, "mac", set(MACS))
123+
124+
def test_key_types_list_matches_hardcoded(host: Host) -> None:
125+
_assert_list_matches(host, "key", ALL_COMPILED_KEY_TYPES)
126+
127+
def _force_algorithm(host: Host, option: str, algo: str, *, extra_options: list[str] = []) -> None:
128+
# multiplexing must be off: a shared control connection would reuse the
129+
# algorithm negotiated by whichever call created it, silently ignoring
130+
# the -o option on every later call.
131+
ssh(host.hostname_or_ip, 'true', options=['-o', f'{option}={algo}'] + extra_options, multiplexing=False)
132+
133+
# mlkem768nistp256-sha256 and mlkem1024nistp384-sha384 aren't part of
134+
# upstream OpenSSH (see openssh-10.0-mlkem-nist.patch): they're carried by
135+
# RHEL-family builds (RHEL, CentOS, Alma, and this XCP-ng build) for FIPS
136+
# compliance, but not by other builds, including the machine running these
137+
# tests. There's no second RHEL-family host available to interoperate with
138+
# either, so unlike every other algorithm here, these two are exercised in
139+
# loopback on the host itself (which does understand its own names). That
140+
# can't reach an authenticated session (root has no key to log into
141+
# itself), so instead we assert the exact requested algorithm was the one
142+
# negotiated.
143+
SPECIFIC_KEX_NAMES = {"mlkem768nistp256-sha256", "mlkem1024nistp384-sha384"}
144+
145+
def _negotiated_kex_in_loopback(host: Host, algo: str) -> str:
146+
# the trailing "; true" keeps the remote command's exit code at 0
147+
# (the inner loopback ssh fails at authentication, not at key exchange)
148+
# so we can just inspect its output instead of juggling SSHCommandFailed.
149+
output = host.ssh(
150+
"ssh -v -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
151+
f"-o ControlMaster=no -o KexAlgorithms={algo} localhost true 2>&1; true"
152+
)
153+
for line in output.splitlines():
154+
if line.startswith("debug1: kex: algorithm:"):
155+
return line.rsplit(':', 1)[1].strip()
156+
pytest.fail(f"could not find the negotiated KEX algorithm in loopback ssh -v output:\n{output}")
157+
158+
@pytest.mark.parametrize("algo,enabled", KEX_ALGORITHMS.items(), ids=list(KEX_ALGORITHMS))
159+
def test_kex_algorithm(host: Host, algo: str, enabled: bool) -> None:
160+
if algo in SPECIFIC_KEX_NAMES:
161+
assert enabled
162+
assert _negotiated_kex_in_loopback(host, algo) == algo
163+
elif enabled:
164+
_force_algorithm(host, "KexAlgorithms", algo)
165+
else:
166+
with pytest.raises(SSHCommandFailed):
167+
_force_algorithm(host, "KexAlgorithms", algo)
168+
169+
@pytest.mark.parametrize("algo,enabled", CIPHERS.items(), ids=list(CIPHERS))
170+
def test_cipher_algorithm(host: Host, algo: str, enabled: bool) -> None:
171+
if enabled:
172+
_force_algorithm(host, "Ciphers", algo)
173+
else:
174+
with pytest.raises(SSHCommandFailed):
175+
_force_algorithm(host, "Ciphers", algo)
176+
177+
@pytest.mark.parametrize("algo,enabled", MACS.items(), ids=list(MACS))
178+
def test_mac_algorithm(host: Host, algo: str, enabled: bool) -> None:
179+
# MACs are only negotiated for non-AEAD ciphers (AEAD ciphers like the
180+
# default chacha20-poly1305/aes-gcm have their MAC built in, making the
181+
# MACs option moot), so a non-AEAD cipher is pinned to force the point.
182+
non_aead_cipher = ['-o', 'Ciphers=aes256-ctr']
183+
if enabled:
184+
_force_algorithm(host, "MACs", algo, extra_options=non_aead_cipher)
185+
else:
186+
with pytest.raises(SSHCommandFailed):
187+
_force_algorithm(host, "MACs", algo, extra_options=non_aead_cipher)
188+
189+
@pytest.mark.parametrize("algo,enabled", HOSTKEY_ALGORITHMS.items(), ids=list(HOSTKEY_ALGORITHMS))
190+
def test_hostkey_algorithm(host: Host, algo: str, enabled: bool) -> None:
191+
if enabled:
192+
_force_algorithm(host, "HostKeyAlgorithms", algo)
193+
else:
194+
with pytest.raises(SSHCommandFailed):
195+
_force_algorithm(host, "HostKeyAlgorithms", algo)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import random
2+
3+
from lib.common import Defer
4+
from lib.host import Host
5+
6+
# Regression test for the ssh client and sshd silently ignoring drop-in
7+
# configuration files (missing "Include" directive in the packaged config).
8+
# Covers both /etc/ssh/ssh_config.d/*.conf (client) and
9+
# /etc/ssh/sshd_config.d/*.conf (server).
10+
#
11+
# Each test creates a drop-in with a directive not set elsewhere, then uses
12+
# `ssh -G` or `sshd -T` to verify that the corresponding directory is included.
13+
#
14+
# Requirements:
15+
# - an XCP-ng host (--hosts) >= 8.2
16+
# - the ssh_config.d test additionally requires the OpenSSH security update:
17+
# the Include directive isn't in the previous package's ssh_config at all
18+
19+
SSH_CONFIG_D = "/etc/ssh/ssh_config.d"
20+
SSHD_CONFIG_D = "/etc/ssh/sshd_config.d"
21+
22+
def test_ssh_config_d_is_included(host: Host, defer: Defer) -> None:
23+
marker = str(random.randint(10000, 99999))
24+
dropin = f"{SSH_CONFIG_D}/99-xcp-ng-tests-include-check.conf"
25+
26+
host.ssh(f"echo 'ConnectTimeout {marker}' > {dropin}")
27+
defer(lambda: host.ssh(f"rm -f {dropin}"))
28+
29+
effective_config = host.ssh("ssh -G localhost")
30+
assert f"connecttimeout {marker}" in effective_config, (
31+
f"drop-in {dropin} was not picked up by ssh -G: "
32+
f"{SSH_CONFIG_D} seems to be ignored"
33+
)
34+
35+
def test_sshd_config_d_is_included(host: Host, defer: Defer) -> None:
36+
marker = host.ssh('mktemp')
37+
dropin = f"{SSHD_CONFIG_D}/99-xcp-ng-tests-include-check.conf"
38+
39+
host.ssh(f"echo 'Banner {marker}' > {dropin}")
40+
defer(lambda: host.ssh(f"rm -f {dropin}"))
41+
42+
effective_config = host.ssh("/usr/sbin/sshd -T")
43+
assert f"banner {marker}" in effective_config, (
44+
f"drop-in {dropin} was not picked up by sshd -T: "
45+
f"{SSHD_CONFIG_D} seems to be ignored"
46+
)

tests/system/test_ssh_fido.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import pytest
2+
3+
from lib.commands import SSHCommandFailed
4+
from lib.common import Defer
5+
from lib.host import Host
6+
7+
# FIDO/security-key (sk-*) support is new in this OpenSSH update.
8+
#
9+
# We have no physical FIDO token to enroll a real sk key end to end, so
10+
# these tests prove the feature is compiled in and wired up as far as
11+
# possible without hardware:
12+
# - the sk-* algorithms are advertised by the client and accepted by sshd
13+
# for pubkey authentication;
14+
# - `ssh-keygen -t ed25519-sk` reaches actual hardware detection and fails
15+
# with "device not found" rather than "unknown key type", proving the
16+
# key type itself is recognized and libfido2 support is wired in.
17+
#
18+
# Requirements:
19+
# - an XCP-ng host (--hosts) >= 8.3, with the OpenSSH 9.9p1
20+
21+
SK_KEY_TYPES = {
22+
"sk-ssh-ed25519@openssh.com",
23+
"sk-ssh-ed25519-cert-v01@openssh.com",
24+
"sk-ecdsa-sha2-nistp256@openssh.com",
25+
"sk-ecdsa-sha2-nistp256-cert-v01@openssh.com",
26+
}
27+
28+
def test_fido_key_types_advertised_by_client(host: Host) -> None:
29+
supported = set(host.ssh("ssh -Q key").splitlines())
30+
missing = SK_KEY_TYPES - supported
31+
assert not missing, f"ssh client doesn't advertise expected FIDO key types: {missing}"
32+
33+
def test_fido_key_types_accepted_by_server(host: Host) -> None:
34+
output = host.ssh("sshd -T | grep '^pubkeyacceptedalgorithms '")
35+
# look for the matching line specifically: some hosts print unrelated
36+
# lines on every ssh session (e.g. a root-login warning) ahead of it
37+
line = next(line for line in output.splitlines() if line.startswith("pubkeyacceptedalgorithms "))
38+
accepted = set(line.split(" ", 1)[1].split(','))
39+
missing = {"sk-ssh-ed25519@openssh.com", "sk-ecdsa-sha2-nistp256@openssh.com"} - accepted
40+
assert not missing, f"sshd doesn't accept expected FIDO key types for pubkey auth: {missing}"
41+
42+
def test_sk_key_enrollment_reaches_hardware_detection(host: Host, defer: Defer) -> None:
43+
""" Without a token, enrollment must fail at the hardware-detection stage, not earlier. """
44+
keyfile = host.ssh("mktemp -u")
45+
defer(lambda: host.ssh(f"rm -f {keyfile} {keyfile}.pub"))
46+
47+
with pytest.raises(SSHCommandFailed) as exc_info:
48+
host.ssh(f"timeout 8 ssh-keygen -t ed25519-sk -f {keyfile} -N '' -O no-touch-required")
49+
50+
error = str(exc_info.value)
51+
assert "device not found" in error, f"expected a hardware-detection failure, got: {error}"
52+
assert "unknown key type" not in error
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import pytest
2+
3+
import hashlib
4+
import os
5+
import subprocess
6+
import tempfile
7+
8+
from lib.commands import sftp
9+
from lib.common import Defer
10+
from lib.host import Host
11+
12+
# OpenSSH 9.0 switched scp's default wire protocol from the legacy SCP
13+
# protocol to SFTP ('-O' restores the old protocol, still shipped for
14+
# compatibility). Both paths, plus the sftp client itself, are exercised
15+
# here end to end (upload then download, content checked with a checksum)
16+
# since SFTP-based transfers are exactly what caused trouble in the past.
17+
#
18+
# Requirements:
19+
# - an XCP-ng host (--hosts) >= 8.2
20+
21+
REMOTE_DIR = "/tmp"
22+
23+
def _sha256(path: str) -> str:
24+
with open(path, 'rb') as f:
25+
return hashlib.sha256(f.read()).hexdigest()
26+
27+
def _make_local_file(size: int = 1_000_000) -> str:
28+
fd, path = tempfile.mkstemp(prefix="xcpng-tests-ssh-transfer-")
29+
with os.fdopen(fd, 'wb') as f:
30+
f.write(os.urandom(size))
31+
return path
32+
33+
def _scp(src: str, dest: str, *, legacy: bool) -> None:
34+
opts = ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=no',
35+
'-o', 'UserKnownHostsFile=/dev/null', '-o', 'LogLevel=ERROR']
36+
if legacy:
37+
opts.append('-O')
38+
subprocess.run(['scp'] + opts + [src, dest], check=True,
39+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
40+
41+
@pytest.mark.parametrize("legacy", [False, True], ids=["sftp-protocol", "legacy-protocol"])
42+
def test_scp_roundtrip(host: Host, defer: Defer, legacy: bool) -> None:
43+
local_src = _make_local_file()
44+
defer(lambda: os.remove(local_src))
45+
remote_path = f"{REMOTE_DIR}/{os.path.basename(local_src)}"
46+
defer(lambda: host.ssh(f"rm -f {remote_path}"))
47+
48+
_scp(local_src, f"root@{host.hostname_or_ip}:{remote_path}", legacy=legacy)
49+
remote_sha256 = host.ssh(f"sha256sum {remote_path}").split()[0]
50+
assert remote_sha256 == _sha256(local_src), "uploaded file content differs from the original"
51+
52+
local_dst = local_src + ".download"
53+
defer(lambda: os.remove(local_dst))
54+
_scp(f"root@{host.hostname_or_ip}:{remote_path}", local_dst, legacy=legacy)
55+
assert _sha256(local_dst) == _sha256(local_src), "downloaded file content differs from the original"
56+
57+
def test_sftp_batch_roundtrip(host: Host, defer: Defer) -> None:
58+
local_src = _make_local_file()
59+
defer(lambda: os.remove(local_src))
60+
remote_path = f"{REMOTE_DIR}/{os.path.basename(local_src)}"
61+
defer(lambda: host.ssh(f"rm -f {remote_path}"))
62+
63+
put_res = sftp(host.hostname_or_ip, [f"put {local_src} {remote_path}", "bye"])
64+
assert put_res.returncode == 0, put_res.stdout.decode()
65+
remote_sha256 = host.ssh(f"sha256sum {remote_path}").split()[0]
66+
assert remote_sha256 == _sha256(local_src), "uploaded file content differs from the original"
67+
68+
local_dst = local_src + ".download"
69+
defer(lambda: os.remove(local_dst))
70+
get_res = sftp(host.hostname_or_ip, [f"get {remote_path} {local_dst}", "bye"])
71+
assert get_res.returncode == 0, get_res.stdout.decode()
72+
assert _sha256(local_dst) == _sha256(local_src), "downloaded file content differs from the original"

0 commit comments

Comments
 (0)