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/sntrup.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sntrup761x25519-sha512 hybrid KEX with a pluggable backend.
|
|
3
|
+
|
|
4
|
+
NTRU Prime is not in `cryptography`, and pqcrypto does not build on this
|
|
5
|
+
platform, so the primitive comes from optional liboqs (`pip install oqs`
|
|
6
|
+
plus system liboqs). Framing/negotiation are ours and are tested with the
|
|
7
|
+
fake backend below. Without a backend, sntrup is excluded from offers and
|
|
8
|
+
forcing it raises a clear error (see load_backend).
|
|
9
|
+
Sizes: ek 1158 B, ct 1039 B, C_INIT 1190 B, S_REPLY 1071 B, K = SHA512,
|
|
10
|
+
string-encoded like the mlkem hybrid.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
import hashlib
|
|
14
|
+
import os
|
|
15
|
+
import threading
|
|
16
|
+
|
|
17
|
+
SNTRUP_EK_LEN = 1158
|
|
18
|
+
SNTRUP_CT_LEN = 1039
|
|
19
|
+
SNTRUP_SS_LEN = 32
|
|
20
|
+
X_PUB_LEN = 32
|
|
21
|
+
C_INIT_LEN = SNTRUP_EK_LEN + X_PUB_LEN
|
|
22
|
+
S_REPLY_LEN = SNTRUP_CT_LEN + X_PUB_LEN
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BackendMissing(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_liboqs = None
|
|
30
|
+
_LIBOQS_LOCK = threading.Lock()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_liboqs():
|
|
34
|
+
"""
|
|
35
|
+
liboqs shared library via ctypes (no Python dependency). Cached;
|
|
36
|
+
raises BackendMissing with install hints when absent.
|
|
37
|
+
"""
|
|
38
|
+
global _liboqs
|
|
39
|
+
if _liboqs is not None:
|
|
40
|
+
return _liboqs
|
|
41
|
+
with _LIBOQS_LOCK:
|
|
42
|
+
if _liboqs is not None:
|
|
43
|
+
return _liboqs
|
|
44
|
+
return _load_liboqs_locked()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load_liboqs_locked():
|
|
48
|
+
global _liboqs
|
|
49
|
+
import ctypes
|
|
50
|
+
import ctypes.util
|
|
51
|
+
cands = []
|
|
52
|
+
try:
|
|
53
|
+
found = ctypes.util.find_library("oqs")
|
|
54
|
+
if found:
|
|
55
|
+
cands.append(found)
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
cands.append("liboqs.so") # loader default paths (covers Termux $PREFIX/lib)
|
|
59
|
+
last: Exception | None = None
|
|
60
|
+
for cand in cands:
|
|
61
|
+
try:
|
|
62
|
+
lib = ctypes.CDLL(cand)
|
|
63
|
+
lib.OQS_KEM_new.argtypes = [ctypes.c_char_p]
|
|
64
|
+
lib.OQS_KEM_new.restype = ctypes.c_void_p
|
|
65
|
+
lib.OQS_KEM_free.argtypes = [ctypes.c_void_p]
|
|
66
|
+
lib.OQS_KEM_free.restype = None
|
|
67
|
+
lib.OQS_KEM_keypair.argtypes = [ctypes.c_void_p] * 3
|
|
68
|
+
lib.OQS_KEM_keypair.restype = ctypes.c_int
|
|
69
|
+
lib.OQS_KEM_encaps.argtypes = [ctypes.c_void_p] * 4
|
|
70
|
+
lib.OQS_KEM_encaps.restype = ctypes.c_int
|
|
71
|
+
lib.OQS_KEM_decaps.argtypes = [ctypes.c_void_p] * 4
|
|
72
|
+
lib.OQS_KEM_decaps.restype = ctypes.c_int
|
|
73
|
+
kem_probe = lib.OQS_KEM_new(b"sntrup761")
|
|
74
|
+
if not kem_probe:
|
|
75
|
+
raise BackendMissing("liboqs has no sntrup761 support")
|
|
76
|
+
lib.OQS_KEM_free(kem_probe) # probe only: must not leak the context
|
|
77
|
+
_liboqs = lib
|
|
78
|
+
return lib
|
|
79
|
+
except BackendMissing:
|
|
80
|
+
raise
|
|
81
|
+
except Exception as e:
|
|
82
|
+
last = e
|
|
83
|
+
raise BackendMissing(
|
|
84
|
+
"sntrup761 needs liboqs (open-quantum-safe/liboqs) installed where "
|
|
85
|
+
f"the loader finds it; last error: {last}. Without it, ccssh "
|
|
86
|
+
"negotiates mlkem/curve instead.") from last
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _kem_lengths(lib):
|
|
90
|
+
"""
|
|
91
|
+
Read sntrup761 size parameters from the OQS_KEM struct itself instead
|
|
92
|
+
of hardcoding the secret-key length. Layout (stable since 0.8):
|
|
93
|
+
char* name, char* version, u8 level, bool cca, then size_t
|
|
94
|
+
public/secret/ciphertext/shared-secret lengths.
|
|
95
|
+
"""
|
|
96
|
+
import ctypes
|
|
97
|
+
|
|
98
|
+
class Kem(ctypes.Structure):
|
|
99
|
+
_fields_ = [("method_name", ctypes.c_void_p),
|
|
100
|
+
("alg_version", ctypes.c_void_p),
|
|
101
|
+
("claimed_nist_level", ctypes.c_uint8),
|
|
102
|
+
("ind_cca", ctypes.c_bool),
|
|
103
|
+
("length_public_key", ctypes.c_size_t),
|
|
104
|
+
("length_secret_key", ctypes.c_size_t),
|
|
105
|
+
("length_ciphertext", ctypes.c_size_t),
|
|
106
|
+
("length_shared_secret", ctypes.c_size_t)]
|
|
107
|
+
|
|
108
|
+
kem = lib.OQS_KEM_new(b"sntrup761")
|
|
109
|
+
if not kem:
|
|
110
|
+
raise BackendMissing("liboqs has no sntrup761 support")
|
|
111
|
+
try:
|
|
112
|
+
k = Kem.from_address(kem)
|
|
113
|
+
out = (k.length_public_key, k.length_secret_key,
|
|
114
|
+
k.length_ciphertext, k.length_shared_secret)
|
|
115
|
+
finally:
|
|
116
|
+
lib.OQS_KEM_free(kem)
|
|
117
|
+
if out[0] != SNTRUP_EK_LEN or out[2] != SNTRUP_CT_LEN or out[3] != SNTRUP_SS_LEN:
|
|
118
|
+
raise BackendMissing(f"unexpected sntrup761 parameters {out}")
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class LibOQSBackend:
|
|
123
|
+
"""
|
|
124
|
+
Real sntrup761 via the liboqs C API. Each operation opens and frees
|
|
125
|
+
its own KEM context (a handshake does a handful - negligible cost).
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self):
|
|
129
|
+
lib = _load_liboqs()
|
|
130
|
+
_ek, sk_len, _ct, _ss = _kem_lengths(lib)
|
|
131
|
+
self._lib = lib
|
|
132
|
+
self._sk_len = sk_len
|
|
133
|
+
|
|
134
|
+
def _new(self):
|
|
135
|
+
kem = self._lib.OQS_KEM_new(b"sntrup761")
|
|
136
|
+
if not kem:
|
|
137
|
+
raise BackendMissing("liboqs lost sntrup761 support")
|
|
138
|
+
return kem
|
|
139
|
+
|
|
140
|
+
def generate(self):
|
|
141
|
+
import ctypes
|
|
142
|
+
lib = self._lib
|
|
143
|
+
kem = self._new()
|
|
144
|
+
try:
|
|
145
|
+
ek = ctypes.create_string_buffer(SNTRUP_EK_LEN)
|
|
146
|
+
sk = ctypes.create_string_buffer(self._sk_len)
|
|
147
|
+
if lib.OQS_KEM_keypair(kem, ek, sk) != 0:
|
|
148
|
+
raise BackendMissing("sntrup761 keypair failed")
|
|
149
|
+
return bytes(ek), bytes(sk)
|
|
150
|
+
finally:
|
|
151
|
+
lib.OQS_KEM_free(kem)
|
|
152
|
+
|
|
153
|
+
def encap(self, ek: bytes):
|
|
154
|
+
import ctypes
|
|
155
|
+
if len(ek) != SNTRUP_EK_LEN:
|
|
156
|
+
raise ValueError(f"sntrup ek must be {SNTRUP_EK_LEN} bytes")
|
|
157
|
+
lib = self._lib
|
|
158
|
+
kem = self._new()
|
|
159
|
+
try:
|
|
160
|
+
ct = ctypes.create_string_buffer(SNTRUP_CT_LEN)
|
|
161
|
+
ss = ctypes.create_string_buffer(SNTRUP_SS_LEN)
|
|
162
|
+
if lib.OQS_KEM_encaps(kem, ct, ss, ek) != 0:
|
|
163
|
+
raise BackendMissing("sntrup761 encaps failed")
|
|
164
|
+
return bytes(ct), bytes(ss)
|
|
165
|
+
finally:
|
|
166
|
+
lib.OQS_KEM_free(kem)
|
|
167
|
+
|
|
168
|
+
def decap(self, sk: bytes, ct: bytes):
|
|
169
|
+
import ctypes
|
|
170
|
+
if len(ct) != SNTRUP_CT_LEN:
|
|
171
|
+
raise ValueError(f"sntrup ct must be {SNTRUP_CT_LEN} bytes")
|
|
172
|
+
if len(sk) != self._sk_len:
|
|
173
|
+
raise ValueError("sntrup sk has wrong length")
|
|
174
|
+
lib = self._lib
|
|
175
|
+
kem = self._new()
|
|
176
|
+
try:
|
|
177
|
+
ss = ctypes.create_string_buffer(SNTRUP_SS_LEN)
|
|
178
|
+
if lib.OQS_KEM_decaps(kem, ss, ct, sk) != 0:
|
|
179
|
+
# Real KEMs never fail decaps (implicit rejection inside).
|
|
180
|
+
ss = bytes(os.urandom(SNTRUP_SS_LEN))
|
|
181
|
+
return bytes(ss)
|
|
182
|
+
finally:
|
|
183
|
+
lib.OQS_KEM_free(kem)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def load_backend():
|
|
187
|
+
"""
|
|
188
|
+
Production backend (liboqs) or BackendMissing with install hints.
|
|
189
|
+
"""
|
|
190
|
+
be = LibOQSBackend() # probe+reuse: raises BackendMissing when unusable
|
|
191
|
+
return be
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class FakeBackend:
|
|
195
|
+
"""Stateful fake for framing tests (NOT crypto): remembers ct->ss."""
|
|
196
|
+
|
|
197
|
+
def __init__(self):
|
|
198
|
+
self._ct_to_ss: dict[bytes, bytes] = {}
|
|
199
|
+
|
|
200
|
+
def generate(self):
|
|
201
|
+
ek, sk = os.urandom(SNTRUP_EK_LEN), os.urandom(32)
|
|
202
|
+
return ek, sk
|
|
203
|
+
|
|
204
|
+
def encap(self, ek: bytes):
|
|
205
|
+
_ = ek # fake ignores its input; remembers ct->ss instead
|
|
206
|
+
ct, ss = os.urandom(SNTRUP_CT_LEN), os.urandom(SNTRUP_SS_LEN)
|
|
207
|
+
self._ct_to_ss[ct] = ss
|
|
208
|
+
return ct, ss
|
|
209
|
+
|
|
210
|
+
def decap(self, sk: bytes, ct: bytes):
|
|
211
|
+
_ = sk # fake ignores its input; looks up ct->ss instead
|
|
212
|
+
ss = self._ct_to_ss.get(ct)
|
|
213
|
+
if ss is None: # implicit rejection, like real KEMs
|
|
214
|
+
ss = os.urandom(SNTRUP_SS_LEN)
|
|
215
|
+
return ss
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def combine(Kpq: bytes, Kecdh: bytes) -> bytes:
|
|
219
|
+
if len(Kpq) != 32 or len(Kecdh) != 32:
|
|
220
|
+
raise ValueError("K_PQ and K_ECDH must be 32 bytes each")
|
|
221
|
+
return hashlib.sha512(Kpq + Kecdh).digest()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# hybrid handshake (same shape as mlkem768x25519-sha256, SHA-512)
|
|
225
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
|
|
226
|
+
|
|
227
|
+
from ccssh import mlkem as _MLKEM
|
|
228
|
+
from ccssh import wire as _W
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class ClientState:
|
|
232
|
+
"""Ephemeral client state between C_INIT and S_REPLY."""
|
|
233
|
+
|
|
234
|
+
def __init__(self, backend, kem_sk: bytes, x_sk, Vc: bytes, Ic: bytes, C_INIT: bytes):
|
|
235
|
+
self.backend = backend
|
|
236
|
+
self.kem_sk = kem_sk
|
|
237
|
+
self.x_sk = x_sk
|
|
238
|
+
self.V_C = Vc
|
|
239
|
+
self.I_C = Ic
|
|
240
|
+
self.C_INIT = C_INIT
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class Session:
|
|
244
|
+
"""Agreed session (mirrors mlkem.Session field names)."""
|
|
245
|
+
|
|
246
|
+
def __init__(self, role, K_pq, K_ecdh, K, H, session_id,
|
|
247
|
+
V_C, V_S, I_C, I_S, K_S, C_INIT, S_REPLY):
|
|
248
|
+
self.role = role
|
|
249
|
+
self.K_pq = K_pq
|
|
250
|
+
self.K_ecdh = K_ecdh
|
|
251
|
+
self.K = K
|
|
252
|
+
self.H = H
|
|
253
|
+
self.session_id = session_id
|
|
254
|
+
self.V_C = V_C
|
|
255
|
+
self.V_S = V_S
|
|
256
|
+
self.I_C = I_C
|
|
257
|
+
self.I_S = I_S
|
|
258
|
+
self.K_S = K_S
|
|
259
|
+
self.C_INIT = C_INIT
|
|
260
|
+
self.S_REPLY = S_REPLY
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def compute_exchange_hash(V_C, V_S, I_C, I_S, K_S, C_INIT, S_REPLY, K_raw) -> bytes:
|
|
264
|
+
if len(C_INIT) != C_INIT_LEN:
|
|
265
|
+
raise ValueError(f"C_INIT must be {C_INIT_LEN} bytes, got {len(C_INIT)}")
|
|
266
|
+
if len(S_REPLY) != S_REPLY_LEN:
|
|
267
|
+
raise ValueError(f"S_REPLY must be {S_REPLY_LEN} bytes, got {len(S_REPLY)}")
|
|
268
|
+
if len(K_raw) != 64:
|
|
269
|
+
raise ValueError(f"K must be 64 bytes, got {len(K_raw)}")
|
|
270
|
+
h = hashlib.sha512()
|
|
271
|
+
for part in (V_C, V_S, I_C, I_S, K_S, C_INIT, S_REPLY, K_raw):
|
|
272
|
+
h.update(_W.enc_string(part))
|
|
273
|
+
return h.digest()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def client_create(V_C: bytes, I_C: bytes, backend=None) -> ClientState:
|
|
277
|
+
be = backend if backend is not None else load_backend()
|
|
278
|
+
ek, sk = be.generate()
|
|
279
|
+
if len(ek) != SNTRUP_EK_LEN:
|
|
280
|
+
raise ValueError(f"sntrup ek must be {SNTRUP_EK_LEN} bytes")
|
|
281
|
+
x_sk = X25519PrivateKey.generate()
|
|
282
|
+
C_INIT = ek + x_sk.public_key().public_bytes_raw()
|
|
283
|
+
if len(C_INIT) != C_INIT_LEN:
|
|
284
|
+
raise ValueError(f"C_INIT must be {C_INIT_LEN} bytes")
|
|
285
|
+
return ClientState(be, sk, x_sk, V_C, I_C, C_INIT)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def client_process_reply(st: ClientState, *, V_S: bytes, I_S: bytes, K_S: bytes, S_REPLY: bytes, sig_inner: bytes, host_alg: str = "ssh-ed25519") -> Session:
|
|
289
|
+
if len(S_REPLY) != S_REPLY_LEN:
|
|
290
|
+
raise ValueError(f"S_REPLY must be {S_REPLY_LEN} bytes")
|
|
291
|
+
ct, x_pk_s = S_REPLY[:SNTRUP_CT_LEN], S_REPLY[SNTRUP_CT_LEN:]
|
|
292
|
+
K_pq = st.backend.decap(st.kem_sk, ct)
|
|
293
|
+
_MLKEM.reject_low_order_x25519(x_pk_s)
|
|
294
|
+
K_ecdh = st.x_sk.exchange(X25519PublicKey.from_public_bytes(x_pk_s))
|
|
295
|
+
_MLKEM.check_secret_nonzero(K_ecdh)
|
|
296
|
+
K_raw = combine(K_pq, K_ecdh)
|
|
297
|
+
H = compute_exchange_hash(st.V_C, V_S, st.I_C, I_S, K_S, st.C_INIT, S_REPLY, K_raw)
|
|
298
|
+
if host_alg == "ssh-ed25519":
|
|
299
|
+
host_pub = _MLKEM.parse_hostkey_blob(K_S)
|
|
300
|
+
ok = _MLKEM.verify_exchange_hash(host_pub, H, sig_inner)
|
|
301
|
+
else:
|
|
302
|
+
from ccssh import hostkeys as _HK # lazy: avoids an import cycle
|
|
303
|
+
ok = _HK.verify_host_signature(K_S, host_alg, H, sig_inner)
|
|
304
|
+
if not ok:
|
|
305
|
+
raise ValueError("exchange hash signature mismatch (MitM/damage?)")
|
|
306
|
+
return Session("client", K_pq, K_ecdh, K_raw, H, H, st.V_C, V_S, st.I_C, I_S, K_S, st.C_INIT, S_REPLY)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def server_process_init(*, host_priv, V_C: bytes, V_S: bytes, I_C: bytes, I_S: bytes, C_INIT: bytes, backend=None):
|
|
310
|
+
be = backend if backend is not None else load_backend()
|
|
311
|
+
if len(C_INIT) != C_INIT_LEN:
|
|
312
|
+
raise ValueError(f"C_INIT must be {C_INIT_LEN} bytes")
|
|
313
|
+
ek, x_pk_c = C_INIT[:SNTRUP_EK_LEN], C_INIT[SNTRUP_EK_LEN:]
|
|
314
|
+
ct, K_pq = be.encap(ek)
|
|
315
|
+
x_sk_s = X25519PrivateKey.generate()
|
|
316
|
+
x_pk_s = x_sk_s.public_key().public_bytes_raw()
|
|
317
|
+
_MLKEM.reject_low_order_x25519(x_pk_c)
|
|
318
|
+
K_ecdh = x_sk_s.exchange(X25519PublicKey.from_public_bytes(x_pk_c))
|
|
319
|
+
_MLKEM.check_secret_nonzero(K_ecdh)
|
|
320
|
+
K_raw = combine(K_pq, K_ecdh)
|
|
321
|
+
S_REPLY = ct + x_pk_s
|
|
322
|
+
Ks = _MLKEM.hostkey_blob_from_priv(host_priv)
|
|
323
|
+
H = compute_exchange_hash(V_C, V_S, I_C, I_S, Ks, C_INIT, S_REPLY, K_raw)
|
|
324
|
+
sig = _MLKEM.sign_exchange_hash(host_priv, H)
|
|
325
|
+
return S_REPLY, Ks, sig, Session("server", K_pq, K_ecdh, K_raw, H, H, V_C, V_S, I_C, I_S, Ks, C_INIT, S_REPLY)
|