Skip to content

Commit 00c94e8

Browse files
committed
fixes pyca#12132 -- implement ssh public key fingerprints
1 parent 663b5c5 commit 00c94e8

File tree

5 files changed

+169
-0
lines changed

5 files changed

+169
-0
lines changed

CHANGELOG.rst

+2
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ Changelog
5757
This extension defines the period during which the private key corresponding
5858
to the certificate's public key may be used.
5959
* Added support for compiling against `aws-lc`_.
60+
* Added :func:`~cryptography.hazmat.primitives.serialization.ssh_key_fingerprint`
61+
for computing fingerprints of SSH public keys.
6062

6163
.. _v44-0-2:
6264

docs/hazmat/primitives/asymmetric/serialization.rst

+29
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,35 @@ DSA keys look almost identical but begin with ``ssh-dss`` rather than
426426
:raises cryptography.exceptions.UnsupportedAlgorithm: If the serialized
427427
key is of a type that is not supported.
428428

429+
430+
.. function:: ssh_key_fingerprint(key, hash_algorithm)
431+
432+
.. versionadded:: 45.0.0
433+
434+
Computes the fingerprint of an SSH public key.
435+
436+
:param key: The public key to compute the fingerprint for.
437+
:type key: One of :data:`SSHPublicKeyTypes`
438+
439+
:param hash_algorithm: The hash algorithm to use, either ``MD5()`` or
440+
``SHA256()``.
441+
442+
:return: The key fingerprint.
443+
:rtype: bytes
444+
445+
.. code-block:: pycon
446+
447+
>>> from cryptography.hazmat.primitives.serialization import load_ssh_public_key, ssh_key_fingerprint
448+
>>> key_data = b"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAhVNvf1vigXfagQXKjdKN5zEF12KWVMVdDrU3sVLhgd [email protected]"
449+
>>> public_key = load_ssh_public_key(key_data)
450+
>>> md5_fingerprint = ssh_key_fingerprint(public_key, hashes.MD5())
451+
>>> md5_fingerprint
452+
b'95f6c0e3736fae6ecc98bbf4d8424a15'
453+
>>> sha256_fingerprint = ssh_key_fingerprint(public_key, hashes.SHA256())
454+
>>> sha256_fingerprint
455+
b'Ug8qIZlmOZrNmFvoLSa6aKZ4loez+eCbsSzMvXTUw7c'
456+
457+
429458
OpenSSH Private Key
430459
~~~~~~~~~~~~~~~~~~~
431460

src/cryptography/hazmat/primitives/serialization/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
load_ssh_private_key,
3434
load_ssh_public_identity,
3535
load_ssh_public_key,
36+
ssh_key_fingerprint,
3637
)
3738

3839
__all__ = [
@@ -60,4 +61,5 @@
6061
"load_ssh_private_key",
6162
"load_ssh_public_identity",
6263
"load_ssh_public_key",
64+
"ssh_key_fingerprint",
6365
]

src/cryptography/hazmat/primitives/serialization/ssh.py

+41
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from __future__ import annotations
66

7+
import base64
78
import binascii
89
import enum
910
import os
@@ -1152,6 +1153,46 @@ def _parse_exts_opts(exts_opts: memoryview) -> dict[bytes, bytes]:
11521153
return result
11531154

11541155

1156+
def ssh_key_fingerprint(
1157+
key: SSHPublicKeyTypes,
1158+
hash_algorithm: hashes.MD5 | hashes.SHA256,
1159+
) -> bytes:
1160+
"""
1161+
Computes the fingerprint of an SSH public key as bytes.
1162+
1163+
The fingerprint is computed over the public key's SSH encoding,
1164+
matching how OpenSSH's ssh-keygen computes fingerprints.
1165+
1166+
:param key: The public key to compute the fingerprint for.
1167+
:param hash_algorithm: The hash algorithm to use, either an instance of
1168+
MD5 or SHA256.
1169+
1170+
:return: The fingerprint as bytes. For MD5, hexadecimal encoded.
1171+
For SHA256, base64 encoded (without padding).
1172+
"""
1173+
key_type = _get_ssh_key_type(key)
1174+
kformat = _lookup_kformat(key_type)
1175+
1176+
f_pub = _FragList()
1177+
f_pub.put_sshstr(key_type)
1178+
kformat.encode_public(key, f_pub)
1179+
1180+
ssh_binary_data = f_pub.tobytes()
1181+
1182+
# Hash the binary data
1183+
hash_obj = hashes.Hash(hash_algorithm)
1184+
hash_obj.update(ssh_binary_data)
1185+
digest = hash_obj.finalize()
1186+
1187+
# Format the output based on the hash algorithm
1188+
if isinstance(hash_algorithm, hashes.MD5):
1189+
return binascii.hexlify(digest)
1190+
elif isinstance(hash_algorithm, hashes.SHA256):
1191+
return base64.b64encode(digest).rstrip(b"=")
1192+
else:
1193+
raise TypeError("hash_algorithm must be either MD5 or SHA256")
1194+
1195+
11551196
def load_ssh_public_key(
11561197
data: utils.Buffer, backend: typing.Any = None
11571198
) -> SSHPublicKeyTypes:

tests/hazmat/primitives/test_ssh.py

+95
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
InvalidTag,
1616
UnsupportedAlgorithm,
1717
)
18+
from cryptography.hazmat.primitives import hashes
1819
from cryptography.hazmat.primitives.asymmetric import (
1920
dsa,
2021
ec,
@@ -36,6 +37,7 @@
3637
load_ssh_public_identity,
3738
load_ssh_public_key,
3839
ssh,
40+
ssh_key_fingerprint,
3941
)
4042

