summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--paramiko/__init__.py1
-rw-r--r--paramiko/channel.py1
-rw-r--r--paramiko/client.py36
-rw-r--r--paramiko/ed25519key.py194
-rw-r--r--paramiko/hostkeys.py3
-rw-r--r--paramiko/transport.py4
-rw-r--r--setup.py2
-rw-r--r--sites/www/changelog.rst8
-rw-r--r--sites/www/index.rst1
-rw-r--r--sites/www/installing-1.x.rst1
-rw-r--r--sites/www/installing.rst6
-rw-r--r--tests/test_client.py4
-rw-r--r--tests/test_ed25519.key8
-rw-r--r--tests/test_ed25519_password.key8
-rw-r--r--tests/test_pkey.py19
15 files changed, 261 insertions, 35 deletions
diff --git a/paramiko/__init__.py b/paramiko/__init__.py
index 197f519a..d67ad62f 100644
--- a/paramiko/__init__.py
+++ b/paramiko/__init__.py
@@ -45,6 +45,7 @@ from paramiko.server import ServerInterface, SubsystemHandler, InteractiveQuery
from paramiko.rsakey import RSAKey
from paramiko.dsskey import DSSKey
from paramiko.ecdsakey import ECDSAKey
+from paramiko.ed25519key import Ed25519Key
from paramiko.sftp import SFTPError, BaseSFTP
from paramiko.sftp_client import SFTP, SFTPClient
from paramiko.sftp_server import SFTPServer
diff --git a/paramiko/channel.py b/paramiko/channel.py
index 51646ea3..e1747fb8 100644
--- a/paramiko/channel.py
+++ b/paramiko/channel.py
@@ -25,6 +25,7 @@ import os
import socket
import time
import threading
+# TODO: switch as much of py3compat.py to 'six' as possible, then use six.wraps
from functools import wraps
from paramiko import util
diff --git a/paramiko/client.py b/paramiko/client.py
index d4bd8cb6..689f84b1 100644
--- a/paramiko/client.py
+++ b/paramiko/client.py
@@ -32,6 +32,7 @@ from paramiko.common import DEBUG
from paramiko.config import SSH_PORT
from paramiko.dsskey import DSSKey
from paramiko.ecdsakey import ECDSAKey
+from paramiko.ed25519key import Ed25519Key
from paramiko.hostkeys import HostKeys
from paramiko.py3compat import string_types
from paramiko.resource import ResourceManager
@@ -548,7 +549,7 @@ class SSHClient (ClosingContextManager):
if not two_factor:
for key_filename in key_filenames:
- for pkey_class in (RSAKey, DSSKey, ECDSAKey):
+ for pkey_class in (RSAKey, DSSKey, ECDSAKey, Ed25519Key):
try:
key = pkey_class.from_private_key_file(
key_filename, password)
@@ -588,25 +589,20 @@ class SSHClient (ClosingContextManager):
if not two_factor:
keyfiles = []
- rsa_key = os.path.expanduser('~/.ssh/id_rsa')
- dsa_key = os.path.expanduser('~/.ssh/id_dsa')
- ecdsa_key = os.path.expanduser('~/.ssh/id_ecdsa')
- if os.path.isfile(rsa_key):
- keyfiles.append((RSAKey, rsa_key))
- if os.path.isfile(dsa_key):
- keyfiles.append((DSSKey, dsa_key))
- if os.path.isfile(ecdsa_key):
- keyfiles.append((ECDSAKey, ecdsa_key))
- # look in ~/ssh/ for windows users:
- rsa_key = os.path.expanduser('~/ssh/id_rsa')
- dsa_key = os.path.expanduser('~/ssh/id_dsa')
- ecdsa_key = os.path.expanduser('~/ssh/id_ecdsa')
- if os.path.isfile(rsa_key):
- keyfiles.append((RSAKey, rsa_key))
- if os.path.isfile(dsa_key):
- keyfiles.append((DSSKey, dsa_key))
- if os.path.isfile(ecdsa_key):
- keyfiles.append((ECDSAKey, ecdsa_key))
+
+ for keytype, name in [
+ (RSAKey, "rsa"),
+ (DSSKey, "dsa"),
+ (ECDSAKey, "ecdsa"),
+ (Ed25519Key, "ed25519"),
+ ]:
+ # ~/ssh/ is for windows
+ for directory in [".ssh", "ssh"]:
+ full_path = os.path.expanduser(
+ "~/%s/id_%s" % (directory, name)
+ )
+ if os.path.isfile(full_path):
+ keyfiles.append((keytype, full_path))
if not look_for_keys:
keyfiles = []
diff --git a/paramiko/ed25519key.py b/paramiko/ed25519key.py
new file mode 100644
index 00000000..e1a8a732
--- /dev/null
+++ b/paramiko/ed25519key.py
@@ -0,0 +1,194 @@
+# This file is part of paramiko.
+#
+# Paramiko is free software; you can redistribute it and/or modify it under the
+# terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation; either version 2.1 of the License, or (at your option)
+# any later version.
+#
+# Paramiko is distrubuted in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with Paramiko; if not, write to the Free Software Foundation, Inc.,
+# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA.
+
+import bcrypt
+
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives.ciphers import Cipher
+
+import nacl.signing
+
+import six
+
+from paramiko.message import Message
+from paramiko.pkey import PKey
+from paramiko.ssh_exception import SSHException, PasswordRequiredException
+
+
+OPENSSH_AUTH_MAGIC = b"openssh-key-v1\x00"
+
+
+def unpad(data):
+ # At the moment, this is only used for unpadding private keys on disk. This
+ # really ought to be made constant time (possibly by upstreaming this logic
+ # into pyca/cryptography).
+ padding_length = six.indexbytes(data, -1)
+ if padding_length > 16:
+ raise SSHException("Invalid key")
+ for i in range(1, padding_length + 1):
+ if six.indexbytes(data, -i) != (padding_length - i + 1):
+ raise SSHException("Invalid key")
+ return data[:-padding_length]
+
+
+class Ed25519Key(PKey):
+ def __init__(self, msg=None, data=None, filename=None, password=None):
+ verifying_key = signing_key = None
+ if msg is None and data is not None:
+ msg = Message(data)
+ if msg is not None:
+ if msg.get_text() != "ssh-ed25519":
+ raise SSHException("Invalid key")
+ verifying_key = nacl.signing.VerifyKey(msg.get_binary())
+ elif filename is not None:
+ with open(filename, "r") as f:
+ data = self._read_private_key("OPENSSH", f)
+ signing_key = self._parse_signing_key_data(data, password)
+
+ if signing_key is None and verifying_key is None:
+ raise ValueError("need a key")
+
+ self._signing_key = signing_key
+ self._verifying_key = verifying_key
+
+ def _parse_signing_key_data(self, data, password):
+ from paramiko.transport import Transport
+ # We may eventually want this to be usable for other key types, as
+ # OpenSSH moves to it, but for now this is just for Ed25519 keys.
+ # This format is described here:
+ # https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key
+ # The description isn't totally complete, and I had to refer to the
+ # source for a full implementation.
+ message = Message(data)
+ if message.get_bytes(len(OPENSSH_AUTH_MAGIC)) != OPENSSH_AUTH_MAGIC:
+ raise SSHException("Invalid key")
+
+ ciphername = message.get_text()
+ kdfname = message.get_text()
+ kdfoptions = message.get_binary()
+ num_keys = message.get_int()
+
+ if kdfname == "none":
+ # kdfname of "none" must have an empty kdfoptions, the ciphername
+ # must be "none"
+ if kdfoptions or ciphername != "none":
+ raise SSHException("Invalid key")
+ elif kdfname == "bcrypt":
+ if not password:
+ raise PasswordRequiredException(
+ "Private key file is encrypted"
+ )
+ kdf = Message(kdfoptions)
+ bcrypt_salt = kdf.get_binary()
+ bcrypt_rounds = kdf.get_int()
+ else:
+ raise SSHException("Invalid key")
+
+ if ciphername != "none" and ciphername not in Transport._cipher_info:
+ raise SSHException("Invalid key")
+
+ public_keys = []
+ for _ in range(num_keys):
+ pubkey = Message(message.get_binary())
+ if pubkey.get_text() != "ssh-ed25519":
+ raise SSHException("Invalid key")
+ public_keys.append(pubkey.get_binary())
+
+ private_ciphertext = message.get_binary()
+ if ciphername == "none":
+ private_data = private_ciphertext
+ else:
+ cipher = Transport._cipher_info[ciphername]
+ key = bcrypt.kdf(
+ password=password,
+ salt=bcrypt_salt,
+ desired_key_bytes=cipher["key-size"] + cipher["block-size"],
+ rounds=bcrypt_rounds,
+ # We can't control how many rounds are on disk, so no sense
+ # warning about it.
+ ignore_few_rounds=True,
+ )
+ decryptor = Cipher(
+ cipher["class"](key[:cipher["key-size"]]),
+ cipher["mode"](key[cipher["key-size"]:]),
+ backend=default_backend()
+ ).decryptor()
+ private_data = (
+ decryptor.update(private_ciphertext) + decryptor.finalize()
+ )
+
+ message = Message(unpad(private_data))
+ if message.get_int() != message.get_int():
+ raise SSHException("Invalid key")
+
+ signing_keys = []
+ for i in range(num_keys):
+ if message.get_text() != "ssh-ed25519":
+ raise SSHException("Invalid key")
+ # A copy of the public key, again, ignore.
+ public = message.get_binary()
+ key_data = message.get_binary()
+ # The second half of the key data is yet another copy of the public
+ # key...
+ signing_key = nacl.signing.SigningKey(key_data[:32])
+ # Verify that all the public keys are the same...
+ assert (
+ signing_key.verify_key.encode() == public == public_keys[i] ==
+ key_data[32:]
+ )
+ signing_keys.append(signing_key)
+ # Comment, ignore.
+ message.get_binary()
+
+ if len(signing_keys) != 1:
+ raise SSHException("Invalid key")
+ return signing_keys[0]
+
+ def asbytes(self):
+ if self.can_sign():
+ v = self._signing_key.verify_key
+ else:
+ v = self._verifying_key
+ m = Message()
+ m.add_string("ssh-ed25519")
+ m.add_string(v.encode())
+ return m.asbytes()
+
+ def get_name(self):
+ return "ssh-ed25519"
+
+ def get_bits(self):
+ return 256
+
+ def can_sign(self):
+ return self._signing_key is not None
+
+ def sign_ssh_data(self, data):
+ m = Message()
+ m.add_string("ssh-ed25519")
+ m.add_string(self._signing_key.sign(data).signature)
+ return m
+
+ def verify_ssh_sig(self, data, msg):
+ if msg.get_text() != "ssh-ed25519":
+ return False
+
+ try:
+ self._verifying_key.verify(data, msg.get_binary())
+ except nacl.exceptions.BadSignatureError:
+ return False
+ else:
+ return True
diff --git a/paramiko/hostkeys.py b/paramiko/hostkeys.py
index 008ba592..3e27fd52 100644
--- a/paramiko/hostkeys.py
+++ b/paramiko/hostkeys.py
@@ -35,6 +35,7 @@ from paramiko.dsskey import DSSKey
from paramiko.rsakey import RSAKey
from paramiko.util import get_logger, constant_time_bytes_eq
from paramiko.ecdsakey import ECDSAKey
+from paramiko.ed25519key import Ed25519Key
from paramiko.ssh_exception import SSHException
@@ -360,6 +361,8 @@ class HostKeyEntry:
key = DSSKey(data=decodebytes(key))
elif keytype in ECDSAKey.supported_key_format_identifiers():
key = ECDSAKey(data=decodebytes(key), validate_point=False)
+ elif keytype == 'ssh-ed25519':
+ key = Ed25519Key(data=decodebytes(key))
else:
log.info("Unable to handle key of type %s" % (keytype,))
return None
diff --git a/paramiko/transport.py b/paramiko/transport.py
index 9ca951d1..6071a8bc 100644
--- a/paramiko/transport.py
+++ b/paramiko/transport.py
@@ -54,6 +54,7 @@ from paramiko.common import (
)
from paramiko.compress import ZlibCompressor, ZlibDecompressor
from paramiko.dsskey import DSSKey
+from paramiko.ed25519key import Ed25519Key
from paramiko.kex_gex import KexGex, KexGexSHA256
from paramiko.kex_group1 import KexGroup1
from paramiko.kex_group14 import KexGroup14
@@ -80,6 +81,7 @@ def _join_lingering_threads():
for thr in _active_threads:
thr.stop_thread()
+
import atexit
atexit.register(_join_lingering_threads)
@@ -126,6 +128,7 @@ class Transport(threading.Thread, ClosingContextManager):
'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp384',
'ecdsa-sha2-nistp521',
+ 'ssh-ed25519',
'ssh-rsa',
'ssh-dss',
)
@@ -216,6 +219,7 @@ class Transport(threading.Thread, ClosingContextManager):
'ecdsa-sha2-nistp256': ECDSAKey,
'ecdsa-sha2-nistp384': ECDSAKey,
'ecdsa-sha2-nistp521': ECDSAKey,
+ 'ssh-ed25519': Ed25519Key,
}
_kex_info = {
diff --git a/setup.py b/setup.py
index 80d5ea7f..e2ace96d 100644
--- a/setup.py
+++ b/setup.py
@@ -74,7 +74,9 @@ setup(
'Programming Language :: Python :: 3.5',
],
install_requires=[
+ 'bcrypt>=3.0.0',
'cryptography>=1.1',
+ 'pynacl>=1.0.1',
'pyasn1>=0.1.7',
],
)
diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst
index 58ef3a33..c75b4b68 100644
--- a/sites/www/changelog.rst
+++ b/sites/www/changelog.rst
@@ -15,6 +15,14 @@ Changelog
to hosts which only offer these key types and no others. This is now fixed.
Thanks to ``@ncoult`` and ``@kasdoe`` for reports and Pierce Lopez for the
patch.
+* :feature:`325` (via :issue:`972`) Add Ed25519 support, for both host keys
+ and user authentication. Big thanks to Alex Gaynor for the patch.
+
+ .. note::
+ This change adds the ``bcrypt`` and ``pynacl`` Python libraries as
+ dependencies. No C-level dependencies beyond those previously required (for
+ Cryptography) have been added.
+
* :support:`974 backported` Overhaul the codebase to be PEP-8, etc, compliant
(i.e. passes the maintainer's preferred `flake8 <http://flake8.pycqa.org/>`_
configuration) and add a ``flake8`` step to the Travis config. Big thanks to
diff --git a/sites/www/index.rst b/sites/www/index.rst
index b09ab589..f0a5db8a 100644
--- a/sites/www/index.rst
+++ b/sites/www/index.rst
@@ -20,6 +20,7 @@ Please see the sidebar to the left to begin.
changelog
FAQs <faq>
installing
+ installing-1.x
contributing
contact
diff --git a/sites/www/installing-1.x.rst b/sites/www/installing-1.x.rst
index 356fac49..8ede40d5 100644
--- a/sites/www/installing-1.x.rst
+++ b/sites/www/installing-1.x.rst
@@ -1,3 +1,4 @@
+================
Installing (1.x)
================
diff --git a/sites/www/installing.rst b/sites/www/installing.rst
index 6537b850..f335a9e7 100644
--- a/sites/www/installing.rst
+++ b/sites/www/installing.rst
@@ -110,9 +110,3 @@ due to their infrequent utility & non-platform-agnostic requirements):
delegation, make sure that the target host is trusted for delegation in the
active directory configuration. For details see:
http://technet.microsoft.com/en-us/library/cc738491%28v=ws.10%29.aspx
-
-
-.. toctree::
- :hidden:
-
- installing-1.x
diff --git a/tests/test_client.py b/tests/test_client.py
index 5f4f0dd5..a340be00 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -43,6 +43,7 @@ FINGERPRINTS = {
'ssh-dss': b'\x44\x78\xf0\xb9\xa2\x3c\xc5\x18\x20\x09\xff\x75\x5b\xc1\xd2\x6c',
'ssh-rsa': b'\x60\x73\x38\x44\xcb\x51\x86\x65\x7f\xde\xda\xa2\x2b\x5a\x57\xd5',
'ecdsa-sha2-nistp256': b'\x25\x19\xeb\x55\xe6\xa1\x47\xff\x4f\x38\xd2\x75\x6f\xa5\xd5\x60',
+ 'ssh-ed25519': b'\xb3\xd5"\xaa\xf9u^\xe8\xcd\x0e\xea\x02\xb9)\xa2\x80',
}
@@ -194,6 +195,9 @@ class SSHClientTest (unittest.TestCase):
"""
self._test_connection(key_filename=test_path('test_ecdsa_256.key'))
+ def test_client_ed25519(self):
+ self._test_connection(key_filename=test_path('test_ed25519.key'))
+
def test_3_multiple_key_files(self):
"""
verify that SSHClient accepts and tries multiple key files.
diff --git a/tests/test_ed25519.key b/tests/test_ed25519.key
new file mode 100644
index 00000000..eb9f94c2
--- /dev/null
+++ b/tests/test_ed25519.key
@@ -0,0 +1,8 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACB69SvZKJh/9VgSL0G27b5xVYa8nethH3IERbi0YqJDXwAAAKhjwAdrY8AH
+awAAAAtzc2gtZWQyNTUxOQAAACB69SvZKJh/9VgSL0G27b5xVYa8nethH3IERbi0YqJDXw
+AAAEA9tGQi2IrprbOSbDCF+RmAHd6meNSXBUQ2ekKXm4/8xnr1K9komH/1WBIvQbbtvnFV
+hryd62EfcgRFuLRiokNfAAAAI2FsZXhfZ2F5bm9yQEFsZXhzLU1hY0Jvb2stQWlyLmxvY2
+FsAQI=
+-----END OPENSSH PRIVATE KEY-----
diff --git a/tests/test_ed25519_password.key b/tests/test_ed25519_password.key
new file mode 100644
index 00000000..d178aaae
--- /dev/null
+++ b/tests/test_ed25519_password.key
@@ -0,0 +1,8 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jYmMAAAAGYmNyeXB0AAAAGAAAABDaKD4ac7
+kieb+UfXaLaw68AAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIOQn7fjND5ozMSV3
+CvbEtIdT73hWCMRjzS/lRdUDw50xAAAAsE8kLGyYBnl9ihJNqv378y6mO3SkzrDbWXOnK6
+ij0vnuTAvcqvWHAnyu6qBbplu/W2m55ZFeAItgaEcV2/V76sh/sAKlERqrLFyXylN0xoOW
+NU5+zU08aTlbSKGmeNUU2xE/xfJq12U9XClIRuVUkUpYANxNPbmTRpVrbD3fgXMhK97Jrb
+DEn8ca1IqMPiYmd/hpe5+tq3OxyRljXjCUFWTnqkp9VvUdzSTdSGZHsW9i
+-----END OPENSSH PRIVATE KEY-----
diff --git a/tests/test_pkey.py b/tests/test_pkey.py
index 24d78c3e..a26ff170 100644
--- a/tests/test_pkey.py
+++ b/tests/test_pkey.py
@@ -27,7 +27,7 @@ from binascii import hexlify
from hashlib import md5
import base64
-from paramiko import RSAKey, DSSKey, ECDSAKey, Message, util
+from paramiko import RSAKey, DSSKey, ECDSAKey, Ed25519Key, Message, util
from paramiko.py3compat import StringIO, byte_chr, b, bytes, PY2
from tests.util import test_path
@@ -112,14 +112,7 @@ TEST_KEY_BYTESTR_2 = '\x00\x00\x00\x07ssh-rsa\x00\x00\x00\x01#\x00\x00\x00\x81\x
TEST_KEY_BYTESTR_3 = '\x00\x00\x00\x07ssh-rsa\x00\x00\x00\x01#\x00\x00\x00\x00ӏV\x07k%<\x1fT$E#>ғfD\x18 \x0cae#̬S#VlE\x1epvo\x17M߉DUXL<\x06\x10דw\u2bd5ٿw˟0)#y{\x10l\tPru\t\x19Π\u070e/f0yFmm\x1f'
-class KeyTest (unittest.TestCase):
-
- def setUp(self):
- pass
-
- def tearDown(self):
- pass
-
+class KeyTest(unittest.TestCase):
def test_1_generate_key_bytes(self):
key = util.generate_key_bytes(md5, x1234, 'happy birthday', 30)
exp = b'\x61\xE1\xF2\x72\xF4\xC1\xC4\x56\x15\x86\xBD\x32\x24\x98\xC0\xE9\x24\x67\x27\x80\xF4\x7B\xB3\x7D\xDA\x7D\x54\x01\x9E\x64'
@@ -436,3 +429,11 @@ class KeyTest (unittest.TestCase):
key = RSAKey.from_private_key_file(test_path('test_rsa.key'))
comparable = TEST_KEY_BYTESTR_2 if PY2 else TEST_KEY_BYTESTR_3
self.assertEqual(str(key), comparable)
+
+ def test_ed25519(self):
+ key1 = Ed25519Key.from_private_key_file(test_path('test_ed25519.key'))
+ key2 = Ed25519Key.from_private_key_file(
+ test_path('test_ed25519_password.key'), b'abc123'
+ )
+
+ self.assertNotEqual(key1.asbytes(), key2.asbytes())