Skip to content

Commit

Permalink
Support loading Ed448 public keys in OpenSSH format
Browse files Browse the repository at this point in the history
The 'ssh-ed448' key type is documented along with 'ssh-ed25519' in [1], but
has never been supported by any as-yet-released version of OpenSSH.

However, LANcom router devices (which appear to be primarily used in
Germany, see [2] for examples on the public Internet) appear to support
these keys, so this library can and should support loading them.

See also jtesta/ssh-audit#277 in which I extended
`ssh-audit` to allow it to scan and discover host keys of type 'ssh-ed488'.

[1] https://datatracker.ietf.org/doc/html/rfc8709#name-public-key-format
[2] https://www.shodan.io/search?query=ssh+%22ed448%22
  • Loading branch information
dlenskiSB committed Jul 16, 2024
1 parent a4eb4ef commit 26cb3a1
Show file tree
Hide file tree
Showing 4 changed files with 101 additions and 5 deletions.
60 changes: 60 additions & 0 deletions src/cryptography/hazmat/primitives/serialization/ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from cryptography.hazmat.primitives.asymmetric import (
dsa,
ec,
ed448,
ed25519,
padding,
rsa,
Expand Down Expand Up @@ -57,6 +58,7 @@ def _bcrypt_kdf(


_SSH_ED25519 = b"ssh-ed25519"
_SSH_ED448 = b"ssh-ed448"
_SSH_RSA = b"ssh-rsa"
_SSH_DSA = b"ssh-dss"
_ECDSA_NISTP256 = b"ecdsa-sha2-nistp256"
Expand Down Expand Up @@ -152,6 +154,10 @@ def _get_ssh_key_type(key: SSHPrivateKeyTypes | SSHPublicKeyTypes) -> bytes:
key, (ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey)
):
key_type = _SSH_ED25519
elif isinstance(
key, (ed448.Ed448PublicKey,) # private keys are not yet supported
):
key_type = _SSH_ED448
else:
raise ValueError("Unsupported key type")

Expand Down Expand Up @@ -582,6 +588,59 @@ def encode_private(
f_priv.put_sshstr(f_keypair)


class _SSHFormatEd448:
"""Format for Ed448 keys.
Public:
bytes point
Private:
bytes point
bytes secret_and_point
"""

def get_public(
self, data: memoryview
) -> tuple[tuple[memoryview], memoryview]:
"""Ed448 public fields"""
point, data = _get_sshstr(data)
return (point,), data

def load_public(
self, data: memoryview
) -> tuple[ed448.Ed448PublicKey, memoryview]:
"""Make Ed448 public key from data."""
(point,), data = self.get_public(data)
public_key = ed448.Ed448PublicKey.from_public_bytes(
point.tobytes()
)
return public_key, data

def load_private(
self, data: memoryview, pubfields
) -> tuple[ed448.Ed448PrivateKey, memoryview]:
"""Make Ed448 private key from data."""
raise UnsupportedAlgorithm(
"Loading Ed448 SSH private keys is unsupported"
)

def encode_public(
self, public_key: ed448.Ed448PublicKey, f_pub: _FragList
) -> None:
"""Write Ed448 public key"""
raw_public_key = public_key.public_bytes(
Encoding.Raw, PublicFormat.Raw
)
f_pub.put_sshstr(raw_public_key)

def encode_private(
self, private_key: ed448.Ed448PrivateKey, f_priv: _FragList
) -> None:
"""Write Ed448 private key"""
raise UnsupportedAlgorithm(
"Serializing Ed448 SSH private keys is unsupported"
)


def load_application(data) -> tuple[memoryview, memoryview]:
"""
U2F application strings
Expand Down Expand Up @@ -636,6 +695,7 @@ def load_public(
_SSH_RSA: _SSHFormatRSA(),
_SSH_DSA: _SSHFormatDSA(),
_SSH_ED25519: _SSHFormatEd25519(),
_SSH_ED448: _SSHFormatEd448(),
_ECDSA_NISTP256: _SSHFormatECDSA(b"nistp256", ec.SECP256R1()),
_ECDSA_NISTP384: _SSHFormatECDSA(b"nistp384", ec.SECP384R1()),
_ECDSA_NISTP521: _SSHFormatECDSA(b"nistp521", ec.SECP521R1()),
Expand Down
5 changes: 0 additions & 5 deletions tests/hazmat/primitives/test_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -1406,11 +1406,6 @@ def test_openssl_serialization_unsupported(self, backend):

def test_openssh_serialization_unsupported(self, backend):
key = ed448.Ed448PrivateKey.generate()
with pytest.raises(ValueError):
key.public_key().public_bytes(
Encoding.OpenSSH,
PublicFormat.OpenSSH,
)
with pytest.raises(ValueError):
key.private_bytes(
Encoding.PEM,
Expand Down
40 changes: 40 additions & 0 deletions tests/hazmat/primitives/test_ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
dsa,
ec,
ed25519,
ed448,
rsa,
)
from cryptography.hazmat.primitives.serialization import (
Expand Down Expand Up @@ -55,6 +56,7 @@ class TestOpenSSHSerialization:
("ecdsa-nopsw.key.pub", "ecdsa-nopsw.key-cert.pub"),
("ed25519-psw.key.pub", None),
("ed25519-nopsw.key.pub", "ed25519-nopsw.key-cert.pub"),
("ed448-nopsw.key.pub", None),
("sk-ecdsa-psw.key.pub", None),
("sk-ecdsa-nopsw.key.pub", None),
("sk-ed25519-psw.key.pub", None),
Expand All @@ -64,6 +66,8 @@ class TestOpenSSHSerialization:
def test_load_ssh_public_key(self, key_file, cert_file, backend):
if "ed25519" in key_file and not backend.ed25519_supported():
pytest.skip("Requires OpenSSL with Ed25519 support")
if "ed448" in key_file and not backend.ed448_supported():
pytest.skip("Requires OpenSSL with Ed448 support")

# normal public key
pub_data = load_vectors_from_file(
Expand Down Expand Up @@ -170,6 +174,8 @@ def run_partial_pubkey(self, pubdata, backend):
def test_load_ssh_private_key(self, key_file, backend):
if "ed25519" in key_file and not backend.ed25519_supported():
pytest.skip("Requires OpenSSL with Ed25519 support")
if "ed448" in key_file and not backend.ed448_supported():
pytest.skip("Requires OpenSSL with Ed448 support")
if "-psw" in key_file and not ssh._bcrypt_supported:
pytest.skip("Requires bcrypt module")

Expand Down Expand Up @@ -1130,6 +1136,40 @@ def test_load_ssh_public_key_trailing_data(self, backend):
load_ssh_public_key(ssh_key, backend)


@pytest.mark.supported(
only_if=lambda backend: backend.ed448_supported(),
skip_message="Requires OpenSSL with Ed448 support",
)
class TestEd448SSHSerialization:
def test_load_ssh_public_key(self, backend):
ssh_key = (
b"ssh-ed448 AAAACXNzaC1lZDQ0OAAAADnVY+2PC4Oj9MSsYZORD7xivKK3zy"
b"yHFKYj3eMCMPsAwNVk6fqGHeSIRDN39ld5Jto8S5Y1lemtJHA= user@chir"
b"on.local"
)
key = load_ssh_public_key(ssh_key, backend)
assert isinstance(key, ed448.Ed448PublicKey)
assert key.public_bytes(Encoding.Raw, PublicFormat.Raw) == (
bytes.fromhex(
"d5 63 ed 8f 0b 83 a3 f4 c4 ac 61 93 91 0f bc 62"
"bc a2 b7 cf 2c 87 14 a6 23 dd e3 02 30 fb 00 c0"
"d5 64 e9 fa 86 1d e4 88 44 33 77 f6 57 79 26 da"
"3c 4b 96 35 95 e9 ad 24 70"
)
)

def test_public_bytes_openssh(self, backend):
ssh_key = (
b"ssh-ed448 AAAACXNzaC1lZDQ0OAAAADnVY+2PC4Oj9MSsYZORD7xivKK3zy"
b"yHFKYj3eMCMPsAwNVk6fqGHeSIRDN39ld5Jto8S5Y1lemtJHA="
)
key = load_ssh_public_key(ssh_key, backend)
assert isinstance(key, ed448.Ed448PublicKey)
assert (
key.public_bytes(Encoding.OpenSSH, PublicFormat.OpenSSH) == ssh_key
)


class TestSSHCertificate:
@pytest.mark.supported(
only_if=lambda backend: backend.ed25519_supported(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ssh-ed448 AAAACXNzaC1lZDQ0OAAAADnVY+2PC4Oj9MSsYZORD7xivKK3zyyHFKYj3eMCMPsAwNVk6fqGHeSIRDN39ld5Jto8S5Y1lemtJHA=

0 comments on commit 26cb3a1

Please sign in to comment.