Skip to content

Commit a3a3b26

Browse files
committed
Add support for post-quantum ML-KEM key exchange algorithms
This commit adds support for the post-quantum kex algorithms mlkem768nistp256-sha256, mlkem1024nistp384-sha384, and mlkem768x25519-sha256. The latter is now also supported in OpenSSH 9.9, and interoperability has been tested against that. This commit also makes sntrup761x25519-sha512 available without the "@openssh.com" suffix, now that OpenSSH 9.9 supports both names.
1 parent 2601966 commit a3a3b26

File tree

10 files changed

+168
-137
lines changed

10 files changed

+168
-137
lines changed

.github/workflows/run_tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ jobs:
3838

3939
runs-on: ${{ matrix.os }}
4040
env:
41-
liboqs_version: '0.7.2'
41+
liboqs_version: '0.10.1'
4242
nettle_version: nettle_3.8.1_release_20220727
4343

4444
steps:

README.rst

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,7 @@ Features
5757
* Byte and string based I/O with settable encoding
5858
* A variety of `key exchange`__, `encryption`__, and `MAC`__ algorithms
5959

60-
* Including OpenSSH post-quantum kex algorithm
61-
sntrup761x25519-sha512\@openssh.com
60+
* Including post-quantum kex algorithms ML-KEM and SNTRUP
6261

6362
* Support for `gzip compression`__
6463

@@ -159,7 +158,7 @@ functionality:
159158

160159
* Install liboqs from https://github.com/open-quantum-safe/liboqs
161160
if you want support for the OpenSSH post-quantum key exchange
162-
algorithm sntrup761x25519-sha512\@openssh.com.
161+
algorithms based on ML-KEM and SNTRUP.
163162

164163
* Install libsodium from https://github.com/jedisct1/libsodium
165164
and libnacl from https://pypi.python.org/pypi/libnacl if you have

asyncssh/crypto/__init__.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,7 @@
4040

4141
from .rsa import RSAPrivateKey, RSAPublicKey
4242

43-
from .sntrup import sntrup761_available
44-
from .sntrup import sntrup761_pubkey_bytes, sntrup761_ciphertext_bytes
45-
from .sntrup import sntrup761_keypair, sntrup761_encaps, sntrup761_decaps
43+
from .pq import mlkem_available, sntrup_available, PQDH
4644

4745
# Import chacha20-poly1305 cipher if available
4846
from .chacha import ChachaCipher, chacha_available

asyncssh/crypto/ec.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,12 +194,15 @@ def get_public(self) -> bytes:
194194
return pub_key.public_bytes(Encoding.X962,
195195
PublicFormat.UncompressedPoint)
196196

197-
def get_shared(self, peer_public: bytes) -> int:
198-
"""Return the shared key from the peer's public key"""
197+
def get_shared_bytes(self, peer_public: bytes) -> bytes:
198+
"""Return the shared key from the peer's public key as bytes"""
199199

200200
peer_key = ec.EllipticCurvePublicKey.from_encoded_point(
201201
self._priv_key.curve, peer_public)
202202

203-
shared_key = self._priv_key.exchange(ec.ECDH(), peer_key)
203+
return self._priv_key.exchange(ec.ECDH(), peer_key)
204+
205+
def get_shared(self, peer_public: bytes) -> int:
206+
"""Return the shared key from the peer's public key"""
204207

205-
return int.from_bytes(shared_key, 'big')
208+
return int.from_bytes(self.get_shared_bytes(peer_public), 'big')

asyncssh/crypto/ed.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,13 +245,13 @@ def get_public(self) -> bytes:
245245
PublicFormat.Raw)
246246

247247
def get_shared_bytes(self, peer_public: bytes) -> bytes:
248-
"""Return the shared key from the peer's public key"""
248+
"""Return the shared key from the peer's public key as bytes"""
249249

250250
peer_key = x25519.X25519PublicKey.from_public_bytes(peer_public)
251251
return self._priv_key.exchange(peer_key)
252252

