ccssh 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ccssh/kex.py ADDED
@@ -0,0 +1,222 @@
1
+ """
2
+ Key exchange negotiation plus curve25519-sha256 and mlkem768x25519-sha256.
3
+
4
+ Primitives come from `cryptography`; the hybrid math lives in ccssh.mlkem.
5
+ ServerStub classes are test-only loopback peers (never production).
6
+ """
7
+ from __future__ import annotations
8
+ import hashlib
9
+
10
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
11
+
12
+ from ccssh import hostkeys
13
+ from ccssh import mlkem
14
+ from ccssh import wire as W
15
+
16
+ MSG_KEXINIT = 20
17
+ MSG_NEWKEYS = 21
18
+ MSG_EXT_INFO = 7
19
+ MSG_KEX_ECDH_INIT = 30
20
+ MSG_KEX_ECDH_REPLY = 31
21
+ # The hybrid KEX reuses message numbers 30/31 (HYBRID_INIT/REPLY).
22
+
23
+ # Terrapin defense (strict-kex) + extensions (ext-info). Clients send -c, servers -s.
24
+ KEX_STRICT_C = "kex-strict-c-v00@openssh.com"
25
+ KEX_STRICT_S = "kex-strict-s-v00@openssh.com"
26
+ EXT_INFO_C = "ext-info-c"
27
+
28
+ # Client offer for the loopback stub (priority order).
29
+ KEX_OFFER = ["mlkem768x25519-sha256", "curve25519-sha256"]
30
+ HOSTKEY_OFFER = ["ssh-ed25519"]
31
+ CIPHER_OFFER = ["aes128-ctr"]
32
+ MAC_OFFER = ["hmac-sha2-256"]
33
+ COMP_OFFER = ["none"]
34
+ LANG_OFFER: list[str] = []
35
+
36
+
37
+ def enc_ext_info(exts: list[tuple[bytes, bytes]]) -> bytes:
38
+ out = bytes([MSG_EXT_INFO]) + W.enc_u32(len(exts))
39
+ for n, v in exts:
40
+ out += W.enc_string(n) + W.enc_string(v)
41
+ return out
42
+
43
+
44
+ def negotiate(client: list[str], server: list[str]) -> str:
45
+ flags = {KEX_STRICT_C, KEX_STRICT_S, EXT_INFO_C}
46
+ for c in client:
47
+ if c in flags:
48
+ continue
49
+ if c in server:
50
+ return c
51
+ raise ValueError(f"no common algorithm: client {client} vs server {server}")
52
+
53
+
54
+ def negotiate_symmetric(ours: list[str], peer_c2s: list[str], peer_s2c: list[str], what: str) -> str:
55
+ """
56
+ Negotiate one algorithm for both directions (OpenSSH behavior).
57
+
58
+ RFC 4253 permits asymmetry, but OpenSSH always uses the same cipher/MAC
59
+ both ways and so do our key schedules - accepting anything else would
60
+ derive the wrong receive keys. Fail closed instead.
61
+ """
62
+ c2s = negotiate(ours, peer_c2s)
63
+ s2c = negotiate(ours, peer_s2c)
64
+ if c2s != s2c:
65
+ raise ValueError(f"asymmetric {what}: c2s={c2s} s2c={s2c}")
66
+ return c2s
67
+
68
+
69
+ def peer_wants_strict(peer_kex: list[str], *, is_client: bool) -> bool:
70
+ """Whether the peer advertises strict-kex (clients need -s, servers -c)."""
71
+ want = KEX_STRICT_S if is_client else KEX_STRICT_C
72
+ return want in peer_kex
73
+
74
+
75
+ def peer_wants_ext_info(peer_kex: list[str]) -> bool:
76
+ return EXT_INFO_C in peer_kex
77
+
78
+
79
+ def enc_kexinit(cookie: bytes, kex: list[str], hostkey: list[str], enc_c2s: list[str], enc_s2c: list[str], mac_c2s: list[str], mac_s2c: list[str], comp_c2s: list[str], comp_s2c: list[str], first_follows: bool = False) -> bytes:
80
+ """Full KEXINIT packet payload, message number included."""
81
+ if len(cookie) != 16:
82
+ raise ValueError("cookie must be 16 bytes")
83
+ out = bytes([MSG_KEXINIT]) + cookie
84
+ out += W.enc_namelist(kex) + W.enc_namelist(hostkey)
85
+ out += W.enc_namelist(enc_c2s) + W.enc_namelist(enc_s2c)
86
+ out += W.enc_namelist(mac_c2s) + W.enc_namelist(mac_s2c)
87
+ out += W.enc_namelist(comp_c2s) + W.enc_namelist(comp_s2c)
88
+ out += W.enc_namelist(LANG_OFFER) + W.enc_namelist(LANG_OFFER)
89
+ out += W.enc_bool(first_follows) + W.enc_u32(0)
90
+ return out
91
+
92
+
93
+ def dec_kexinit(payload: bytes) -> dict:
94
+ r = W.Reader(payload)
95
+ if r.byte() != MSG_KEXINIT:
96
+ raise W.WireError("expected SSH_MSG_KEXINIT")
97
+ cookie = r.raw(16)
98
+ out = {
99
+ "cookie": cookie,
100
+ "kex": r.namelist(), "hostkey": r.namelist(),
101
+ "enc_c2s": r.namelist(), "enc_s2c": r.namelist(),
102
+ "mac_c2s": r.namelist(), "mac_s2c": r.namelist(),
103
+ "comp_c2s": r.namelist(), "comp_s2c": r.namelist(),
104
+ "lang_c2s": r.namelist(), "lang_s2c": r.namelist(),
105
+ "first_follows": r.bool(), "_reserved": r.u32(),
106
+ "payload_full": payload,
107
+ }
108
+ if out["_reserved"] != 0:
109
+ raise W.WireError("KEXINIT reserved must be zero")
110
+ r.assert_end()
111
+ return out
112
+
113
+
114
+ """
115
+ I_C/I_S for H is the whole SSH_MSG_KEXINIT content INCLUDING the
116
+ message-number byte (how OpenSSH/Dropbear/PuTTY hash it; verified live
117
+ against OpenSSH: without the first byte the server signature fails).
118
+ """
119
+ I_FOR_H = lambda full_payload: full_payload
120
+
121
+
122
+ # curve25519-sha256
123
+ # Low-order set lives in mlkem (imported by every hybrid path); keep the
124
+ # historic names here so existing callers keep working.
125
+ _LOW_ORDER_X25519 = mlkem.X25519_LOW_ORDER
126
+
127
+
128
+ def _reject_low_order_x25519(q: bytes) -> None:
129
+ mlkem.reject_low_order_x25519(q)
130
+
131
+
132
+ class Curve25519Client:
133
+ def __init__(self):
134
+ self.sk = X25519PrivateKey.generate()
135
+ self.qc = self.sk.public_key().public_bytes_raw()
136
+
137
+ def init_msg(self) -> bytes:
138
+ return bytes([MSG_KEX_ECDH_INIT]) + W.enc_string(self.qc)
139
+
140
+ def finish(self, reply: bytes, Vc: bytes, Vs: bytes, Ic_H: bytes, Is_H: bytes, host_alg: str = "ssh-ed25519") -> tuple[bytes, bytes, bytes]:
141
+ """Returns (K_raw, H, K_S). Raises ValueError on bad length/signature."""
142
+ r = W.Reader(reply)
143
+ if r.byte() != MSG_KEX_ECDH_REPLY:
144
+ raise W.WireError("expected SSH_MSG_KEX_ECDH_REPLY")
145
+ Ks = r.string()
146
+ Qs = r.string()
147
+ sig = r.string()
148
+ r.assert_end()
149
+ if len(Qs) != 32 or len(self.qc) != 32:
150
+ raise ValueError("Qc/Qs must be 32 bytes")
151
+ _reject_low_order_x25519(Qs)
152
+ K_ecdh = self.sk.exchange(X25519PublicKey.from_public_bytes(Qs))
153
+ mlkem.check_secret_nonzero(K_ecdh)
154
+ K_mpint = W.mpint_from_bytes(K_ecdh)
155
+ Hh = hashlib.sha256(W.enc_string(Vc) + W.enc_string(Vs) + W.enc_string(Ic_H) + W.enc_string(Is_H) + W.enc_string(Ks) + W.enc_string(self.qc) + W.enc_string(Qs) + K_mpint).digest()
156
+ if not hostkeys.verify_host_signature(Ks, host_alg, Hh, sig):
157
+ raise ValueError("host signature mismatch (curve25519)")
158
+ return K_ecdh, Hh, Ks
159
+
160
+
161
+ class Curve25519ServerStub:
162
+ """Test-only loopback peer (never production)."""
163
+
164
+ def __init__(self, host_priv):
165
+ self.host_priv = host_priv
166
+ self.sk = X25519PrivateKey.generate()
167
+ self.qs = self.sk.public_key().public_bytes_raw()
168
+
169
+ def reply(self, init_msg: bytes, Vc: bytes, Vs: bytes, Ic_H: bytes, Is_H: bytes) -> tuple[bytes, bytes, bytes]:
170
+ r = W.Reader(init_msg)
171
+ if r.byte() != MSG_KEX_ECDH_INIT:
172
+ raise W.WireError("expected SSH_MSG_KEX_ECDH_INIT")
173
+ Qc = r.string()
174
+ K_ecdh = self.sk.exchange(X25519PublicKey.from_public_bytes(Qc))
175
+ Ks = mlkem.hostkey_blob_from_priv(self.host_priv)
176
+ K_mpint = W.mpint_from_bytes(K_ecdh)
177
+ Hh = hashlib.sha256(W.enc_string(Vc) + W.enc_string(Vs) + W.enc_string(Ic_H) + W.enc_string(Is_H) + W.enc_string(Ks) + W.enc_string(Qc) + W.enc_string(self.qs) + K_mpint).digest()
178
+ sig = mlkem.sign_exchange_hash(self.host_priv, Hh)
179
+ msg = bytes([MSG_KEX_ECDH_REPLY]) + W.enc_string(Ks) + W.enc_string(self.qs) + W.enc_string(sig)
180
+ return msg, K_ecdh, Hh
181
+
182
+
183
+ # mlkem768x25519-sha256 (wrapper over ccssh.mlkem)
184
+ class MlkemHybridClient:
185
+ def __init__(self, Vc: bytes, Ic_H: bytes):
186
+ st = mlkem.client_create(Vc, Ic_H)
187
+ self._st = st
188
+
189
+ @property
190
+ def c_init(self) -> bytes:
191
+ return self._st.C_INIT
192
+
193
+ def init_msg(self) -> bytes:
194
+ return bytes([MSG_KEX_ECDH_INIT]) + W.enc_string(self.c_init)
195
+
196
+ def finish(self, reply: bytes, Vs: bytes, Is_H: bytes, host_alg: str = "ssh-ed25519") -> tuple[bytes, bytes, bytes, bytes, bytes]:
197
+ """(K_raw for H/KDF, H, K_S, K_pq, K_ecdh)."""
198
+ r = W.Reader(reply)
199
+ if r.byte() != MSG_KEX_ECDH_REPLY:
200
+ raise W.WireError("expected SSH_MSG_KEX_ECDH_REPLY")
201
+ Ks = r.string()
202
+ S_reply = r.string()
203
+ sig = r.string()
204
+ r.assert_end()
205
+ sess = mlkem.client_process_reply(self._st, V_S=Vs, I_S=Is_H, K_S=Ks, S_REPLY=S_reply, sig_inner=sig, host_alg=host_alg)
206
+ return sess.K, sess.H, Ks, sess.K_pq, sess.K_ecdh
207
+
208
+
209
+ class MlkemHybridServerStub:
210
+ def __init__(self, host_priv, Vs: bytes, Is_H: bytes):
211
+ self.host_priv = host_priv
212
+ self.Vs = Vs
213
+ self.Is_H = Is_H
214
+
215
+ def reply(self, init_msg: bytes, Vc: bytes, Ic_H: bytes) -> tuple[bytes, bytes, bytes]:
216
+ r = W.Reader(init_msg)
217
+ if r.byte() != MSG_KEX_ECDH_INIT:
218
+ raise W.WireError("expected SSH_MSG_KEX_ECDH_INIT")
219
+ C_init = r.string()
220
+ S_reply, Ks, sig, sess = mlkem.server_process_init( host_priv=self.host_priv, V_C=Vc, V_S=self.Vs, I_C=Ic_H, I_S=self.Is_H, C_INIT=C_init)
221
+ msg = bytes([MSG_KEX_ECDH_REPLY]) + W.enc_string(Ks) + W.enc_string(S_reply) + W.enc_string(sig)
222
+ return msg, sess.K, sess.H
ccssh/kex_ext.py ADDED
@@ -0,0 +1,429 @@
1
+ """
2
+ Extra key exchange methods: ecdsa-sha2-nistp*, diffie-hellman-group*-sha*,
3
+ diffie-hellman-group-exchange-sha256, mlkem768nistp256-sha256, sntrup761
4
+ framing.
5
+
6
+ Exchange-hash formulas follow RFC 5656 (ECDH), RFC 4253 section 8 (DH/GEX),
7
+ and the hybrid drafts (mlkem+p256, sntrup+x25519). ServerStub classes are
8
+ test-only peers.
9
+ """
10
+ from __future__ import annotations
11
+ import hashlib
12
+ import os
13
+
14
+ from cryptography.hazmat.primitives.asymmetric import ec
15
+
16
+ from ccssh import hostkeys
17
+ from ccssh import mlkem
18
+ from ccssh import wire as W
19
+ from ccssh.dh_groups import P1_1024, P14_2048, P16_4096, P18_8192, G as DH_G
20
+ from ccssh.kex import MSG_KEX_ECDH_INIT, MSG_KEX_ECDH_REPLY
21
+
22
+ CURVES = {
23
+ "ecdh-sha2-nistp256": (ec.SECP256R1(), hashlib.sha256),
24
+ "ecdh-sha2-nistp384": (ec.SECP384R1(), hashlib.sha384),
25
+ "ecdh-sha2-nistp521": (ec.SECP521R1(), hashlib.sha512),
26
+ }
27
+ DH_GROUPS = {
28
+ "diffie-hellman-group14-sha256": (P14_2048, hashlib.sha256),
29
+ "diffie-hellman-group14-sha1": (P14_2048, hashlib.sha1),
30
+ "diffie-hellman-group16-sha512": (P16_4096, hashlib.sha512),
31
+ "diffie-hellman-group18-sha512": (P18_8192, hashlib.sha512),
32
+ "diffie-hellman-group1-sha1": (P1_1024, hashlib.sha1),
33
+ }
34
+
35
+ P14 = P14_2048 # short alias, used by tests
36
+
37
+ # curve25519-sha256@libssh.org is wire-identical to curve25519-sha256.
38
+ KEX_ALIASES = {
39
+ "curve25519-sha256@libssh.org": "curve25519-sha256",
40
+ "sntrup761x25519-sha512@openssh.com": "sntrup761x25519-sha512"
41
+ }
42
+
43
+ KEX_HASH = {
44
+ "curve25519-sha256": hashlib.sha256,
45
+ "curve25519-sha256@libssh.org": hashlib.sha256,
46
+ "mlkem768x25519-sha256": hashlib.sha256,
47
+ "mlkem768nistp256-sha256": hashlib.sha256,
48
+ "ecdh-sha2-nistp256": hashlib.sha256,
49
+ "ecdh-sha2-nistp384": hashlib.sha384,
50
+ "ecdh-sha2-nistp521": hashlib.sha512,
51
+ "diffie-hellman-group14-sha256": hashlib.sha256,
52
+ "diffie-hellman-group14-sha1": hashlib.sha1,
53
+ "diffie-hellman-group16-sha512": hashlib.sha512,
54
+ "diffie-hellman-group18-sha512": hashlib.sha512,
55
+ "diffie-hellman-group1-sha1": hashlib.sha1,
56
+ "diffie-hellman-group-exchange-sha256": hashlib.sha256,
57
+ "diffie-hellman-group-exchange-sha1": hashlib.sha1,
58
+ "sntrup761x25519-sha512": hashlib.sha512,
59
+ }
60
+
61
+
62
+ def kdf_hashfn(kex_name: str):
63
+ """KDF hash for a KEX method (always the KEX hash, never cipher/MAC-derived)."""
64
+ h = KEX_HASH.get(KEX_ALIASES.get(kex_name, kex_name), hashlib.sha256)
65
+ return lambda d: h(d).digest()
66
+
67
+
68
+ def _point_encode(pub) -> bytes:
69
+ nums = pub.public_numbers()
70
+ size = (pub.curve.key_size + 7) // 8
71
+ return b"\x04" + nums.x.to_bytes(size, "big") + nums.y.to_bytes(size, "big")
72
+
73
+
74
+ def _point_decode(curve, data: bytes):
75
+ from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey
76
+ return EllipticCurvePublicKey.from_encoded_point(curve, data)
77
+
78
+
79
+ class EcdhClient:
80
+ def __init__(self, name: str):
81
+ curve, self.hash = CURVES[name]
82
+ self.name = name
83
+ self.curve = curve
84
+ self.sk = ec.generate_private_key(curve)
85
+ self.qc = _point_encode(self.sk.public_key())
86
+
87
+ def init_msg(self) -> bytes:
88
+ return bytes([MSG_KEX_ECDH_INIT]) + W.enc_string(self.qc)
89
+
90
+ def finish(self, reply: bytes, Vc: bytes, Vs: bytes, Ic: bytes, Is: bytes, host_alg: str = "ssh-ed25519"):
91
+ r = W.Reader(reply)
92
+ if r.byte() != MSG_KEX_ECDH_REPLY:
93
+ raise W.WireError("expected SSH_MSG_KEX_ECDH_REPLY")
94
+ Ks, Qs, sig = r.string(), r.string(), r.string()
95
+ r.assert_end()
96
+ peer = _point_decode(self.curve, Qs)
97
+ K_ecdh = self.sk.exchange(ec.ECDH(), peer) # x-coordinate octet string
98
+ mlkem.check_secret_nonzero(K_ecdh, "ECDH")
99
+ Hh = self.hash(W.enc_string(Vc) + W.enc_string(Vs) + W.enc_string(Ic) + W.enc_string(Is) + W.enc_string(Ks) + W.enc_string(self.qc) + W.enc_string(Qs) + W.mpint_from_bytes(K_ecdh)).digest()
100
+ if not hostkeys.verify_host_signature(Ks, host_alg, Hh, sig):
101
+ raise ValueError("host signature mismatch (ecdh)")
102
+ return K_ecdh, Hh, Ks
103
+
104
+
105
+ class EcdhServerStub:
106
+ def __init__(self, name: str, host_priv):
107
+ curve, self.hash = CURVES[name]
108
+ self.name, self.curve, self.host_priv = name, curve, host_priv
109
+ self.sk = ec.generate_private_key(curve)
110
+ self.qs = _point_encode(self.sk.public_key())
111
+
112
+ def reply(self, init_msg: bytes, Vc: bytes, Vs: bytes, Ic: bytes, Is: bytes):
113
+ r = W.Reader(init_msg)
114
+ if r.byte() != MSG_KEX_ECDH_INIT:
115
+ raise W.WireError("expected SSH_MSG_KEX_ECDH_INIT")
116
+ Qc = r.string()
117
+ K_ecdh = self.sk.exchange(ec.ECDH(), _point_decode(self.curve, Qc))
118
+ Ks = mlkem.hostkey_blob_from_priv(self.host_priv)
119
+ Hh = self.hash(W.enc_string(Vc) + W.enc_string(Vs) + W.enc_string(Ic) + W.enc_string(Is) + W.enc_string(Ks) + W.enc_string(Qc) + W.enc_string(self.qs) + W.mpint_from_bytes(K_ecdh)).digest()
120
+ sig = mlkem.sign_exchange_hash(self.host_priv, Hh)
121
+ msg = bytes([MSG_KEX_ECDH_REPLY]) + W.enc_string(Ks) + W.enc_string(self.qs) + W.enc_string(sig)
122
+ return msg, K_ecdh, Hh
123
+
124
+
125
+ def _is_probable_prime(n: int, rounds: int | None = None) -> bool:
126
+ """
127
+ Miller-Rabin with trial division (for validating GEX groups from
128
+ a possibly hostile server - never trust a composite modulus).
129
+
130
+ The stock RFC 3526 groups skip the test (exact match); unknown
131
+ moduli get size-scaled rounds (a full 8-round test of an 8192-bit
132
+ unknown group would stall every handshake by ~15s).
133
+ """
134
+ if n in (P14_2048, P16_4096, P18_8192):
135
+ return True
136
+ if rounds is None:
137
+ bits = n.bit_length()
138
+ rounds = 12 if bits <= 2048 else (6 if bits <= 4096 else 3)
139
+ if n < 2:
140
+ return False
141
+ small = (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37)
142
+ for p in small:
143
+ if n % p == 0:
144
+ return n == p
145
+ if n % 2 == 0:
146
+ return False
147
+ d, r = n - 1, 0
148
+ while d % 2 == 0:
149
+ d //= 2
150
+ r += 1
151
+ import random as _random
152
+ rng = _random.SystemRandom()
153
+ for _ in range(rounds):
154
+ a = rng.randrange(2, n - 1)
155
+ x = pow(a, d, n)
156
+ if x in (1, n - 1):
157
+ continue
158
+ for _ in range(r - 1):
159
+ x = (x * x) % n
160
+ if x == n - 1:
161
+ break
162
+ else:
163
+ return False
164
+ return True
165
+
166
+
167
+ def _gen_dh_secret(p: int) -> int:
168
+ """
169
+ Private exponent sized to the group: 256 bits minimum (fast even
170
+ for 8192-bit groups), scaling up toward RFC 3526 guidance.
171
+ """
172
+ nbytes = max(32, min(64, (p.bit_length() + 7) // 8))
173
+ return int.from_bytes(os.urandom(nbytes), "big") | 1
174
+
175
+
176
+ def _check_dh_peer(value: int, p: int, what: str = "peer DH public value") -> None:
177
+ if not 2 <= value <= p - 2:
178
+ raise ValueError(f"{what} out of range")
179
+ if pow(value, (p - 1) // 2, p) != 1:
180
+ raise ValueError(f"{what} not in subgroup")
181
+
182
+
183
+ class DhClient:
184
+ """Generic MODP DH: e=g^x, H=HASH(Vc||Vs||Ic||Is||Ks||e||f||K)."""
185
+
186
+ def __init__(self, name: str):
187
+ p, hf = DH_GROUPS[name]
188
+ self.name, self.p, self.hash = name, p, hf
189
+ self.x = _gen_dh_secret(p)
190
+ self.e = pow(DH_G, self.x, self.p)
191
+
192
+ def init_msg(self) -> bytes:
193
+ return bytes([30]) + W.enc_mpint(self.e) # SSH_MSG_KEXDH_INIT
194
+
195
+ def finish(self, reply: bytes, Vc: bytes, Vs: bytes, Ic: bytes, Is: bytes, host_alg: str = "ssh-ed25519"):
196
+ r = W.Reader(reply)
197
+ if r.byte() != 31:
198
+ raise W.WireError("expected SSH_MSG_KEXDH_REPLY")
199
+ Ks, f, sig = r.string(), r.mpint(), r.string()
200
+ r.assert_end()
201
+ _check_dh_peer(f, self.p)
202
+ K_int = pow(f, self.x, self.p)
203
+ K_raw = K_int.to_bytes((K_int.bit_length() + 7) // 8 or 1, "big")
204
+ Hh = self.hash(W.enc_string(Vc) + W.enc_string(Vs) + W.enc_string(Ic) + W.enc_string(Is) + W.enc_string(Ks) + W.enc_mpint(self.e) + W.enc_mpint(f) + W.mpint_from_bytes(K_raw)).digest()
205
+ if not hostkeys.verify_host_signature(Ks, host_alg, Hh, sig):
206
+ raise ValueError(f"host signature mismatch ({self.name})")
207
+ return K_raw, Hh, Ks
208
+
209
+
210
+ class DhServerStub:
211
+ def __init__(self, name: str, host_priv):
212
+ p, hf = DH_GROUPS[name]
213
+ self.name, self.p, self.hash, self.host_priv = name, p, hf, host_priv
214
+ self.y = _gen_dh_secret(p)
215
+ self.f = pow(DH_G, self.y, self.p)
216
+
217
+ def reply(self, init_msg: bytes, Vc: bytes, Vs: bytes, Ic: bytes, Is: bytes):
218
+ r = W.Reader(init_msg)
219
+ if r.byte() != 30:
220
+ raise W.WireError("expected SSH_MSG_KEXDH_INIT")
221
+ e = r.mpint()
222
+ r.assert_end()
223
+ _check_dh_peer(e, self.p)
224
+ K_int = pow(e, self.y, self.p)
225
+ K_raw = K_int.to_bytes((K_int.bit_length() + 7) // 8 or 1, "big")
226
+ Ks = mlkem.hostkey_blob_from_priv(self.host_priv)
227
+ Hh = self.hash(W.enc_string(Vc) + W.enc_string(Vs) + W.enc_string(Ic) + W.enc_string(Is) + W.enc_string(Ks) + W.enc_mpint(e) + W.enc_mpint(self.f) + W.mpint_from_bytes(K_raw)).digest()
228
+ sig = mlkem.sign_exchange_hash(self.host_priv, Hh)
229
+ msg = bytes([31]) + W.enc_string(Ks) + W.enc_mpint(self.f) + W.enc_string(sig)
230
+ return msg, K_raw, Hh
231
+
232
+
233
+ class Dh14Client(DhClient):
234
+ def __init__(self):
235
+ super().__init__("diffie-hellman-group14-sha256")
236
+
237
+
238
+ class Dh14ServerStub(DhServerStub):
239
+ def __init__(self, host_priv):
240
+ super().__init__("diffie-hellman-group14-sha256", host_priv)
241
+
242
+
243
+ class MlkemP256Client:
244
+ """mlkem768nistp256-sha256: ek||Qc_p256 -> ct||Qs_p256, K=SHA256(Kpq||x)."""
245
+
246
+ def __init__(self, Vc: bytes, Ic: bytes):
247
+ st = mlkem.client_create(Vc, Ic)
248
+ self._st = st
249
+ self._ec = ec.generate_private_key(ec.SECP256R1())
250
+ self.qc = _point_encode(self._ec.public_key())
251
+ self.c_init = st.mlkem_ek + self.qc # 1184+65=1249
252
+
253
+ def init_msg(self) -> bytes:
254
+ return bytes([30]) + W.enc_string(self.c_init)
255
+
256
+ def finish(self, reply: bytes, Vs: bytes, Is: bytes, host_alg: str = "ssh-ed25519"):
257
+ r = W.Reader(reply)
258
+ if r.byte() != 31:
259
+ raise W.WireError("expected SSH_MSG_KEX_HYBRID_REPLY")
260
+ Ks, S_reply, sig = r.string(), r.string(), r.string()
261
+ r.assert_end()
262
+ if len(S_reply) != 1088 + 65:
263
+ raise ValueError(f"bad hybrid reply length {len(S_reply)}")
264
+ ct, Qs = S_reply[:1088], S_reply[1088:]
265
+ Kpq = self._st.mlkem_sk.decapsulate(ct)
266
+ Kec = self._ec.exchange(ec.ECDH(), _point_decode(ec.SECP256R1(), Qs))
267
+ mlkem.check_secret_nonzero(Kec, "ECDH")
268
+ K_raw = hashlib.sha256(Kpq + Kec).digest()
269
+ Hh = hashlib.sha256(W.enc_string(self._st.V_C) + W.enc_string(Vs) + W.enc_string(self._st.I_C) + W.enc_string(Is) + W.enc_string(Ks) + W.enc_string(self.c_init) + W.enc_string(S_reply) + W.enc_string(K_raw)).digest()
270
+ if not hostkeys.verify_host_signature(Ks, host_alg, Hh, sig):
271
+ raise ValueError("host signature mismatch (mlkem+p256)")
272
+ return K_raw, Hh, Ks
273
+
274
+
275
+ # diffie-hellman-group-exchange-sha256 (RFC 4253 section 8)
276
+ GEX_MIN, GEX_N, GEX_MAX = 2048, 3072, 8192
277
+ GEX_GROUPS = [(P14_2048, 2048), (P16_4096, 4096), (P18_8192, 8192)]
278
+
279
+
280
+ class GexClient:
281
+ """
282
+ Two round trips: REQUEST(min/n/max) -> GROUP(p, g) -> INIT(e) ->
283
+ REPLY(Ks, f, sig). H binds the range as uint32 trio, p/g/e/f/K as
284
+ mpints (RFC 4253 section 8, exactly - live-verified against OpenSSH).
285
+ """
286
+
287
+ def __init__(self, min_bits: int = GEX_MIN, n_bits: int = GEX_N, max_bits: int = GEX_MAX, hashfn=None):
288
+ if not 1024 <= min_bits <= n_bits <= max_bits <= 8192:
289
+ raise ValueError("bad GEX range (max 8192 to bound CPU)")
290
+ self.min, self.n, self.max = min_bits, n_bits, max_bits
291
+ self.hash = hashfn or hashlib.sha256
292
+ self.x = None
293
+ self.p = 0
294
+ self.g = DH_G
295
+ self.e = 0
296
+
297
+ def request_msg(self) -> bytes:
298
+ return bytes([34]) + W.enc_u32(self.min) + W.enc_u32(self.n) + W.enc_u32(self.max)
299
+
300
+ def init_msg(self, group: bytes) -> bytes:
301
+ r = W.Reader(group)
302
+ if r.byte() != 31:
303
+ raise W.WireError("expected SSH_MSG_KEX_DH_GEX_GROUP")
304
+ p, g = r.mpint(), r.mpint()
305
+ r.assert_end()
306
+ if not 2 <= g <= p - 2:
307
+ raise ValueError("server group generator out of range")
308
+ if not self.min <= p.bit_length() <= self.max:
309
+ raise ValueError("server group outside requested range")
310
+ # A rogue server offering a smooth/composite modulus would expose
311
+ # the private exponent: demand (probable) primality first.
312
+ if not _is_probable_prime(p):
313
+ raise ValueError("server group modulus is not prime")
314
+ self.p = p
315
+ self.g = g
316
+ self.x = _gen_dh_secret(p)
317
+ self.e = pow(g, self.x, p)
318
+ return bytes([32]) + W.enc_mpint(self.e)
319
+
320
+ def finish(self, reply: bytes, Vc: bytes, Vs: bytes, Ic: bytes, Is: bytes, host_alg: str = "ssh-ed25519"):
321
+ r = W.Reader(reply)
322
+ if r.byte() != 33:
323
+ raise W.WireError("expected SSH_MSG_KEX_DH_GEX_REPLY")
324
+ Ks, f, sig = r.string(), r.mpint(), r.string()
325
+ r.assert_end()
326
+ if self.p == 0 or self.x is None:
327
+ raise ValueError("GEX group not negotiated (call init_msg first)")
328
+ _check_dh_peer(f, self.p)
329
+ K_int = pow(f, self.x, self.p)
330
+ K_raw = K_int.to_bytes((K_int.bit_length() + 7) // 8 or 1, "big")
331
+ Hh = self.hash(W.enc_string(Vc) + W.enc_string(Vs) + W.enc_string(Ic) + W.enc_string(Is) + W.enc_string(Ks) + W.enc_u32(self.min) + W.enc_u32(self.n) + W.enc_u32(self.max) + W.enc_mpint(self.p) + W.enc_mpint(self.g) + W.enc_mpint(self.e) + W.enc_mpint(f) + W.mpint_from_bytes(K_raw)).digest()
332
+ if not hostkeys.verify_host_signature(Ks, host_alg, Hh, sig):
333
+ raise ValueError("host signature mismatch (gex)")
334
+ return K_raw, Hh, Ks
335
+
336
+
337
+ class GexServerStub:
338
+ """
339
+ Test-only loopback peer: serves the largest known group in range.
340
+ """
341
+
342
+ def __init__(self, host_priv, hashfn=None):
343
+ self.hash = hashfn or hashlib.sha256
344
+ self.host_priv = host_priv
345
+ self.mn = self.n = self.mx = 0
346
+ self.p = 0
347
+ self.y = 0
348
+ self.f = 0
349
+
350
+ def reply_request(self, req: bytes) -> bytes:
351
+ r = W.Reader(req)
352
+ if r.byte() != 34:
353
+ raise W.WireError("expected SSH_MSG_KEX_DH_GEX_REQUEST")
354
+ self.mn, self.n, self.mx = r.u32(), r.u32(), r.u32()
355
+ r.assert_end()
356
+ if not 1024 <= self.mn <= self.n <= self.mx <= 8192:
357
+ raise ValueError("bad GEX range")
358
+ pick = [(p, b) for p, b in GEX_GROUPS if self.mn <= b <= self.mx]
359
+ if not pick:
360
+ raise ValueError("no group in range")
361
+ self.p, _ = pick[-1]
362
+ self.y = _gen_dh_secret(self.p)
363
+ self.f = pow(DH_G, self.y, self.p)
364
+ return bytes([31]) + W.enc_mpint(self.p) + W.enc_mpint(DH_G)
365
+
366
+ def reply_init(self, init: bytes, Vc: bytes, Vs: bytes, Ic: bytes, Is: bytes):
367
+ r = W.Reader(init)
368
+ if r.byte() != 32:
369
+ raise W.WireError("expected SSH_MSG_KEX_DH_GEX_INIT")
370
+ e = r.mpint()
371
+ r.assert_end()
372
+ _check_dh_peer(e, self.p)
373
+ K_int = pow(e, self.y, self.p)
374
+ K_raw = K_int.to_bytes((K_int.bit_length() + 7) // 8 or 1, "big")
375
+ Ks = mlkem.hostkey_blob_from_priv(self.host_priv)
376
+ Hh = self.hash(W.enc_string(Vc) + W.enc_string(Vs) + W.enc_string(Ic) + W.enc_string(Is) + W.enc_string(Ks) + W.enc_u32(self.mn) + W.enc_u32(self.n) + W.enc_u32(self.mx) + W.enc_mpint(self.p) + W.enc_mpint(DH_G) + W.enc_mpint(e) + W.enc_mpint(self.f) + W.mpint_from_bytes(K_raw)).digest()
377
+ sig = mlkem.sign_exchange_hash(self.host_priv, Hh)
378
+ msg = bytes([33]) + W.enc_string(Ks) + W.enc_mpint(self.f) + W.enc_string(sig)
379
+ return msg, K_raw, Hh
380
+
381
+
382
+ class SntrupClient:
383
+ """
384
+ sntrup761x25519-sha512 (and the @openssh.com alias): ek||Qc -> ct||Qs,
385
+ K=SHA512(Kpq||Kecdh). Needs a liboqs backend at runtime; tests pass
386
+ FakeBackend explicitly, otherwise load_backend() raises BackendMissing.
387
+ """
388
+
389
+ def __init__(self, Vc: bytes, Ic: bytes, backend=None):
390
+ from ccssh import sntrup as SN
391
+ self._st = SN.client_create(Vc, Ic, backend)
392
+
393
+ def init_msg(self) -> bytes:
394
+ return bytes([MSG_KEX_ECDH_INIT]) + W.enc_string(self._st.C_INIT)
395
+
396
+ def finish(self, reply: bytes, Vs: bytes, Is: bytes,
397
+ host_alg: str = "ssh-ed25519"):
398
+ from ccssh import sntrup as SN
399
+ r = W.Reader(reply)
400
+ if r.byte() != MSG_KEX_ECDH_REPLY:
401
+ raise W.WireError("expected SSH_MSG_KEX_HYBRID_REPLY")
402
+ Ks, S_reply, sig = r.string(), r.string(), r.string()
403
+ r.assert_end()
404
+ sess = SN.client_process_reply(self._st, V_S=Vs, I_S=Is, K_S=Ks, S_REPLY=S_reply, sig_inner=sig, host_alg=host_alg)
405
+ return sess.K, sess.H, Ks, sess.K_pq, sess.K_ecdh
406
+
407
+
408
+ class SntrupServerStub:
409
+ """
410
+ Test-only loopback peer (never production).
411
+ """
412
+
413
+ def __init__(self, host_priv, Vs: bytes, Is: bytes, backend=None):
414
+ from ccssh import sntrup as SN
415
+ be = backend if backend is not None else SN.FakeBackend()
416
+ self._be = be
417
+ self.host_priv = host_priv
418
+ self.Vs = Vs
419
+ self.Is_H = Is
420
+
421
+ def reply(self, init_msg: bytes, Vc: bytes, Ic_H: bytes):
422
+ from ccssh import sntrup as SN
423
+ r = W.Reader(init_msg)
424
+ if r.byte() != MSG_KEX_ECDH_INIT:
425
+ raise W.WireError("expected SSH_MSG_KEX_HYBRID_INIT")
426
+ C_init = r.string()
427
+ S_reply, Ks, sig, sess = SN.server_process_init(host_priv=self.host_priv, V_C=Vc, V_S=self.Vs, I_C=Ic_H, I_S=self.Is_H, C_INIT=C_init, backend=self._be)
428
+ msg = bytes([MSG_KEX_ECDH_REPLY]) + W.enc_string(Ks) + W.enc_string(S_reply) + W.enc_string(sig)
429
+ return msg, sess.K, sess.H