diff --git a/src/ecdsa/der.py b/src/ecdsa/der.py index 8b27941c..fa6979d1 100644 --- a/src/ecdsa/der.py +++ b/src/ecdsa/der.py @@ -5,7 +5,7 @@ import warnings from itertools import chain from six import int2byte, b, text_type -from ._compat import str_idx_as_int +from ._compat import compat26_str, str_idx_as_int class UnexpectedDER(Exception): @@ -400,10 +400,10 @@ def unpem(pem): def topem(der, name): - b64 = base64.b64encode(der) + b64 = base64.b64encode(compat26_str(der)) lines = [("-----BEGIN %s-----\n" % name).encode()] lines.extend( - [b64[start : start + 64] + b("\n") for start in range(0, len(b64), 64)] + [b64[start : start + 76] + b("\n") for start in range(0, len(b64), 76)] ) lines.append(("-----END %s-----\n" % name).encode()) return b("").join(lines) diff --git a/src/ecdsa/keys.py b/src/ecdsa/keys.py index 2b7d3168..d77f4fc4 100644 --- a/src/ecdsa/keys.py +++ b/src/ecdsa/keys.py @@ -7,7 +7,7 @@ import os from six import PY2, b from . import ecdsa, eddsa -from . import der +from . import der, ssh from . import rfc6979 from . import ellipticcurve from .curves import NIST192p, Curve, Ed25519, Ed448 @@ -614,6 +614,18 @@ def to_der( der.encode_bitstring(point_str, 0), ) + def to_ssh(self): + """ + Convert the public key to the SSH format. + + :return: SSH encoding of the public key + :rtype: bytes + """ + return ssh.serialize_public( + self.curve.name, + self.to_string(), + ) + def verify( self, signature, @@ -1281,6 +1293,19 @@ def to_der( der.encode_octet_string(ec_private_key), ) + def to_ssh(self): + """ + Convert the private key to the SSH format. + + :return: SSH encoded private key + :rtype: bytes + """ + return ssh.serialize_private( + self.curve.name, + self.verifying_key.to_string(), + self.to_string(), + ) + def get_verifying_key(self): """ Return the VerifyingKey associated with this private key. diff --git a/src/ecdsa/ssh.py b/src/ecdsa/ssh.py new file mode 100644 index 00000000..64e94030 --- /dev/null +++ b/src/ecdsa/ssh.py @@ -0,0 +1,83 @@ +import binascii +from . import der +from ._compat import compat26_str, int_to_bytes + +_SSH_ED25519 = b"ssh-ed25519" +_SK_MAGIC = b"openssh-key-v1\0" +_NONE = b"none" + + +def _get_key_type(name): + if name == "Ed25519": + return _SSH_ED25519 + else: + raise ValueError("Unsupported key type") + + +class _Serializer: + def __init__(self): + self.bytes = b"" + + def put_raw(self, val): + self.bytes += val + + def put_u32(self, val): + self.bytes += int_to_bytes(val, length=4, byteorder="big") + + def put_str(self, val): + self.put_u32(len(val)) + self.bytes += val + + def put_pad(self, blklen=8): + padlen = blklen - (len(self.bytes) % blklen) + self.put_raw(bytearray(range(1, 1 + padlen))) + + def encode(self): + return binascii.b2a_base64(compat26_str(self.bytes)) + + def tobytes(self): + return self.bytes + + def topem(self): + return der.topem(self.bytes, "OPENSSH PRIVATE KEY") + + +def serialize_public(name, pub): + serial = _Serializer() + ktype = _get_key_type(name) + serial.put_str(ktype) + serial.put_str(pub) + return b" ".join([ktype, serial.encode()]) + + +def serialize_private(name, pub, priv): + # encode public part + spub = _Serializer() + ktype = _get_key_type(name) + spub.put_str(ktype) + spub.put_str(pub) + + # encode private part + spriv = _Serializer() + checksum = 0 + spriv.put_u32(checksum) + spriv.put_u32(checksum) + spriv.put_raw(spub.tobytes()) + spriv.put_str(priv + pub) + comment = b"" + spriv.put_str(comment) + spriv.put_pad() + + # top-level structure + main = _Serializer() + main.put_raw(_SK_MAGIC) + ciphername = kdfname = _NONE + main.put_str(ciphername) + main.put_str(kdfname) + nokdf = 0 + main.put_u32(nokdf) + nkeys = 1 + main.put_u32(nkeys) + main.put_str(spub.tobytes()) + main.put_str(spriv.tobytes()) + return main.topem() diff --git a/src/ecdsa/test_keys.py b/src/ecdsa/test_keys.py index f9dbcad1..4d9e976e 100644 --- a/src/ecdsa/test_keys.py +++ b/src/ecdsa/test_keys.py @@ -364,6 +364,18 @@ def test_export_ed255_to_pem(self): self.assertEqual(vk_pem, vk.to_pem()) + def test_export_ed255_to_ssh(self): + vk_str = ( + b"\x23\x00\x50\xd0\xd6\x64\x22\x28\x8e\xe3\x55\x89\x7e\x6e\x41\x57" + b"\x8d\xae\xde\x44\x26\xee\x56\x27\xbc\x85\xe6\x0b\x2f\x2a\xcb\x65" + ) + + vk = VerifyingKey.from_string(vk_str, Ed25519) + + vk_ssh = b"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICMAUNDWZCIojuNViX5uQVeNrt5EJu5WJ7yF5gsvKstl\n" + + self.assertEqual(vk_ssh, vk.to_ssh()) + def test_ed25519_export_import(self): sk = SigningKey.generate(Ed25519) vk = sk.verifying_key @@ -428,8 +440,8 @@ def test_ed448_to_pem(self): vk_pem = ( b"-----BEGIN PUBLIC KEY-----\n" - b"MEMwBQYDK2VxAzoAeQtetSu7CMEzE+XWB10Bg47LCA0giNikOxHzdp+tZ/eK/En0\n" - b"dTdYD2ll94g58MhSnBiBQB9A1MMA\n" + b"MEMwBQYDK2VxAzoAeQtetSu7CMEzE+XWB10Bg47LCA0giNikOxHzdp+tZ/eK/En0dTdYD2ll94g5\n" + b"8MhSnBiBQB9A1MMA\n" b"-----END PUBLIC KEY-----\n" ) @@ -629,6 +641,25 @@ def test_ed25519_to_pem(self): self.assertEqual(sk.to_pem(format="pkcs8"), pem_str) + def test_ed25519_to_ssh(self): + sk = SigningKey.from_string( + b"\x34\xBA\xC7\xD1\x4E\xD4\xF1\xBC\x4F\x8C\x48\x3E\x0F\x19\x77\x4C" + b"\xFC\xB8\xBE\xAC\x54\x66\x45\x11\x9A\xD7\xD7\xB8\x07\x0B\xF5\xD4", + Ed25519, + ) + + ssh_str = ( + b"-----BEGIN OPENSSH PRIVATE KEY-----\n" + b"b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZWQyNTUx\n" + b"OQAAACAjAFDQ1mQiKI7jVYl+bkFXja7eRCbuVie8heYLLyrLZQAAAIgAAAAAAAAAAAAAAAtzc2gt\n" + b"ZWQyNTUxOQAAACAjAFDQ1mQiKI7jVYl+bkFXja7eRCbuVie8heYLLyrLZQAAAEA0usfRTtTxvE+M\n" + b"SD4PGXdM/Li+rFRmRRGa19e4Bwv11CMAUNDWZCIojuNViX5uQVeNrt5EJu5WJ7yF5gsvKstlAAAA\n" + b"AAECAwQF\n" + b"-----END OPENSSH PRIVATE KEY-----\n" + ) + + self.assertEqual(sk.to_ssh(), ssh_str) + def test_ed25519_to_and_from_pem(self): sk = SigningKey.generate(Ed25519) @@ -665,8 +696,8 @@ def test_ed448_to_pem(self): ) pem_str = ( b"-----BEGIN PRIVATE KEY-----\n" - b"MEcCAQAwBQYDK2VxBDsEOTyFuXqFLXgJlV8uDqcOw9nG4IqzLiZ/i5NfBDoHPzmP\n" - b"OP0JMYaLGlTzwovmvCDJ2zLaezu9NLz9aQ==\n" + b"MEcCAQAwBQYDK2VxBDsEOTyFuXqFLXgJlV8uDqcOw9nG4IqzLiZ/i5NfBDoHPzmPOP0JMYaLGlTz\n" + b"wovmvCDJ2zLaezu9NLz9aQ==\n" b"-----END PRIVATE KEY-----\n" )