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/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)