4143
from ...doubles import DummyKeySerializationEncryption
@@ -1868,3 +1870,96 @@ def test_load_application(self):
18681870
def test_load_application_valueerror(self):
18691871
with pytest.raises(ValueError):
18701872
ssh.load_application(self.ssh_str("hss:test"))
1873+
1874+
1875+
class TestSSHKeyFingerprint:
1876+
@pytest.mark.supported(
1877+
only_if=lambda backend: backend.hash_supported(hashes.MD5()),
1878+
skip_message="Does not support MD5",
1879+
)
1880+
def test_ssh_key_fingerprint_rsa_md5(self):
1881+
ssh_key = load_vectors_from_file(
1882+
os.path.join("asymmetric", "OpenSSH", "rsa-nopsw.key.pub"),
1883+
lambda f: f.read(),
1884+
mode="rb",
1885+
)
1886+
public_key = load_ssh_public_key(ssh_key)
1887+
fingerprint = ssh_key_fingerprint(public_key, hashes.MD5())
1888+
assert fingerprint == b"1047c26573d65149480b811f36047b52"
1889+
1890+
def test_ssh_key_fingerprint_rsa_sha256(self):
1891+
ssh_key = load_vectors_from_file(
1892+
os.path.join("asymmetric", "OpenSSH", "rsa-nopsw.key.pub"),
1893+
lambda f: f.read(),
1894+
mode="rb",
1895+
)
1896+
public_key = load_ssh_public_key(ssh_key)
1897+
fingerprint = ssh_key_fingerprint(public_key, hashes.SHA256())
1898+
assert fingerprint == b"gMB1ylYk/OsEsYNdmh6hjRfEZKIzvmuk6SCSaonm6CU"
1899+
1900+
@pytest.mark.supported(
1901+
only_if=lambda backend: backend.hash_supported(hashes.MD5())
1902+
and backend.ed25519_supported(),
1903+
skip_message="Does not support MD5 or Ed25519",
1904+
)
1905+
def test_ssh_key_fingerprint_ed25519_md5(self):
1906+
ssh_key = load_vectors_from_file(
1907+
os.path.join("asymmetric", "OpenSSH", "ed25519-nopsw.key.pub"),
1908+
lambda f: f.read(),
1909+
mode="rb",
1910+
)
1911+
public_key = load_ssh_public_key(ssh_key)
1912+
fingerprint = ssh_key_fingerprint(public_key, hashes.MD5())
1913+
assert fingerprint == b"e5523d019ea0c1e98c3f4c7cc5945785"
1914+
1915+
@pytest.mark.supported(
1916+
only_if=lambda backend: backend.ed25519_supported(),
1917+
skip_message="Ed25519 not supported",
1918+
)
1919+
def test_ssh_key_fingerprint_ed25519_sha256(self):
1920+
ssh_key = load_vectors_from_file(
1921+
os.path.join("asymmetric", "OpenSSH", "ed25519-nopsw.key.pub"),
1922+
lambda f: f.read(),
1923+
mode="rb",
1924+
)
1925+
public_key = load_ssh_public_key(ssh_key)
1926+
fingerprint = ssh_key_fingerprint(public_key, hashes.SHA256())
1927+
assert fingerprint == b"knottK/0LBWlxvM2cDgzzCJdQ0ppFlY/hzlHWlZTOLk"
1928+
1929+
@pytest.mark.supported(
1930+
only_if=lambda backend: backend.hash_supported(hashes.MD5()),
1931+
skip_message="Does not support MD5",
1932+
)
1933+
def test_ssh_key_fingerprint_ecdsa_md5(self):
1934+
ssh_key = load_vectors_from_file(
1935+
os.path.join("asymmetric", "OpenSSH", "ecdsa-nopsw.key.pub"),
1936+
lambda f: f.read(),
1937+
mode="rb",
1938+
)
1939+
public_key = load_ssh_public_key(ssh_key)
1940+
fingerprint = ssh_key_fingerprint(public_key, hashes.MD5())
1941+
assert fingerprint == b"0d65f22dfa47718c5e16b0352b061b37"
1942+
1943+
def test_ssh_key_fingerprint_ecdsa_sha256(self):
1944+
ssh_key = load_vectors_from_file(
1945+
os.path.join("asymmetric", "OpenSSH", "ecdsa-nopsw.key.pub"),
1946+
lambda f: f.read(),
1947+
mode="rb",
1948+
)
1949+
public_key = load_ssh_public_key(ssh_key)
1950+
fingerprint = ssh_key_fingerprint(public_key, hashes.SHA256())
1951+
assert fingerprint == b"W6Wr6d8N5R5y1rzZl8L03NTgrxc8adxeET7GkXdJSvU"
1952+
1953+
def test_ssh_key_fingerprint_unsupported_hash(self):
1954+
ssh_key = load_vectors_from_file(
1955+
os.path.join("asymmetric", "OpenSSH", "rsa-nopsw.key.pub"),
1956+
lambda f: f.read(),
1957+
mode="rb",
1958+
)
1959+
public_key = load_ssh_public_key(ssh_key)
1960+
with pytest.raises(TypeError):
1961+
ssh_key_fingerprint(public_key, hashes.SHA1()) # type: ignore[arg-type]
1962+
1963+
def test_ssh_key_fingerprint_unsupported_key(self):
1964+
with pytest.raises(ValueError):
1965+
ssh_key_fingerprint(object(), hashes.SHA256()) # type: ignore[arg-type]

0 commit comments

Comments
 (0)