Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

kex: support curve25519-sha256@libssh.org #1258

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 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
202 changes: 202 additions & 0 deletions paramiko/kex_curve25519.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
# Copyright (C) 2003-2007 Robey Pointer <robeypointer@gmail.com>
#
# 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 distributed 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.

"""
Key exchange using DJB's Curve25519. Originally introduced in OpenSSH 6.5, and
the only kex currently (August 2018) recommended by arthepsy's ssh-audit.
"""

# Author: Dan Fuhry <dan@fuhry.com>

from hashlib import sha256

from paramiko.message import Message
from paramiko.py3compat import byte_chr, long
from paramiko.ssh_exception import SSHException
from cryptography.hazmat.primitives.asymmetric import x25519
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from cryptography.exceptions import UnsupportedAlgorithm
from binascii import hexlify


_MSG_KEXC25519_INIT, _MSG_KEXC25519_REPLY = range(30, 32)
c_MSG_KEXC25519_INIT, c_MSG_KEXC25519_REPLY = [
byte_chr(c) for c in range(30, 32)
]


class KexCurve25519(object):
name = "curve25519-sha256@libssh.org"
hash_algo = sha256
K = None

def __init__(self, transport):
self.transport = transport

self.P = long(0)
# Client public key
self.Q_C = None
# Server public key
self.Q_S = None

def start_kex(self):
self._generate_key_pair()
if self.transport.server_mode:
self.transport._expect_packet(_MSG_KEXC25519_INIT)
return
m = Message()
m.add_byte(c_MSG_KEXC25519_INIT)
Q_C_bytes = self.Q_C.public_bytes(
encoding=Encoding.Raw, format=PublicFormat.Raw
)
m.add_string(Q_C_bytes)
self.transport._send_message(m)
self.transport._expect_packet(_MSG_KEXC25519_REPLY)

def parse_next(self, ptype, m):
if self.transport.server_mode and (ptype == _MSG_KEXC25519_INIT):
return self._parse_kexc25519_init(m)
elif not self.transport.server_mode and (
ptype == _MSG_KEXC25519_REPLY
):

return self._parse_kexc25519_reply(m)
msg = "KexCurve25519 asked to handle packet type {:d}"
raise SSHException(msg.format(ptype))

@staticmethod
def is_supported():
"""
Check if the openssl version pyca-cryptography is linked against
supports curve25519 key agreement.

Returns True if OpenSSL supports x25519 keys, and False otherwise.
"""
try:
x25519.X25519PublicKey.from_public_bytes(b"\x00" * 32)
except UnsupportedAlgorithm:
return False

return True

# ...internals...

def _generate_key_pair(self):
while True:
self.P = x25519.X25519PrivateKey.generate()
pub = self.P.public_key().public_bytes(
encoding=Encoding.Raw, format=PublicFormat.Raw
)
if len(pub) != 32:
continue

if self.transport.server_mode:
self.Q_S = self.P.public_key()
else:
self.Q_C = self.P.public_key()
break

def _parse_kexc25519_reply(self, m):
# client mode

# 3 fields in response:
# - KEX host key
# - Ephemeral (Curve25519) key
# - Signature
K_S = m.get_string()
self.Q_S = x25519.X25519PublicKey.from_public_bytes(m.get_string())
sig = m.get_binary()

# Compute shared secret
K = self.P.exchange(self.Q_S)
K = long(hexlify(K), 16)

hm = Message()
hm.add(
self.transport.local_version,
self.transport.remote_version,
self.transport.local_kex_init,
self.transport.remote_kex_init,
)

# "hm" is used as the initial transport key
hm.add_string(K_S)
hm.add_string(
self.Q_C.public_bytes(
encoding=Encoding.Raw, format=PublicFormat.Raw
)
)
hm.add_string(
self.Q_S.public_bytes(
encoding=Encoding.Raw, format=PublicFormat.Raw
)
)
hm.add_mpint(K)
self.transport._set_K_H(K, self.hash_algo(hm.asbytes()).digest())
# Verify that server signed kex message with its own pubkey
self.transport._verify_key(K_S, sig)
self.transport._activate_outbound()

