Coverage for /pythoncovmergedfiles/medio/medio/src/paramiko/paramiko/kex_mlkem.py: 32%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""
2ML-KEM hybrid key exchange for SSH.
4Implements the post-quantum / classical hybrid key agreement methods
5described in draft-ietf-sshm-mlkem-hybrid-kex (e.g.
6``mlkem768x25519-sha256``).
8Each method combines an ML-KEM key encapsulation with a traditional
9ECDH/X25519 key agreement; the final shared secret is the hash of the
10two component secrets concatenated together.
11"""
13from __future__ import annotations
15import hashlib
16from typing import TYPE_CHECKING
18from cryptography.exceptions import UnsupportedAlgorithm
19from cryptography.hazmat.primitives import constant_time
20from cryptography.hazmat.primitives.asymmetric.x25519 import (
21 X25519PrivateKey,
22 X25519PublicKey,
23)
25try:
26 from cryptography.hazmat.primitives.asymmetric import mlkem
27except ImportError: # pragma: no cover - cryptography <48
28 mlkem = None
30from paramiko.common import byte_chr
31from paramiko.message import Message
32from paramiko.ssh_exception import SSHException
34if TYPE_CHECKING:
35 from paramiko.transport import Transport
37# Per draft-ietf-sshm-mlkem-hybrid-kex, the hybrid kex reuses message
38# numbers 30 and 31 (named SSH_MSG_KEX_HYBRID_INIT / _REPLY).
39_MSG_KEX_HYBRID_INIT, _MSG_KEX_HYBRID_REPLY = range(30, 32)
40c_MSG_KEX_HYBRID_INIT, c_MSG_KEX_HYBRID_REPLY = [
41 byte_chr(c) for c in range(30, 32)
42]
45class KexMLKEM768X25519:
46 """
47 ``mlkem768x25519-sha256`` hybrid key exchange.
49 Combines ML-KEM-768 (FIPS 203) with X25519. The shared secret is
50 ``SHA-256(K_PQ || K_CL)`` where ``K_PQ`` is the ML-KEM secret and
51 ``K_CL`` is the X25519 shared secret.
52 """
54 name = "mlkem768x25519-sha256"
55 hash_algo = hashlib.sha256
56 # Fixed sizes from FIPS 203 (ML-KEM-768) and RFC 7748 (X25519).
57 _MLKEM_PUBKEY_BYTES = 1184
58 _MLKEM_CIPHERTEXT_BYTES = 1088
59 _X25519_PUBKEY_BYTES = 32
60 _C_INIT_BYTES = _MLKEM_PUBKEY_BYTES + _X25519_PUBKEY_BYTES
61 _S_REPLY_BYTES = _MLKEM_CIPHERTEXT_BYTES + _X25519_PUBKEY_BYTES
63 def __init__(self, transport: Transport):
64 self.transport = transport
65 # Client-side: our ML-KEM decapsulation key. Server-side: unused.
66 self.mlkem_key = None
67 # Our ephemeral X25519 private key (both roles).
68 self.x25519_key = None
70 @classmethod
71 def is_available(cls):
72 if mlkem is None:
73 return False
74 try:
75 mlkem.MLKEM768PrivateKey.generate()
76 X25519PrivateKey.generate()
77 except UnsupportedAlgorithm:
78 return False
79 return True
81 # ---- protocol entry points ---------------------------------------
83 def start_kex(self):
84 self.x25519_key = X25519PrivateKey.generate()
85 if self.transport.server_mode:
86 self.transport._expect_packet(_MSG_KEX_HYBRID_INIT)
87 return
88 self.mlkem_key = mlkem.MLKEM768PrivateKey.generate()
89 c_init = (
90 self.mlkem_key.public_key().public_bytes_raw()
91 + self.x25519_key.public_key().public_bytes_raw()
92 )
93 m = Message()
94 m.add_byte(c_MSG_KEX_HYBRID_INIT)
95 m.add_string(c_init)
96 self.transport._send_message(m)
97 self.transport._expect_packet(_MSG_KEX_HYBRID_REPLY)
99 def parse_next(self, ptype, m):
100 if self.transport.server_mode and ptype == _MSG_KEX_HYBRID_INIT:
101 return self._parse_hybrid_init(m)
102 if not self.transport.server_mode and ptype == _MSG_KEX_HYBRID_REPLY:
103 return self._parse_hybrid_reply(m)
104 raise SSHException(
105 "{} asked to handle packet type {:d}".format(
106 self.__class__.__name__, ptype
107 )
108 )
110 def _x25519_exchange(self, peer_pub_bytes):
111 peer = X25519PublicKey.from_public_bytes(peer_pub_bytes)
112 secret = self.x25519_key.exchange(peer)
113 # Per RFC 8731 (and reaffirmed by the hybrid draft), reject the
114 # all-zero output that signals a small-order public value.
115 if constant_time.bytes_eq(secret, b"\x00" * 32):
116 raise SSHException(
117 "peer's curve25519 public value has wrong order"
118 )
119 return secret
121 # ---- server side -------------------------------------------------
123 def _parse_hybrid_init(self, m):
124 c_init = m.get_string()
125 if len(c_init) != self._C_INIT_BYTES:
126 raise SSHException(
127 "Invalid C_INIT length for {}: got {}, expected {}".format(
128 self.name, len(c_init), self._C_INIT_BYTES
129 )
130 )
131 c_pk2 = c_init[: self._MLKEM_PUBKEY_BYTES]
132 c_pk1 = c_init[self._MLKEM_PUBKEY_BYTES :]
134 # Encapsulate against the client's ML-KEM public key.
135 client_mlkem_pub = mlkem.MLKEM768PublicKey.from_public_bytes(c_pk2)
136 k_pq, s_ct2 = client_mlkem_pub.encapsulate()
138 # X25519 with the client's ephemeral public value.
139 k_cl = self._x25519_exchange(c_pk1)
141 K_bytes = self.hash_algo(k_pq + k_cl).digest()
143 s_pk1 = self.x25519_key.public_key().public_bytes_raw()
144 s_reply = s_ct2 + s_pk1
146 K_S = self.transport.get_server_key().asbytes()
148 hm = Message()
149 hm.add(
150 self.transport.remote_version,
151 self.transport.local_version,
152 self.transport.remote_kex_init,
153 self.transport.local_kex_init,
154 )
155 hm.add_string(K_S)
156 hm.add_string(c_init)
157 hm.add_string(s_reply)
158 # Per the hybrid draft: K is the hash output, encoded as a string.
159 hm.add_string(K_bytes)
160 H = self.hash_algo(hm.asbytes()).digest()
162 self.transport._set_K_H(K_bytes, H)
164 sig = self.transport.get_server_key().sign_ssh_data(
165 H, self.transport.host_key_type
166 )
168 reply = Message()
169 reply.add_byte(c_MSG_KEX_HYBRID_REPLY)
170 reply.add_string(K_S)
171 reply.add_string(s_reply)
172 reply.add_string(sig)
173 self.transport._send_message(reply)
174 self.transport._activate_outbound()
176 # ---- client side -------------------------------------------------
178 def _parse_hybrid_reply(self, m):
179 K_S = m.get_string()
180 s_reply = m.get_string()
181 sig = m.get_binary()
183 if len(s_reply) != self._S_REPLY_BYTES:
184 raise SSHException(
185 "Invalid S_REPLY length for {}: got {}, expected {}".format(
186 self.name, len(s_reply), self._S_REPLY_BYTES
187 )
188 )
189 s_ct2 = s_reply[: self._MLKEM_CIPHERTEXT_BYTES]
190 s_pk1 = s_reply[self._MLKEM_CIPHERTEXT_BYTES :]
192 k_pq = self.mlkem_key.decapsulate(s_ct2)
193 k_cl = self._x25519_exchange(s_pk1)
194 K_bytes = self.hash_algo(k_pq + k_cl).digest()
196 c_init = (
197 self.mlkem_key.public_key().public_bytes_raw()
198 + self.x25519_key.public_key().public_bytes_raw()
199 )
201 hm = Message()
202 hm.add(
203 self.transport.local_version,
204 self.transport.remote_version,
205 self.transport.local_kex_init,
206 self.transport.remote_kex_init,
207 )
208 hm.add_string(K_S)
209 hm.add_string(c_init)
210 hm.add_string(s_reply)
211 hm.add_string(K_bytes)
212 H = self.hash_algo(hm.asbytes()).digest()
214 self.transport._set_K_H(K_bytes, H)
215 self.transport._verify_key(K_S, sig)
216 self.transport._activate_outbound()