253253
def get_shared(self, peer_public: bytes) -> int:
254-
"""Return the shared key from the peer's public key as bytes"""
254+
"""Return the shared key from the peer's public key"""
255255

256256
return int.from_bytes(self.get_shared_bytes(peer_public), 'big')
257257
else: # pragma: no cover

asyncssh/crypto/pq.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Copyright (c) 2022-2024 by Ron Frederick <ronf@timeheart.net> and others.
2+
#
3+
# This program and the accompanying materials are made available under
4+
# the terms of the Eclipse Public License v2.0 which accompanies this
5+
# distribution and is available at:
6+
#
7+
# http://www.eclipse.org/legal/epl-2.0/
8+
#
9+
# This program may also be made available under the following secondary
10+
# licenses when the conditions for such availability set forth in the
11+
# Eclipse Public License v2.0 are satisfied:
12+
#
13+
# GNU General Public License, Version 2.0, or any later versions of
14+
# that license
15+
#
16+
# SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
17+
#
18+
# Contributors:
19+
# Ron Frederick - initial implementation, API, and documentation
20+
21+
"""A shim around liboqs for Streamlined NTRU Prime post-quantum encryption"""
22+
23+
import ctypes
24+
import ctypes.util
25+
from typing import Mapping, Tuple
26+
27+
28+
_pq_algs: Mapping[bytes, Tuple[int, int, int, int, str]] = {
29+
b'mlkem768': (1184, 2400, 1088, 32, 'KEM_ml_kem_768'),
30+
b'mlkem1024': (1568, 3168, 1568, 32, 'KEM_ml_kem_1024'),
31+
b'sntrup761': (1158, 1763, 1039, 32, 'KEM_ntruprime_sntrup761')
32+
}
33+
34+
mlkem_available = False
35+
sntrup_available = False
36+
37+
for lib in ('oqs', 'liboqs'):
38+
_oqs_lib = ctypes.util.find_library(lib)
39+
40+
if _oqs_lib: # pragma: no branch
41+
break
42+
else: # pragma: no cover
43+
_oqs_lib = None
44+
45+
if _oqs_lib: # pragma: no branch
46+
_oqs = ctypes.cdll.LoadLibrary(_oqs_lib)
47+
48+
mlkem_available = (hasattr(_oqs, 'OQS_KEM_ml_kem_768_keypair') or
49+
hasattr(_oqs, 'OQS_KEM_ml_kem_768_ipd_keypair'))
50+
sntrup_available = hasattr(_oqs, 'OQS_KEM_ntruprime_sntrup761_keypair')
51+
52+
53+
class PQDH:
54+
"""A shim around liboqs for post-quantum key exchange algorithms"""
55+
56+
def __init__(self, alg_name: bytes):
57+
try:
58+
self.pubkey_bytes, self.privkey_bytes, \
59+
self.ciphertext_bytes, self.secret_bytes, \
60+
oqs_name = _pq_algs[alg_name]
61+
except KeyError: # pragma: no cover, other algs not registered
62+
raise ValueError('Unknown PQ algorithm %s' % oqs_name) from None
63+
64+
if not hasattr(_oqs, 'OQS_' + oqs_name + '_keypair'): # pragma: no cover
65+
oqs_name += '_ipd'
66+
67+
self._keypair = getattr(_oqs, 'OQS_' + oqs_name + '_keypair')
68+
self._encaps = getattr(_oqs, 'OQS_' + oqs_name + '_encaps')
69+
self._decaps = getattr(_oqs, 'OQS_' + oqs_name + '_decaps')
70+
71+
def keypair(self) -> Tuple[bytes, bytes]:
72+
"""Make a new key pair"""
73+
74+
pubkey = ctypes.create_string_buffer(self.pubkey_bytes)
75+
privkey = ctypes.create_string_buffer(self.privkey_bytes)
76+
self._keypair(pubkey, privkey)
77+
78+
return pubkey.raw, privkey.raw
79+
80+
def encaps(self, pubkey: bytes) -> Tuple[bytes, bytes]:
81+
"""Generate a random secret and encrypt it with a public key"""
82+
83+
if len(pubkey) != self.pubkey_bytes:
84+
raise ValueError('Invalid public key')
85+
86+
ciphertext = ctypes.create_string_buffer(self.ciphertext_bytes)
87+
secret = ctypes.create_string_buffer(self.secret_bytes)
88+
89+
self._encaps(ciphertext, secret, pubkey)
90+
91+
return secret.raw, ciphertext.raw
92+
93+
def decaps(self, ciphertext: bytes, privkey: bytes) -> bytes:
94+
"""Decrypt an encrypted secret using a private key"""
95+
96+
if len(ciphertext) != self.ciphertext_bytes:
97+
raise ValueError('Invalid ciphertext')
98+
99+
secret = ctypes.create_string_buffer(self.secret_bytes)
100+
101+
self._decaps(secret, ciphertext, privkey)
102+
103+
return secret.raw

