Skip to content

Commit 238fbe6

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 238fbe6

6 files changed

Lines changed: 385 additions & 28 deletions
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import pytest
2+
3+
from contextlib import nullcontext
4+
5+
from lib.commands import SSHCommandFailed, ssh
6+
from lib.host import Host
7+
8+
# Exhaustive coverage of the KEX/cipher/MAC/host-key algorithms OpenSSH
9+
# supports, hardcoded from `ssh -Q kex|cipher|mac|key` on the reference
10+
# build (openssh-9.9p1-27.2.xcpng8.3). The lists are deliberately not
11+
# queried live: the point of test_*_list_matches_hardcoded is to fail the
12+
# day the compiled-in algorithm support changes, forcing a conscious
13+
# update of this file instead of the change going unnoticed.
14+
#
15+
# Requirements:
16+
# - an XCP-ng host (--hosts) >= 8.3, with the OpenSSH 9.9p1
17+
18+
# algorithm -> enabled by default (sshd -T) on the reference build
19+
KEX_ALGORITHMS = {
20+
# hybrid post-quantum, new in this OpenSSH update
21+
"mlkem1024nistp384-sha384": True,
22+
"mlkem768x25519-sha256": True,
23+
"mlkem768nistp256-sha256": True,
24+
"sntrup761x25519-sha512": True,
25+
"sntrup761x25519-sha512@openssh.com": True,
26+
# classical, enabled
27+
"curve25519-sha256": True,
28+
"curve25519-sha256@libssh.org": True,
29+
"ecdh-sha2-nistp521": True,
30+
"ecdh-sha2-nistp384": True,
31+
"ecdh-sha2-nistp256": True,
32+
"diffie-hellman-group16-sha512": True,
33+
"diffie-hellman-group18-sha512": True,
34+
# compiled in, but disabled
35+
"diffie-hellman-group1-sha1": False,
36+
"diffie-hellman-group14-sha1": False,
37+
"diffie-hellman-group14-sha256": False,
38+
"diffie-hellman-group-exchange-sha1": False,
39+
"diffie-hellman-group-exchange-sha256": False,
40+
}
41+
42+
CIPHERS = {
43+
"chacha20-poly1305@openssh.com": True,
44+
"aes256-gcm@openssh.com": True,
45+
"aes128-gcm@openssh.com": True,
46+
"aes256-ctr": True,
47+
"aes128-ctr": True,
48+
"3des-cbc": False,
49+
"aes128-cbc": False,
50+
"aes192-cbc": False,
51+
"aes256-cbc": False,
52+
"aes192-ctr": False,
53+
}
54+
55+
MACS = {
56+
"hmac-sha2-512-etm@openssh.com": True,
57+
"hmac-sha2-256-etm@openssh.com": True,
58+
"umac-128-etm@openssh.com": True,
59+
"hmac-sha2-512": True,
60+
"hmac-sha2-256": True,
61+
"umac-128@openssh.com": True,
62+
"hmac-sha1": False,
63+
"hmac-sha1-96": False,
64+
"hmac-md5": False,
65+
"hmac-md5-96": False,
66+
"umac-64@openssh.com": False,
67+
"hmac-sha1-etm@openssh.com": False,
68+
"hmac-sha1-96-etm@openssh.com": False,
69+
"hmac-md5-etm@openssh.com": False,
70+
"hmac-md5-96-etm@openssh.com": False,
71+
"umac-64-etm@openssh.com": False,
72+
}
73+
74+
# Server host identity algorithms actually exercisable end to end: the host
75+
# only carries one host key per type (ed25519, ecdsa on nistp256, rsa), so
76+
# only algorithms with matching key material are forced here. Certificate
77+
# variants would need a CA, and the sk-* types from `ssh -Q key` are not
78+
# host identity keys at all (they're for user authentication via a
79+
# hardware security key, see test_ssh_fido.py) so they don't belong in a
80+
# HostKeyAlgorithms test.
81+
HOSTKEY_ALGORITHMS = {
82+
"ssh-ed25519": True,
83+
"ecdsa-sha2-nistp256": True,
84+
"rsa-sha2-256": True,
85+
"rsa-sha2-512": True,
86+
"ssh-rsa": False, # raw SHA-1 RSA signature, disabled by crypto-policy
87+
}
88+
89+
# Full compiled-in key type support (`ssh -Q key`), used only to detect if
90+
# that list ever drifts (e.g. FIDO/security-key support silently regressing,
91+
# as it did between the previous and this OpenSSH build).
92+
ALL_COMPILED_KEY_TYPES = {
93+
"ssh-ed25519",
94+
"ssh-ed25519-cert-v01@openssh.com",
95+
"sk-ssh-ed25519@openssh.com",
96+
"sk-ssh-ed25519-cert-v01@openssh.com",
97+
"ecdsa-sha2-nistp256",
98+
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
99+
"ecdsa-sha2-nistp384",
100+
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
101+
"ecdsa-sha2-nistp521",
102+
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
103+
"sk-ecdsa-sha2-nistp256@openssh.com",
104+
"sk-ecdsa-sha2-nistp256-cert-v01@openssh.com",
105+
"ssh-rsa",
106+
"ssh-rsa-cert-v01@openssh.com",
107+
}
108+
109+
def _assert_list_matches(host: Host, query: str, expected: set) -> None:
110+
actual = {line for line in host.ssh(f"ssh -Q {query}").splitlines() if line and ' ' not in line}
111+
assert actual == expected, (
112+
f"`ssh -Q {query}` no longer matches the hardcoded list in this test file "
113+
f"(missing={sorted(expected - actual)}, new={sorted(actual - expected)}); "
114+
"update the hardcoded list after reviewing the change"
115+
)
116+
117+
def test_kex_algorithms_list_matches_hardcoded(host: Host) -> None:
118+
_assert_list_matches(host, "kex", set(KEX_ALGORITHMS))
119+
120+
def test_ciphers_list_matches_hardcoded(host: Host) -> None:
121+
_assert_list_matches(host, "cipher", set(CIPHERS))
122+
123+
def test_macs_list_matches_hardcoded(host: Host) -> None:
124+
_assert_list_matches(host, "mac", set(MACS))
125+
126+
def test_key_types_list_matches_hardcoded(host: Host) -> None:
127+
_assert_list_matches(host, "key", ALL_COMPILED_KEY_TYPES)
128+
129+
def _force_algorithm(host: Host, option: str, algo: str, *, extra_options: list[str] = []) -> None:
130+
# multiplexing must be off: a shared control connection would reuse the
131+
# algorithm negotiated by whichever call created it, silently ignoring
132+
# the -o option on every later call.
133+
ssh(host.hostname_or_ip, 'true', options=['-o', f'{option}={algo}'] + extra_options, multiplexing=False)
134+
135+
# mlkem768nistp256-sha256 and mlkem1024nistp384-sha384 aren't part of
136+
# upstream OpenSSH (see openssh-10.0-mlkem-nist.patch): they're carried by
137+
# RHEL-family builds (RHEL, CentOS, Alma, and this XCP-ng build) for FIPS
138+
# compliance, but not by other builds, including the machine running these
139+
# tests. There's no second RHEL-family host available to interoperate with
140+
# either, so unlike every other algorithm here, these two are exercised in
141+
# loopback on the host itself (which does understand its own names). That
142+
# can't reach an authenticated session (root has no key to log into
143+
# itself), so instead we assert the exact requested algorithm was the one
144+
# negotiated.
145+
SPECIFIC_KEX_NAMES = {"mlkem768nistp256-sha256", "mlkem1024nistp384-sha384"}
146+
147+
def _negotiated_kex_in_loopback(host: Host, algo: str) -> str:
148+
# the trailing "; true" keeps the remote command's exit code at 0
149+
# (the inner loopback ssh fails at authentication, not at key exchange)
150+
# so we can just inspect its output instead of juggling SSHCommandFailed.
151+
output = host.ssh(
152+
"ssh -v -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
153+
f"-o ControlMaster=no -o KexAlgorithms={algo} localhost true 2>&1; true"
154+
)
155+
for line in output.splitlines():
156+
if line.startswith("debug1: kex: algorithm:"):
157+
return line.rsplit(':', 1)[1].strip()
158+
pytest.fail(f"could not find the negotiated KEX algorithm in loopback ssh -v output:\n{output}")
159+
160+
@pytest.mark.parametrize("algo,enabled", KEX_ALGORITHMS.items(), ids=list(KEX_ALGORITHMS))
161+
def test_kex_algorithm(host: Host, algo: str, enabled: bool) -> None:
162+
if algo in SPECIFIC_KEX_NAMES:
163+
assert enabled
164+
assert _negotiated_kex_in_loopback(host, algo) == algo
165+
return
166+
with pytest.raises(SSHCommandFailed) if not enabled else nullcontext():
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+
with pytest.raises(SSHCommandFailed) if not enabled else nullcontext():
172+
_force_algorithm(host, "Ciphers", algo)
173+
174+
@pytest.mark.parametrize("algo,enabled", MACS.items(), ids=list(MACS))
175+
def test_mac_algorithm(host: Host, algo: str, enabled: bool) -> None:
176+
# MACs are only negotiated for non-AEAD ciphers (AEAD ciphers like the
177+
# default chacha20-poly1305/aes-gcm have their MAC built in, making the
178+
# MACs option moot), so a non-AEAD cipher is pinned to force the point.
179+
non_aead_cipher = ['-o', 'Ciphers=aes256-ctr']
180+
with pytest.raises(SSHCommandFailed) if not enabled else nullcontext():
181+
_force_algorithm(host, "MACs", algo, extra_options=non_aead_cipher)
182+
183+
@pytest.mark.parametrize("algo,enabled", HOSTKEY_ALGORITHMS.items(), ids=list(HOSTKEY_ALGORITHMS))
184+
def test_hostkey_algorithm(host: Host, algo: str, enabled: bool) -> None:
185+
with pytest.raises(SSHCommandFailed) if not enabled else nullcontext():
186+
_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: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import pytest
2+
3+
import hashlib
4+
import os
5+
import tempfile
6+
from pathlib import Path
7+
8+
from lib.commands import local_cmd, sftp
9+
from lib.common import Defer
10+
from lib.host import Host
11+
12+
from typing import Literal, TypeAlias, get_args
13+
14+
# OpenSSH 9.0 switched scp's default wire protocol from the legacy SCP
15+
# protocol to SFTP ('-O' restores the old protocol, still shipped for
16+
# compatibility). Both paths, plus the sftp client itself, are exercised
17+
# here end to end (upload then download, content checked with a checksum)
18+
# since SFTP-based transfers are exactly what caused trouble in the past.
19+
#
20+
# Requirements:
21+
# - an XCP-ng host (--hosts) >= 8.2
22+
23+
REMOTE_DIR = "/tmp"
24+
25+
Protocol: TypeAlias = Literal['sftp', 'legacy']
26+
27+
def _sha256(path: Path) -> str:
28+
with open(path, 'rb') as f:
29+
return hashlib.sha256(f.read()).hexdigest()
30+
31+
def _make_random_local_file(defer: Defer, size: int = 1_000_000) -> Path:
32+
with tempfile.NamedTemporaryFile(prefix="xcpng-tests-ssh-transfer-", delete=False) as f:
33+
defer(lambda: os.remove(f.name))
34+
f.write(os.urandom(size))
35+
return Path(f.name)
36+
37+
def _scp(src: str, dest: str, protocol: Protocol) -> None:
38+
opts = ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=no',
39+
'-o', 'UserKnownHostsFile=/dev/null', '-o', 'LogLevel=ERROR']
40+
if protocol == 'legacy':
41+
opts.append('-O')
42+
local_cmd(['scp'] + opts + [src, dest])
43+
44+
@pytest.mark.parametrize("protocol", get_args(Protocol))
45+
def test_scp_roundtrip(host: Host, defer: Defer, protocol: Protocol) -> None:
46+
local_src = _make_random_local_file(defer)
47+
remote_path = f"{REMOTE_DIR}/{local_src.name}"
48+
defer(lambda: host.ssh(f"rm -f {remote_path}"))
49+
50+
_scp(str(local_src), f"root@{host.hostname_or_ip}:{remote_path}", protocol)
51+
remote_sha256 = host.ssh(f"sha256sum {remote_path}").split()[0]
52+
assert remote_sha256 == _sha256(local_src), "uploaded file content differs from the original"
53+
54+
local_dst = local_src.with_name(local_src.name + ".download")
55+
defer(lambda: os.remove(local_dst))
56+
_scp(f"root@{host.hostname_or_ip}:{remote_path}", str(local_dst), protocol)
57+
assert _sha256(local_dst) == _sha256(local_src), "downloaded file content differs from the original"
58+
59+
def test_sftp_batch_roundtrip(host: Host, defer: Defer) -> None:
60+
local_src = _make_random_local_file(defer)
61+
remote_path = f"{REMOTE_DIR}/{local_src.name}"
62+
defer(lambda: host.ssh(f"rm -f {remote_path}"))
63+
64+
put_res = sftp(host.hostname_or_ip, [f"put {local_src} {remote_path}", "bye"])
65+
assert put_res.returncode == 0, put_res.stdout.decode()
66+
remote_sha256 = host.ssh(f"sha256sum {remote_path}").split()[0]
67+
assert remote_sha256 == _sha256(local_src), "uploaded file content differs from the original"
68+
69+
local_dst = local_src.with_name(local_src.name + ".download")
70+
defer(lambda: os.remove(local_dst))
71+
get_res = sftp(host.hostname_or_ip, [f"get {remote_path} {local_dst}", "bye"])
72+
assert get_res.returncode == 0, get_res.stdout.decode()
73+
assert _sha256(local_dst) == _sha256(local_src), "downloaded file content differs from the original"
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import pytest
2+
3+
from lib.commands import ssh_with_result
4+
from lib.host import Host
5+
6+
# Hybrid post-quantum key exchange (ML-KEM, e.g. mlkem768x25519-sha256) is
7+
# new in OpenSSH 9.9p1 (reference build openssh-9.9p1-27.2.xcpng8.3); the
8+
# OpenSSH build shipped before it had no ML-KEM support.
9+
# This test proves it is actually negotiated by default, not just compiled
10+
# in and available on request (see test_ssh_algorithms.py for per-algorithm
11+
# coverage).
12+
#
13+
# Requirements:
14+
# - an XCP-ng host (--hosts) >= 8.3, with openssh >= 9.9p1 installed
15+
16+
def _negotiated_kex_algorithm(host: Host) -> str:
17+
# multiplexing must be off, otherwise a shared control connection could
18+
# be reused and its (already negotiated) algorithm silently returned.
19+
result = ssh_with_result(host.hostname_or_ip, 'true', options=['-v'], multiplexing=False)
20+
for line in result.ssherr.splitlines():
21+
if line.startswith('debug1: kex: algorithm:'):
22+
return line.rsplit(':', 1)[1].strip()
23+
pytest.fail(f"could not find the negotiated KEX algorithm in ssh -v output:\n{result.ssherr}")
24+
25+
def test_pq_kex_preferred_by_default(host: Host) -> None:
26+
""" The default KEX negotiated must be the hybrid post-quantum one. """
27+
algo = _negotiated_kex_algorithm(host)
28+
assert algo.startswith("mlkem"), f"expected a hybrid post-quantum KEX by default, got {algo}"

0 commit comments

Comments
 (0)