def _parse_kexc25519_init(self, m):
# server mode

# Only one field in the client's message, which is their public key
Q_C_bytes = m.get_string()
self.Q_C = x25519.X25519PublicKey.from_public_bytes(Q_C_bytes)

# Compute shared secret
K = self.P.exchange(self.Q_C)
K = long(hexlify(K), 16)

# Prepare hostkey
K_S = self.transport.get_server_key().asbytes()

# Compute initial transport key
hm = Message()
hm.add(
self.transport.remote_version,
self.transport.local_version,
self.transport.remote_kex_init,
self.transport.local_kex_init,
)

hm.add_string(K_S)
hm.add_string(Q_C_bytes)
hm.add_string(
self.Q_S.public_bytes(
encoding=Encoding.Raw, format=PublicFormat.Raw
)
)
hm.add_mpint(K)
H = self.hash_algo(hm.asbytes()).digest()
self.transport._set_K_H(K, H)

# Compute signature
sig = self.transport.get_server_key().sign_ssh_data(H)
# construct reply
m = Message()
m.add_byte(c_MSG_KEXC25519_REPLY)
m.add_string(K_S)
m.add_string(
self.Q_S.public_bytes(
encoding=Encoding.Raw, format=PublicFormat.Raw
)
)
m.add_string(sig)
self.transport._send_message(m)
self.transport._activate_outbound()
9 changes: 9 additions & 0 deletions paramiko/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
from paramiko.kex_group1 import KexGroup1
from paramiko.kex_group14 import KexGroup14
from paramiko.kex_ecdh_nist import KexNistp256, KexNistp384, KexNistp521
from paramiko.kex_curve25519 import KexCurve25519
from paramiko.kex_gss import KexGSSGex, KexGSSGroup1, KexGSSGroup14
from paramiko.message import Message
from paramiko.packet import Packetizer, NeedRekeyException
Expand Down Expand Up @@ -175,6 +176,7 @@ class Transport(threading.Thread, ClosingContextManager):
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
)
_preferred_c25519kex = ("curve25519-sha256@libssh.org",)
_preferred_gsskex = (
"gss-gex-sha1-toWM5Slw5Ew8Mqkay+al2g==",
"gss-group14-sha1-toWM5Slw5Ew8Mqkay+al2g==",
Expand Down Expand Up @@ -268,6 +270,7 @@ class Transport(threading.Thread, ClosingContextManager):
"ecdh-sha2-nistp256": KexNistp256,
"ecdh-sha2-nistp384": KexNistp384,
"ecdh-sha2-nistp521": KexNistp521,
"curve25519-sha256@libssh.org": KexCurve25519,
}

_compression_info = {
Expand Down Expand Up @@ -387,6 +390,12 @@ def __init__(
self.session_id = None
self.host_key_type = None
self.host_key = None
self.use_c25519_kex = False

if self._kex_info["curve25519-sha256@libssh.org"].is_supported():
self._preferred_kex = (
self._preferred_c25519kex + self._preferred_kex
)

# GSS-API / SSPI Key Exchange
self.use_gss_kex = gss_kex
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,5 @@
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
],
install_requires=["bcrypt>=3.1.3", "cryptography>=1.5", "pynacl>=1.0.1"],
install_requires=["bcrypt>=3.1.3", "cryptography>=2.5", "pynacl>=1.0.1"],
)
76 changes: 76 additions & 0 deletions tests/test_kex.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,19 @@
from binascii import hexlify, unhexlify
import os
import unittest
import pytest

from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric import x25519