asyncssh/crypto/sntrup.py

Lines changed: 0 additions & 88 deletions
This file was deleted.

asyncssh/kex_dh.py

Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,9 @@
2525
from typing_extensions import Protocol
2626

2727
from .constants import DEFAULT_LANG
28+
from .crypto import Curve25519DH, Curve448DH, DH, ECDH, PQDH
2829
from .crypto import curve25519_available, curve448_available
29-
from .crypto import Curve25519DH, Curve448DH, DH, ECDH
30-
from .crypto import sntrup761_available
31-
from .crypto import sntrup761_pubkey_bytes, sntrup761_ciphertext_bytes
32-
from .crypto import sntrup761_keypair, sntrup761_encaps, sntrup761_decaps
30+
from .crypto import mlkem_available, sntrup_available
3331
from .gss import GSSError
3432
from .kex import Kex, register_kex_alg, register_gss_kex_alg
3533
from .misc import HashType, KeyExchangeFailed, ProtocolError
@@ -50,6 +48,9 @@ class DHKey(Protocol):
5048
def get_public(self) -> bytes:
5149
"""Return the public key to send to the peer"""
5250

51+
def get_shared_bytes(self, peer_public: bytes) -> bytes:
52+
"""Return the shared key from the peer's public key in bytes"""
53+
5354
def get_shared(self, peer_public: bytes) -> int:
5455
"""Return the shared key from the peer's public key"""
5556

@@ -467,58 +468,56 @@ async def start(self) -> None:
467468
}
468469

469470

470-
class _KexSNTRUP761(_KexECDH):
471-
"""Handler for Streamlined NTRU Prime post-quantum key exchange"""
471+
class _KexHybridECDH(_KexECDH):
472+
"""Handler for post-quantum key exchange"""
472473

473474
def __init__(self, alg: bytes, conn: 'SSHConnection', hash_alg: HashType,
474-
*args: object):
475-
super().__init__(alg, conn, hash_alg, Curve25519DH)
475+
pq_alg_name: bytes, ecdh_class: _ECDHClass, *args: object):
476+
super().__init__(alg, conn, hash_alg, ecdh_class, *args)
477+
478+
self._pq = PQDH(pq_alg_name)
476479

477480
if conn.is_client():
478-
sntrup_pub, self._sntrup_priv = sntrup761_keypair()
479-
self._client_pub = sntrup_pub + self._client_pub
481+
pq_pub, self._pq_priv = self._pq.keypair()
482+
self._client_pub = pq_pub + self._client_pub
480483

481484
def _compute_client_shared(self) -> bytes:
482485
"""Compute client shared key"""
483486

484-
ciphertext = self._server_pub[:sntrup761_ciphertext_bytes]
485-
curve25519_pub = self._server_pub[sntrup761_ciphertext_bytes:]
487+
pq_ciphertext = self._server_pub[:self._pq.ciphertext_bytes]
488+
ec_pub = self._server_pub[self._pq.ciphertext_bytes:]
486489

