Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions dissect/target/plugins/apps/ssh/openssh.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import base64
import re
from hashlib import md5, sha1, sha256
from itertools import product
from pathlib import Path
from typing import Iterator
Expand Down Expand Up @@ -143,12 +145,14 @@ def public_keys(self) -> Iterator[PublicKeyRecord]:
continue

key_type, public_key, comment = parse_ssh_public_key_file(file_path)
fingerprints = calculate_fingerprints(base64.b64decode(public_key))

yield PublicKeyRecord(
mtime_ts=file_path.stat().st_mtime,
key_type=key_type,
public_key=public_key,
comment=comment,
fingerprint=fingerprints,
path=file_path,
_target=self.target,
_user=user,
Expand Down Expand Up @@ -190,3 +194,36 @@ def parse_known_host(known_host_string: str) -> tuple[str, list, str, str, str]:
comment = " ".join(parts[3:]) if len(parts) > 3 else ""

return marker, hostnames.split(","), keytype, public_key, comment


def calculate_fingerprints(public_key_decoded: bytes, ssh_keygen_format: bool = False) -> tuple[str, str, str]:
"""Calculate the MD5, SHA1 and SHA256 digest of the given decoded public key.

Adheres as much as possible to the output provided by ssh-keygen when ``ssh_keygen_format``
parameter is set to ``True``. When set to ``False`` (default) hexdigests are calculated
instead for ``sha1``and ``sha256``.

Resources:
- https://en.wikipedia.org/wiki/Public_key_fingerprint
- https://man7.org/linux/man-pages/man1/ssh-keygen.1.html
- ``ssh-keygen -l -E <alg> -f key.pub``
"""
if not public_key_decoded:
raise ValueError("No decoded public key provided")

if not isinstance(public_key_decoded, bytes):
raise ValueError("Provided public key should be bytes")

if public_key_decoded[0:3] != b"\x00\x00\x00":
raise ValueError("Provided value does not look like a public key")

fingerprint_md5 = md5(public_key_decoded).hexdigest()

if ssh_keygen_format:
fingerprint_sha1 = base64.b64encode(sha1(public_key_decoded).digest()).rstrip(b"=").decode("utf-8")
fingerprint_sha256 = base64.b64encode(sha256(public_key_decoded).digest()).rstrip(b"=").decode("utf-8")
else:
fingerprint_sha1 = sha1(public_key_decoded).hexdigest()
fingerprint_sha256 = sha256(public_key_decoded).hexdigest()

return fingerprint_md5, fingerprint_sha1, fingerprint_sha256
41 changes: 29 additions & 12 deletions dissect/target/plugins/apps/ssh/putty.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from base64 import b64decode
from datetime import datetime
from pathlib import Path
from typing import Iterator, Optional, Union
Expand All @@ -12,6 +13,7 @@
from dissect.target.helpers.record import create_extended_descriptor
from dissect.target.helpers.regutil import RegistryKey
from dissect.target.plugin import export
from dissect.target.plugins.apps.ssh.openssh import calculate_fingerprints
from dissect.target.plugins.apps.ssh.ssh import KnownHostRecord, SSHPlugin
from dissect.target.plugins.general.users import UserDetails

Expand Down Expand Up @@ -96,12 +98,15 @@ def _regf_known_hosts(self, putty_key: RegistryKey, user_details: UserDetails) -
key_type, host = entry.name.split("@")
port, host = host.split(":")

public_key, fingerprints = construct_public_key(key_type, entry.value)

yield KnownHostRecord(
mtime_ts=ssh_host_keys.ts,
host=host,
port=port,
key_type=key_type,
public_key=construct_public_key(key_type, entry.value),
public_key=public_key,
fingerprint=fingerprints,
comment="",
marker=None,
path=windows_path(ssh_host_keys.path),
Expand All @@ -121,12 +126,15 @@ def _path_known_hosts(self, putty_path: TargetPath, user_details: UserDetails) -
key_type, host = parts[0].split("@")
port, host = host.split(":")

public_key, fingerprints = construct_public_key(key_type, parts[1])

yield KnownHostRecord(
mtime_ts=ts,
host=host,
port=port,
key_type=key_type,
public_key=construct_public_key(key_type, parts[1]),
public_key=public_key,
fingerprint=fingerprints,
comment="",
marker=None,
path=posix_path(ssh_host_keys_path),
Expand Down Expand Up @@ -197,18 +205,16 @@ def parse_host_user(host: str, user: str) -> tuple[str, str]:
return host, user


def construct_public_key(key_type: str, iv: str) -> str:
"""Returns OpenSSH format public key calculated from PuTTY SshHostKeys format.
def construct_public_key(key_type: str, iv: str) -> tuple[str, tuple[str, str, str]]:
"""Returns OpenSSH format public key calculated from PuTTY SshHostKeys format and set of fingerprints.

PuTTY stores raw public key components instead of OpenSSH-formatted public keys
or fingerprints. With RSA public keys the exponent and modulus are stored.
With ECC keys the x and y prime coordinates are stored together with the curve type.

Currently supports ``ssh-ed25519``, ``ecdsa-sha2-nistp256`` and ``rsa2`` key types.

NOTE:
- Sha256 fingerprints of the reconstructed public keys are currently not generated.
- More key types could be supported in the future.
NOTE: More key types could be supported in the future.

Resources:
- https://github.com/github/putty/blob/master/contrib/kh2reg.py
Expand All @@ -217,20 +223,31 @@ def construct_public_key(key_type: str, iv: str) -> str:
- https://github.com/mkorthof/reg2kh
"""

if not isinstance(key_type, str) or not isinstance(iv, str):
raise ValueError("Invalid key_type or iv")

key = None

if key_type == "ssh-ed25519":
x, y = iv.split(",")
key = ECC.construct(curve="ed25519", point_x=int(x, 16), point_y=int(y, 16))
return key.public_key().export_key(format="OpenSSH").split()[-1]

if key_type == "ecdsa-sha2-nistp256":
_, x, y = iv.split(",")
key = ECC.construct(curve="NIST P-256", point_x=int(x, 16), point_y=int(y, 16))
return key.public_key().export_key(format="OpenSSH").split()[-1]

if key_type == "rsa2":
exponent, modulus = iv.split(",")
key = RSA.construct((int(modulus, 16), int(exponent, 16)))
return key.public_key().export_key(format="OpenSSH").decode("utf-8").split()[-1]

log.warning("Could not reconstruct public key: type %s not implemented.", key_type)
return iv
if not key:
log.warning("Could not reconstruct public key: type %s not implemented.", key_type)
return iv, (None, None, None)

openssh_public_key = key.public_key().export_key(format="OpenSSH")

if isinstance(openssh_public_key, bytes):
openssh_public_key = openssh_public_key.decode("utf-8")

fingerprints = calculate_fingerprints(b64decode(openssh_public_key.split(" ")[1]))
return openssh_public_key.split()[-1], fingerprints
2 changes: 2 additions & 0 deletions dissect/target/plugins/apps/ssh/ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
("varint", "port"),
("string", "public_key"),
("string", "marker"),
("digest", "fingerprint"),
],
)

Expand All @@ -50,6 +51,7 @@
("datetime", "mtime_ts"),
*COMMON_ELLEMENTS,
("string", "public_key"),
("digest", "fingerprint"),
],
)

Expand Down
28 changes: 27 additions & 1 deletion tests/plugins/apps/ssh/test_openssh.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import base64
import textwrap
from enum import Enum, auto
from io import BytesIO
Expand All @@ -8,7 +9,10 @@

from dissect.target import Target
from dissect.target.filesystem import VirtualFilesystem
from dissect.target.plugins.apps.ssh.openssh import OpenSSHPlugin
from dissect.target.plugins.apps.ssh.openssh import (
OpenSSHPlugin,
calculate_fingerprints,
)


@pytest.fixture(
Expand Down Expand Up @@ -393,13 +397,35 @@ def test_public_keys_plugin(target_and_filesystem: tuple[Target, VirtualFilesyst
assert user_public_key.key_type == user_public_key_data.split(" ", 2)[0]
assert user_public_key.public_key == user_public_key_data.split(" ", 2)[1]
assert user_public_key.comment == user_public_key_data.split(" ", 2)[2]
assert user_public_key.fingerprint.md5 == "1f3d475966231eeb5455c8485dd030e4"
assert user_public_key.fingerprint.sha1 == "e39242ca1d74bea99285b212e908e18cc67e4dec"
assert user_public_key.fingerprint.sha256 == "7b77007b0b51a86ced6b5fe25639092484c4c39cf76b283ef65fdf49a00f44d2"
assert str(user_public_key.path).replace("\\", "/") == target_system.filesystem_path(
".ssh/id_ed25519.pub", TargetDir.HOME
).replace("\\", "/")

assert host_public_key.key_type == host_public_key_data.split(" ", 2)[0]
assert host_public_key.public_key == host_public_key_data.split(" ", 2)[1]
assert host_public_key.comment == host_public_key_data.split(" ", 2)[2]
assert host_public_key.fingerprint.md5 == "a3f2ebfa8d16efd321015e1618fd281b"
assert host_public_key.fingerprint.sha1 == "f6656cc642fb08f53a1df77d0acff9852a649989"
assert host_public_key.fingerprint.sha256 == "8c7023d563c763fcf5104332d7cf51c978c5ba1dd9f5cbd341edd32dfcbef3ef"
assert str(host_public_key.path).replace("\\", "/") == target_system.filesystem_path(
"ssh_host_rsa_key.pub", TargetDir.SSHD
).replace(target_system.label, "\\sysvol\\").replace("\\", "/")


def test_calculate_fingerprints() -> None:
ed25519_pub = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINz6oq+IweAoQFMzQ0aJLYXJFkLn3tXMbVZ550wvUKOw long comment here"

assert calculate_fingerprints(base64.b64decode(ed25519_pub.split(" ")[1])) == (
"1f3d475966231eeb5455c8485dd030e4",
"e39242ca1d74bea99285b212e908e18cc67e4dec",
"7b77007b0b51a86ced6b5fe25639092484c4c39cf76b283ef65fdf49a00f44d2",
)

assert calculate_fingerprints(base64.b64decode(ed25519_pub.split(" ")[1]), ssh_keygen_format=True) == (
"1f3d475966231eeb5455c8485dd030e4",
"45JCyh10vqmShbIS6QjhjMZ+Tew",
"e3cAewtRqGzta1/iVjkJJITEw5z3ayg+9l/fSaAPRNI",
)
28 changes: 27 additions & 1 deletion tests/plugins/apps/ssh/test_putty.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from datetime import datetime, timezone
from os import stat

import pytest
from flow.record.fieldtypes import path

from dissect.target import Target
from dissect.target.filesystem import VirtualFilesystem
from dissect.target.helpers.regutil import VirtualHive, VirtualKey, VirtualValue
from dissect.target.plugins.apps.ssh.putty import PuTTYPlugin
from dissect.target.plugins.apps.ssh.putty import PuTTYPlugin, construct_public_key
from tests._utils import absolute_path


Expand All @@ -22,30 +23,45 @@ def test_putty_plugin_ssh_host_keys_unix(target_unix_users: Target, fs_unix: Vir
assert records[0].mtime_ts is not None
assert records[0].host == "192.168.123.130"
assert records[0].port == 22

assert records[0].key_type == "ssh-ed25519"
assert records[0].public_key == "AAAAC3NzaC1lZDI1NTE5AAAAIHUl23i/4p/7xcZnNPDK+Dr+A539zpEEXutrm/tESFYq"
assert records[0].fingerprint.md5 == "16da68cfc0e1b7954c84147a385be2b6"
assert records[0].fingerprint.sha1 == "11e647366ef9b74feb55da7f8507a7180123eed0"
assert records[0].fingerprint.sha256 == "0ecaca9db7d166fd55f84ad775074bd6c743ae2d26e6dac10e60e88efbfcc01d"

assert records[0].path == path.from_posix("/root/.putty/sshhostkeys")
assert records[0].username == "root"

assert records[1].mtime_ts is not None
assert records[1].host == "example.com"
assert records[1].port == 22

assert records[1].key_type == "ecdsa-sha2-nistp256"
assert (
records[1].public_key
== "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNhJOmUcnvcVIizc67M3bS0/2Gz43YPHYCRaogqAgHvgVUartyfsl5nZBoFR3FiUc9kTJJUPybWbqXaGLEthjfM=" # noqa E501
)
assert records[1].fingerprint.md5 == "e23736ef80f12b672cf217c390a1acfb"
assert records[1].fingerprint.sha1 == "93a03a809a02091aab194b04c836c37298c0602a"
assert records[1].fingerprint.sha256 == "53d7097c310ce329d554dfb533c81f81d955facae8f46e9d7cfa4db58304c674"

assert records[1].path == path.from_posix("/root/.putty/sshhostkeys")
assert records[1].username == "root"

assert records[2].mtime_ts is not None
assert records[2].host == "1.2.3.4"
assert records[2].port == 1234

assert records[2].key_type == "rsa2"
assert (
records[2].public_key
== "AAAAB3NzaC1yc2EAAAADAQABAAABgQCmYvCKueiA3lyhZuW0OAObOZvf0g957H0wGhGi3BruZSDpa9UQhsVNtgPZfuyx/yYWUIhQ4VSz+qw00IhZJt9myRNrXCxIABM/qubTghrdjRU+ydb0J9uTBIRz+ys/0dr0dg2Gc7C5w9+E3EpRit6VCZfTi8mNYZi+/GC12VYIDsqY9/4D4xloHJP+fs1mCNnY12VtJuIWw281fKaTCxm3H95NOBbAE6sAwr8H5lMLU34D4DcA5ZIG5F48yDmYUkILtN4qLAZMl4ZlQ6KpcxMYCN6DPj2NwR8JHzr+mkwjmGqbs8tjYD/KRYL2sYhK6Fdx7wvw5djQeVTEnhjxoeiCYNFAGkzB9BfeP4N0K2rp6F3NktFU4WIc6nLsX5G1LEWuAvAxwg+v6y4YzmpXHP+WrRHKyhS+B64aLpmD7AAJzPwSyqtt5+8SY3z7EMCpYBGz8uhdYzaY0U+qWFHtIfI5GLrl2akYBzH9t/YPAF6wdTuSh4jxVe2IVrd1x7g23Zs=" # noqa E501
)
assert records[2].fingerprint.md5 == "539539a9dec6afa0c8d9cdbb9357ed5e"
assert records[2].fingerprint.sha1 == "c0f0c0a51ed7ae4c2b271f0426554f837f9508d0"
assert records[2].fingerprint.sha256 == "c2d4bd813cdf1eec38ea8c7bbf0c56b129f9db3e78627d5b7ebfae8db60c5eb6"

assert records[2].path == path.from_posix("/root/.putty/sshhostkeys")
assert records[2].username == "root"

Expand Down Expand Up @@ -129,3 +145,13 @@ def test_putty_plugin_saved_sessions_windows(
assert records[0].port_forward == ""
assert records[0].manual_ssh_host_keys == ""
assert records[0].path == path.from_windows("Software\\SimonTatham\\PuTTY\\Sessions\\example-saved-session")


def test_construct_public_key() -> None:
with pytest.raises(ValueError):
construct_public_key(None, None)

assert construct_public_key("unsupported", "some-iv") == (
"some-iv",
(None, None, None),
)