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/__init__.py +21 -0
- ccssh/__main__.py +5 -0
- ccssh/algs.py +290 -0
- ccssh/async_client.py +419 -0
- ccssh/auth.py +170 -0
- ccssh/certs.py +283 -0
- ccssh/chacha.py +117 -0
- ccssh/channel.py +124 -0
- ccssh/chansock.py +227 -0
- ccssh/cli.py +889 -0
- ccssh/client.py +1274 -0
- ccssh/crypto.py +316 -0
- ccssh/dh_groups.py +92 -0
- ccssh/forward.py +872 -0
- ccssh/gcm.py +87 -0
- ccssh/hostcheck.py +217 -0
- ccssh/hostkeys.py +250 -0
- ccssh/kex.py +222 -0
- ccssh/kex_ext.py +429 -0
- ccssh/mldsa.py +210 -0
- ccssh/mlkem.py +367 -0
- ccssh/mux.py +301 -0
- ccssh/sftp.py +270 -0
- ccssh/shell.py +257 -0
- ccssh/sk.py +224 -0
- ccssh/sntrup.py +325 -0
- ccssh/stub.py +710 -0
- ccssh/transport.py +737 -0
- ccssh/umac.py +262 -0
- ccssh/wire.py +199 -0
- ccssh-1.0.0.dist-info/METADATA +442 -0
- ccssh-1.0.0.dist-info/RECORD +36 -0
- ccssh-1.0.0.dist-info/WHEEL +5 -0
- ccssh-1.0.0.dist-info/entry_points.txt +2 -0
- ccssh-1.0.0.dist-info/licenses/LICENSE +21 -0
- ccssh-1.0.0.dist-info/top_level.txt +1 -0
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
|