487490
try:
488-
sntrup_secret = sntrup761_decaps(ciphertext, self._sntrup_priv)
491+
pq_secret = self._pq.decaps(pq_ciphertext, self._pq_priv)
489492
except ValueError:
490-
raise ProtocolError('Invalid SNTRUP server ciphertext') from None
493+
raise ProtocolError('Invalid PQ server ciphertext') from None
491494

492495
try:
493-
priv = cast(Curve25519DH, self._priv)
494-
curve25519_shared = priv.get_shared_bytes(curve25519_pub)
496+
ec_shared = self._priv.get_shared_bytes(ec_pub)
495497
except ValueError:
496498
raise ProtocolError('Invalid ECDH server public key') from None
497499

498-
return String(self._hash_alg(sntrup_secret +
499-
curve25519_shared).digest())
500+
return String(self._hash_alg(pq_secret + ec_shared).digest())
500501

501502
def _compute_server_shared(self) -> bytes:
502503
"""Compute server shared key"""
503504

504-
sntrup_pub = self._client_pub[:sntrup761_pubkey_bytes]
505-
curve25519_pub = self._client_pub[sntrup761_pubkey_bytes:]
505+
pq_pub = self._client_pub[:self._pq.pubkey_bytes]
506+
ec_pub = self._client_pub[self._pq.pubkey_bytes:]
506507

507508
try:
508-
sntrup_secret, ciphertext = sntrup761_encaps(sntrup_pub)
509+
pq_secret, pq_ciphertext = self._pq.encaps(pq_pub)
509510
except ValueError:
510-
raise ProtocolError('Invalid SNTRUP client public key') from None
511+
raise ProtocolError('Invalid PQ client public key') from None
511512

512513
try:
513-
priv = cast(Curve25519DH, self._priv)
514-
curve25519_shared = priv.get_shared_bytes(curve25519_pub)
514+
ec_shared = self._priv.get_shared_bytes(ec_pub)
515515
except ValueError:
516516
raise ProtocolError('Invalid ECDH client public key') from None
517517

518-
self._server_pub = ciphertext + self._server_pub
518+
self._server_pub = pq_ciphertext + self._server_pub
519519

520-
return String(self._hash_alg(sntrup_secret +
521-
curve25519_shared).digest())
520+
return String(self._hash_alg(pq_secret + ec_shared).digest())
522521

523522

524523
class _KexGSSBase(_KexDHBase):
@@ -737,10 +736,22 @@ class _KexGSSECDH(_KexGSSBase, _KexECDH):
737736
}
738737

739738

739+
if mlkem_available: # pragma: no branch
740+
if curve25519_available: # pragma: no branch
741+
register_kex_alg(b'mlkem768x25519-sha256', _KexHybridECDH,
742+
sha256, (b'mlkem768', Curve25519DH), True)
743+
744+
register_kex_alg(b'mlkem768nistp256-sha256', _KexHybridECDH,
745+
sha256, (b'mlkem768', ECDH, b'nistp256'), True)
746+
register_kex_alg(b'mlkem1024nistp384-sha384', _KexHybridECDH,
747+
sha384, (b'mlkem1024', ECDH, b'nistp384'), True)
748+
740749
if curve25519_available: # pragma: no branch
741-
if sntrup761_available: # pragma: no branch
742-
register_kex_alg(b'sntrup761x25519-sha512@openssh.com', _KexSNTRUP761,
743-
sha512, (), True)
750+
if sntrup_available: # pragma: no branch
751+
register_kex_alg(b'sntrup761x25519-sha512', _KexHybridECDH,
752+
sha512, (b'sntrup761', Curve25519DH), True)
753+
register_kex_alg(b'sntrup761x25519-sha512@openssh.com', _KexHybridECDH,
754+
sha512, (b'sntrup761', Curve25519DH), True)
744755

745756
register_kex_alg(b'curve25519-sha256', _KexECDH, sha256,
746757
(Curve25519DH,), True)

0 commit comments

Comments
 (0)