import paramiko.util
from paramiko.kex_group1 import KexGroup1
from paramiko.kex_gex import KexGex, KexGexSHA256
from paramiko import Message
from paramiko.common import byte_chr
from paramiko.kex_ecdh_nist import KexNistp256
from paramiko.kex_curve25519 import KexCurve25519


def dummy_urandom(n):
Expand Down Expand Up @@ -62,6 +65,17 @@ def dummy_generate_key_pair(obj):
).public_key(default_backend())


def dummy_generate_key_curve25519(obj):
private_key_value = unhexlify(
b"2184abc7eb3e656d2349d2470ee695b570c227340c2b2863b6c9ff427af1f040"
)
obj.P = x25519.X25519PrivateKey.from_private_bytes(private_key_value)
if obj.transport.server_mode:
obj.Q_S = obj.P.public_key()
else:
obj.Q_C = obj.P.public_key()


class FakeKey(object):

def __str__(self):
Expand Down Expand Up @@ -127,6 +141,7 @@ def setUp(self):
os.urandom = dummy_urandom
self._original_generate_key_pair = KexNistp256._generate_key_pair
KexNistp256._generate_key_pair = dummy_generate_key_pair
KexCurve25519._generate_key_pair = dummy_generate_key_curve25519

def tearDown(self):
os.urandom = self._original_urandom
Expand Down Expand Up @@ -544,3 +559,64 @@ def test_12_kex_nistp256_server(self):
self.assertEqual(K, transport._K)
self.assertTrue(transport._activated)
self.assertEqual(H, hexlify(transport._H).upper())

def test_13_kex_c25519_client(self):
# Skip test if system OpenSSL doesn't support x25519
if not KexCurve25519.is_supported():
return pytest.skip(
"openssl used by cryptography does not support x25519"
)

K = 71294722834835117201316639182051104803802881348227506835068888449366462300724
transport = FakeTransport()
transport.server_mode = False
kex = KexCurve25519(transport)
kex.start_kex()
self.assertEqual(
(paramiko.kex_curve25519._MSG_KEXC25519_REPLY,), transport._expect
)

# fake reply
msg = Message()
msg.add_string("fake-host-key")
Q_S = unhexlify(
"8d13a119452382a1ada8eea4c979f3e63ad3f0c7366786d6c5b54b87219bae49"
)
msg.add_string(Q_S)
msg.add_string("fake-sig")
msg.rewind()
kex.parse_next(paramiko.kex_curve25519._MSG_KEXC25519_REPLY, msg)
H = b"05B6F6437C0CF38D1A6C5A6F6E2558DEB54E7FC62447EBFB1E5D7407326A5475"
self.assertEqual(K, kex.transport._K)
self.assertEqual(H, hexlify(transport._H).upper())
self.assertEqual((b"fake-host-key", b"fake-sig"), transport._verify)
self.assertTrue(transport._activated)

def test_14_kex_c25519_server(self):
# Skip test if system OpenSSL doesn't support x25519
if not KexCurve25519.is_supported():
return pytest.skip(
"openssl used by cryptography does not support x25519"
)

K = 71294722834835117201316639182051104803802881348227506835068888449366462300724
transport = FakeTransport()
transport.server_mode = True
kex = KexCurve25519(transport)
kex.start_kex()
self.assertEqual(
(paramiko.kex_curve25519._MSG_KEXC25519_INIT,), transport._expect
)

# fake init
msg = Message()
Q_C = unhexlify(
"8d13a119452382a1ada8eea4c979f3e63ad3f0c7366786d6c5b54b87219bae49"
)
H = b"DF08FCFCF31560FEE639D9B6D56D760BC3455B5ADA148E4514181023E7A9B042"
msg.add_string(Q_C)
msg.rewind()
kex.parse_next(paramiko.kex_curve25519._MSG_KEXC25519_INIT, msg)
self.assertEqual(K, transport._K)
self.assertTrue(transport._activated)
self.assertEqual(H, hexlify(transport._H).upper())