Last active
July 28, 2024 01:57
-
-
Save jborean93/7c8c21142f75a2e412436c39472c4356 to your computer and use it in GitHub Desktop.
Parses an OpenSSH Private Key file
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# /// script | |
# dependencies = [ | |
# "bcrypt", | |
# "cryptography >= 43.0.0", | |
# "pyyaml", | |
# ] | |
# /// | |
"""Unpacks OpenSSH Private Keys. | |
This script can be used to parse OpenSSH private key files. OpenSSH private | |
keys start with the header '-----BEGIN OPENSSH PRIVATE KEY-----'. It will | |
output the key data as a YAML string. | |
If the key is encrypted, specify the password with the '--password' argument. | |
OpenSSH key algorithms supported: | |
ssh-rsa | |
ecdsa-sha2-* | |
ssh-ed25519 | |
OpenSSH encryption ciphers supported: | |
none | |
3des-cbc | |
aes128-cbc | |
aes128-ctr | |
[email protected] | |
aes192-cbc | |
aes192-ctr | |
aes256-cbc | |
aes256-ctr | |
[email protected] | |
[email protected] | |
This bash script can be used to generate some test keys: | |
ciphers="$( ssh -Q ciphers )" | |
for key_type in "rsa" "ecdsa" "ed25519"; do | |
filename="${key_type}" | |
ssh-keygen -C "${filename}" -f "${filename}" -N "" -m RFC4716 -t "${key_type}" | |
while IFS= read -r cipher; do | |
ssh-keygen -C "${filename}-${cipher}" -f "${filename}-${cipher}" -N password -m RFC4716 -t "${key_type}" -Z "${cipher}" | |
done <<< "${ciphers}" | |
done | |
ssh-keygen -f "rsa-PEM" -N "" -m PEM -t rsa | |
for cipher in "aes128" "aes256" "des3"; do | |
openssl genrsa -out "rsa-PEM-${cipher}" -passout pass:password -"${cipher}" 2048 | |
ssh-keygen -f "rsa-PEM-${cipher}" -y -P password > "rsa-PEM-${cipher}.pub" | |
done | |
openssl ecparam -name prime256v1 -genkey -noout -out ecdsa-PEM | |
ssh-keygen -f ecdsa-PEM -y > ecdsa-PEM.pub | |
for cipher in "aes128" "aes256" "des3"; do | |
openssl ec -in ecdsa-PEM -out "ecdsa-PEM-${cipher}" -"${cipher}" -passout pass:password | |
ssh-keygen -f "ecdsa-PEM-${cipher}" -y -P password > "ecdsa-PEM-${cipher}.pub" | |
done | |
""" | |
from __future__ import annotations | |
import argparse | |
import base64 | |
import collections.abc | |
import dataclasses | |
import pathlib | |
import sys | |
import typing as t | |
import bcrypt | |
import yaml | |
from cryptography.hazmat.decrepit.ciphers import algorithms as decrepit_algorithms | |
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes | |
HAS_ARGCOMPLETE = True | |
try: | |
import argcomplete | |
except ImportError: | |
HAS_ARGCOMPLETE = False | |
@dataclasses.dataclass(frozen=True) | |
class RFC4253PubKey: | |
identifier: str | |
@classmethod | |
def from_bytes( | |
cls, | |
data: bytes, | |
) -> RFC4253PubKey: | |
view = memoryview(data) | |
b_identifier, view = _read_string(view) | |
identifier = b_identifier.decode() | |
if identifier == "ssh-rsa": | |
e, view = _read_mpint(view) | |
n, view = _read_mpint(view) | |
if view: | |
raise ValueError(f"RSA Public Key had {len(view)} bytes left over") | |
return SSHRsaPubKey( | |
identifier=identifier, | |
e=e, | |
n=n, | |
) | |
elif identifier.startswith("ecdsa-sha2-"): | |
ecdsa_id, view = _read_string(view) | |
q, view = _read_string(view) | |
if view: | |
raise ValueError(f"ECDSA Public Key had {len(view)} bytes left over") | |
return SSHEcdsaSha2PubKey( | |
identifier=identifier, | |
ec_identifier=ecdsa_id.decode(), | |
q=q, | |
) | |
elif identifier.startswith("ssh-ed25519"): | |
b, view = _read_string(view) | |
if view: | |
raise ValueError(f"Ed25519 Public Key had {len(view)} bytes left over") | |
return SSHEd25519PubKey( | |
identifier=identifier, | |
b=b, | |
) | |
else: | |
raise ValueError(f"Unsupported public key {identifier}") | |
@dataclasses.dataclass(frozen=True) | |
class SSHRsaPubKey(RFC4253PubKey): | |
e: int | |
n: int | |
@dataclasses.dataclass(frozen=True) | |
class SSHEcdsaSha2PubKey(RFC4253PubKey): | |
ec_identifier: str | |
q: bytes | |
@dataclasses.dataclass(frozen=True) | |
class SSHEd25519PubKey(RFC4253PubKey): | |
b: bytes | |
@dataclasses.dataclass(frozen=True) | |
class BCryptKDFOptions: | |
salt: bytes | |
rounds: int | |
@classmethod | |
def from_bytes( | |
cls, | |
data: bytes, | |
) -> BCryptKDFOptions: | |
view = memoryview(data) | |
salt, view = _read_string(view) | |
rounds, view = _read_uint32(view) | |
if view: | |
raise ValueError(f"BCrypt KDF Options had {len(view[4:])} bytes left over") | |
return BCryptKDFOptions( | |
salt=salt, | |
rounds=rounds, | |
) | |
@dataclasses.dataclass(frozen=True) | |
class SSHAgentKey: | |
key_type: str | |
comment: str | |
@dataclasses.dataclass(frozen=True) | |
class SSHRsaKey(SSHAgentKey): | |
n: int | |
e: int | |
d: int | |
iqmp: int | |
p: int | |
q: int | |
@dataclasses.dataclass(frozen=True) | |
class SSHEcdsaKey(SSHAgentKey): | |
curve_name: str | |
q: bytes | |
d: int | |
@dataclasses.dataclass(frozen=True) | |
class SSHEddsaKey(SSHAgentKey): | |
pub_key: bytes | |
priv_key: bytes | |
@dataclasses.dataclass(frozen=True) | |
class OpenSSHPrivateKey: | |
# https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key | |
AUTH_MAGIC = "openssh-key-v1" | |
cipher_name: str | |
kdf_name: str | |
kdf_options: BCryptKDFOptions | None | |
pub_keys: list[RFC4253PubKey] | |
enc_priv_keys: bytes | |
mac: bytes | |
def get_private_keys( | |
self, | |
password: str | None = None, | |
) -> list[SSHAgentKey]: | |
if self.cipher_name == "none": | |
data = self.enc_priv_keys | |
elif not password: | |
raise ValueError( | |
"Private keys are encrypted, specify the password to decrypt them." | |
) | |
else: | |
algo_factory: collections.abc.Callable[[bytes], algorithms.CipherAlgorithm] | |
mode_factory: collections.abc.Callable[[bytes], modes.Mode | None] | |
if self.cipher_name == "3des-cbc": | |
key_size = 24 | |
iv_size = 8 | |
algo_factory = decrepit_algorithms.TripleDES | |
mode_factory = modes.CBC | |
elif self.cipher_name in [ | |
"aes128-cbc", | |
"aes128-ctr", | |
"aes192-cbc", | |
"aes192-ctr", | |
"aes256-cbc", | |
"aes256-ctr", | |
"[email protected]", | |
"[email protected]", | |
]: | |
key_size = int(self.cipher_name[3:6]) // 8 | |
iv_size = 12 if self.cipher_name.endswith("[email protected]") else 16 | |
algo_factory = algorithms.AES | |
if self.cipher_name.endswith("cbc"): | |
mode_factory = modes.CBC | |
elif self.cipher_name.endswith("ctr"): | |
mode_factory = modes.CTR | |
else: | |
mode_factory = lambda iv: modes.GCM(iv, tag=self.mac) | |
elif self.cipher_name == "[email protected]": | |
key_size = 64 | |
iv_size = 0 | |
algo_factory = lambda key: algorithms.ChaCha20( | |
key[:32], | |
b"\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", | |
) | |
mode_factory = lambda iv: None | |
else: | |
raise ValueError( | |
f"Decryption has not been implemented for {self.cipher_name}" | |
) | |
if not self.kdf_options: | |
raise ValueError("Key Derivation Function options are missing") | |
bcrypt_key = bcrypt.kdf( | |
password=password.encode(), | |
salt=self.kdf_options.salt, | |
desired_key_bytes=key_size + iv_size, | |
rounds=self.kdf_options.rounds, | |
ignore_few_rounds=True, | |
) | |
key = bcrypt_key[:key_size] | |
iv = bcrypt_key[key_size:] | |
algorithm = algo_factory(key) | |
mode = mode_factory(iv) | |
cipher = Cipher(algorithm, mode=mode) | |
decryptor = cipher.decryptor() | |
data = decryptor.update(self.enc_priv_keys) + decryptor.finalize() | |
# The list of privatekey/comment pairs is padded with the | |
# bytes 1, 2, 3, ... until the total length is a multiple | |
# of the cipher block size. | |
# uint32 checkint | |
# uint32 checkint | |
# byte[] privatekey1 | |
# string comment1 | |
# byte[] privatekey2 | |
# string comment2 | |
# ... | |
# byte[] privatekeyN | |
# string commentN | |
# byte 1 | |
# byte 2 | |
# byte 3 | |
# ... | |
# byte padlen % 255 | |
view = memoryview(data) | |
check_int1, view = _read_uint32(view) | |
check_int2, view = _read_uint32(view) | |
if check_int1 != check_int2: | |
raise ValueError("Failed to decrypt private key data, checkint mismatch") | |
keys = [] | |
for _ in range(len(self.pub_keys)): | |
# https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent | |
b_key_type, view = _read_string(view) | |
key_type = b_key_type.decode() | |
agent_key: SSHAgentKey | |
if key_type == "ssh-rsa": | |
n, view = _read_mpint(view) | |
e, view = _read_mpint(view) | |
d, view = _read_mpint(view) | |
iqmp, view = _read_mpint(view) | |
p, view = _read_mpint(view) | |
q, view = _read_mpint(view) | |
comment, view = _read_string(view) | |
agent_key = SSHRsaKey( | |
key_type=key_type, | |
comment=comment.decode(), | |
n=n, | |
e=e, | |
d=d, | |
iqmp=iqmp, | |
p=p, | |
q=q, | |
) | |
elif key_type.startswith("ecdsa-sha2-"): | |
curve_name, view = _read_string(view) | |
pub_key, view = _read_string(view) | |
ecdsa_key, view = _read_mpint(view) | |
comment, view = _read_string(view) | |
agent_key = SSHEcdsaKey( | |
key_type=key_type, | |
comment=comment.decode(), | |
curve_name=curve_name.decode(), | |
q=pub_key, | |
d=ecdsa_key, | |
) | |
elif key_type == "ssh-ed25519": | |
pub_key, view = _read_string(view) | |
ed25519_key, view = _read_string(view) | |
comment, view = _read_string(view) | |
agent_key = SSHEddsaKey( | |
key_type=key_type, | |
comment=comment.decode(), | |
pub_key=pub_key, | |
priv_key=ed25519_key[: -(len(pub_key))], | |
) | |
else: | |
raise ValueError(f"Unknown private key type '{key_type}'") | |
keys.append(agent_key) | |
return keys | |
@classmethod | |
def from_bytes( | |
cls, | |
data: bytes, | |
) -> OpenSSHPrivateKey: | |
# #define AUTH_MAGIC "openssh-key-v1" | |
# byte[] AUTH_MAGIC | |
# string ciphername | |
# string kdfname | |
# string kdfoptions | |
# uint32 number of keys N | |
# string publickey1 | |
# string publickey2 | |
# ... | |
# string publickeyN | |
# string encrypted, padded list of private keys | |
view = memoryview(data) | |
if not data.startswith(f"{cls.AUTH_MAGIC}\x00".encode()): | |
raise ValueError("Invalid OpenSSH private key format") | |
view = view[15:] | |
ciphername, view = _read_string(view) | |
b_kdf_name, view = _read_string(view) | |
kdf_name = b_kdf_name.decode() | |
b_kdf_opts, view = _read_string(view) | |
kdf_opts = None | |
if kdf_name == "bcrypt": | |
kdf_opts = BCryptKDFOptions.from_bytes(b_kdf_opts) | |
elif kdf_name != "none": | |
raise ValueError(f"Unsupported KDF Name {kdf_name}") | |
num_pub_keys, view = _read_uint32(view) | |
pub_keys = [] | |
for _ in range(num_pub_keys): | |
pkey, view = _read_string(view) | |
pub_keys.append(RFC4253PubKey.from_bytes(pkey)) | |
enc_pkeys, view = _read_string(view) | |
return OpenSSHPrivateKey( | |
cipher_name=ciphername.decode("utf-8"), | |
kdf_name=kdf_name, | |
kdf_options=kdf_opts, | |
pub_keys=pub_keys, | |
enc_priv_keys=enc_pkeys, | |
mac=view.tobytes(), | |
) | |
def parse_args() -> argparse.Namespace: | |
parser = argparse.ArgumentParser( | |
prog=sys.argv[0], | |
description="Parse a key file.", | |
) | |
parser.add_argument( | |
"key_file", | |
type=pathlib.Path, | |
help="The key file to parse.", | |
) | |
parser.add_argument( | |
"--password", | |
type=str, | |
required=False, | |
help="The password used to decrypt an encrypted key.", | |
) | |
if HAS_ARGCOMPLETE: | |
argcomplete.autocomplete(parser) | |
return parser.parse_args(sys.argv[1:]) | |
def _read_mpint(view: memoryview) -> tuple[int, memoryview]: | |
int_len = int.from_bytes(view[:4], byteorder="big", signed=False) | |
view = view[4:] | |
int_val = 0 | |
if int_len: | |
int_val = int.from_bytes(view[:int_len], byteorder="big", signed=True) | |
view = view[int_len:] | |
return int_val, view | |
def _read_uint32(view: memoryview) -> tuple[int, memoryview]: | |
val = int.from_bytes(view[:4], byteorder="big", signed=False) | |
return val, view[4:] | |
def _read_string(view: memoryview) -> tuple[bytes, memoryview]: | |
string_len = int.from_bytes(view[:4], byteorder="big", signed=False) | |
view = view[4:] | |
if string_len: | |
return view[:string_len].tobytes(), view[string_len:] | |
else: | |
return b"", view | |
def main() -> None: | |
args = parse_args() | |
key_file = t.cast(pathlib.Path, args.key_file) | |
if not key_file.exists(): | |
raise ValueError(f"Key file '{key_file}' does not exist.") | |
if not key_file.is_file(): | |
raise ValueError(f"Key file '{key_file}' is not a file.") | |
begin_label = "" | |
end_label = "" | |
reached_end = False | |
key_lines = [] | |
metadata = {} | |
for line in open(key_file, mode="r"): | |
line = line.strip() | |
if reached_end: | |
raise ValueError("Key had extra data after end label") | |
if line.startswith("-----BEGIN "): | |
if begin_label: | |
raise ValueError("Key contains multiple begin segments") | |
begin_label = line | |
end_label = begin_label.replace("BEGIN", "END") | |
elif not begin_label: | |
raise ValueError("Key did not start with begin segment") | |
elif line.startswith("-----END "): | |
if line != end_label: | |
raise ValueError( | |
f"Key did not end with expected end label. Expecting '{end_label}' but got '{line}'" | |
) | |
reached_end = True | |
elif ":" in line: | |
key, value = line.split(":", 1) | |
metadata[key.strip()] = value.strip() | |
else: | |
key_lines.append(line) | |
if not reached_end: | |
raise ValueError(f"Key did not end with expected end label '{end_label}'") | |
key_data = base64.b64decode("".join(key_lines)) | |
if begin_label == "-----BEGIN OPENSSH PRIVATE KEY-----": | |
key = OpenSSHPrivateKey.from_bytes(key_data) | |
key_dict = dataclasses.asdict(key) | |
key_dict = {"auth_magic": key.AUTH_MAGIC} | key_dict | |
dec_key: str | list[dict[str, t.Any]] | |
try: | |
dec_keys = key.get_private_keys(password=args.password) | |
dec_key = [dataclasses.asdict(k) for k in dec_keys] | |
except ValueError as e: | |
dec_key = str(e) | |
key_dict |= {"decrypted_keys": dec_key} | |
yaml.dump(key_dict, sys.stdout, sort_keys=False) | |
else: | |
raise ValueError(f"Unsupported key format '{begin_label}'") | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment