DeterministicESPAsyncWebServer v6.27.1
Zero-allocation, bounded-execution async HTTP server for ESP32
Loading...
Searching...
No Matches
ssh_transport.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file ssh_transport.cpp
6 * @brief SSH transport handshake - banner exchange and KEXINIT negotiation.
7 */
8
11#include "network_drivers/presentation/ssh/crypto/ssh_curve25519.h" // ssh_x25519 (curve25519-sha256 KEX)
12#include "network_drivers/presentation/ssh/crypto/ssh_ecdsa.h" // ssh_ecdsa_p256_* (ecdsa-sha2-nistp256 host key)
13#include "network_drivers/presentation/ssh/crypto/ssh_ed25519.h" // ssh_ed25519 host-key sign
14#include "network_drivers/presentation/ssh/crypto/ssh_rsa.h" // ssh_rsa_encode_pubkey/sign, ssh_host_pubkey, SSH_RSA_*
16#include "network_drivers/presentation/ssh/transport/ssh_dh.h" // ssh_rng_fill(), ssh_dh[], ssh_dh_generate/derive_keys
17#include "network_drivers/presentation/ssh/transport/ssh_packet.h" // SSH_MSG_KEXINIT, ssh_pkt[]
18#include "services/clock.h" // detws_millis() (re-key timer)
19#if DETWS_ENABLE_PQC_KEX
20#include "network_drivers/presentation/pqc/mlkem.h" // mlkem768_encaps (PQ/T hybrid KEX responder)
21#endif
22#if DETWS_ENABLE_SSH_ZLIB
23#include "network_drivers/presentation/ssh/transport/ssh_comp.h" // s2c compression negotiation
24#endif
25#include <stdio.h> // snprintf (name-list assembly)
26#include <string.h>
27
29
30// ---------------------------------------------------------------------------
31// Negotiable algorithms. The server supports two KEX methods and two host-key
32// types; cipher / MAC / compression are fixed. Negotiation is crypto-agnostic and
33// steers toward whichever suite ssh_kex_set_prefer_rsa() selects (default: RSA/DH,
34// hardware-accelerated on ESP32), while still advertising both suites so a client
35// that supports only one still connects.
36// ---------------------------------------------------------------------------
37
38static const char *const KEX_DH = "diffie-hellman-group14-sha256";
39static const char *const KEX_C25519 = "curve25519-sha256";
40static const char *const KEX_C25519_LIBSSH = "curve25519-sha256@libssh.org"; // identical wire protocol
41static const char *const KEX_ECDH_NISTP256 = "ecdh-sha2-nistp256"; // NIST P-256 ECDH (RFC 5656 §4)
42#if DETWS_ENABLE_PQC_KEX
43static const char *const KEX_MLKEM768 = "mlkem768x25519-sha256"; // PQ/T hybrid (ML-KEM-768 + X25519)
44#endif
45static const char *const HOSTKEY_RSA_SHA256 = "rsa-sha2-256";
46static const char *const HOSTKEY_RSA_SHA512 = "rsa-sha2-512";
47static const char HOSTKEY_ED[] = "ssh-ed25519";
48static const char HOSTKEY_ECDSA[] = "ecdsa-sha2-nistp256";
49static const char *const ALG_CIPHER = "aes256-ctr";
50static const char *const ALG_CIPHER_GCM = "aes256-gcm@openssh.com";
51// Advertised cipher preference (OpenSSH's default order): chacha20-poly1305@openssh.com (AEAD)
52// first, aes256-gcm@openssh.com (AEAD, HW-accelerated) second, aes256-ctr (HW fallback) last.
53static const char *const ALG_CIPHER_LIST = "chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes256-ctr";
54static const char *const ALG_MAC = "hmac-sha2-256";
55// Advertised MAC preference (aes256-ctr only; the chacha AEAD needs none): encrypt-then-MAC first
56// (OpenSSH's default), then plain encrypt-and-MAC.
57static const char *const ALG_MAC_LIST = "hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,"
58 "hmac-sha2-256,hmac-sha2-512";
59static const char *const ALG_COMP = "none";
60#if DETWS_ENABLE_SSH_ZLIB
61// Server->client compression preference: zlib@openssh.com (delayed, OpenSSH's default) first, then
62// zlib (immediate), then none. Client->server stays "none" (ALG_COMP): see ssh_zlib.h.
63static const char *const ALG_COMP_S2C = "zlib@openssh.com,zlib,none";
64#else
65static const char *const ALG_COMP_S2C = "none";
66#endif
67// RFC 8308 indicator a client sets in its kex_algorithms to request EXT_INFO.
68static const char *const EXT_INFO_C = "ext-info-c";
69
70// All SSH transport host-key/KEX state, owned by one instance (internal linkage): the runtime
71// KEX preference (true = prefer the hardware-accelerated RSA/DH suite) and the optional
72// ssh-ed25519 host key (the RSA host key is loaded via ssh_rsa). One named owner, cross-TU
73// unreachable.
75{
76 bool prefer_rsa = true;
77 uint8_t ed_seed[32];
78 uint8_t ed_pub[32];
79 bool ed_have = false;
80 uint8_t ecdsa_priv[SSH_ECDSA_P256_PRIV_LEN]; ///< P-256 host private scalar d.
81 uint8_t ecdsa_pub[SSH_ECDSA_P256_PUB_LEN]; ///< P-256 host public point (0x04||X||Y).
82 bool ecdsa_have = false;
83};
84static SshTransportCtx s_sshtr;
85
86void ssh_kex_set_prefer_rsa(bool prefer)
87{
88 s_sshtr.prefer_rsa = prefer;
89}
91{
92 return s_sshtr.prefer_rsa;
93}
94
95void ssh_hostkey_ed25519_set(const uint8_t seed[32])
96{
97 memcpy(s_sshtr.ed_seed, seed, 32);
98 ssh_ed25519_pubkey(s_sshtr.ed_pub, s_sshtr.ed_seed);
99 s_sshtr.ed_have = true;
100}
102{
103 return s_sshtr.ed_have;
104}
105void ssh_hostkey_ecdsa_set(const uint8_t priv[SSH_ECDSA_P256_PRIV_LEN])
106{
107 // Derive and cache the public point; reject an invalid scalar (leaves ecdsa_have false).
108 if (!ssh_ecdsa_p256_pubkey(s_sshtr.ecdsa_pub, priv))
109 return;
110 memcpy(s_sshtr.ecdsa_priv, priv, SSH_ECDSA_P256_PRIV_LEN);
111 s_sshtr.ecdsa_have = true;
112}
114{
115 return s_sshtr.ecdsa_have;
116}
117static bool hostkey_rsa_available(void)
118{
119 return ssh_host_pubkey.loaded;
120}
121
122// Build the KEX / host-key advertise lists in preference order (RFC 4253 §7.1 name-
123// lists), filtering host-key types to the keys we actually hold. server-sig-algs
124// (RFC 8308) uses the same host-key ordering. Written into a caller buffer.
125static void build_kex_list(char *out, size_t cap)
126{
127 const char *c1 = KEX_C25519;
128 const char *c2 = KEX_C25519_LIBSSH;
129 const char *dh = KEX_DH;
130 const char *ec = KEX_ECDH_NISTP256; // NIST P-256 ECDH (RFC 5656)
131#if DETWS_ENABLE_PQC_KEX
132 // Post-quantum hybrid advertised first: a PQC-capable peer (OpenSSH 9.9+, which also lists it
133 // first) negotiates it over classical X25519, closing the harvest-now-decrypt-later gap.
134 const char *pq = KEX_MLKEM768;
135 if (s_sshtr.prefer_rsa)
136 snprintf(out, cap, "%s,%s,%s,%s,%s,ext-info-s", pq, dh, ec, c1, c2);
137 else
138 snprintf(out, cap, "%s,%s,%s,%s,%s,ext-info-s", pq, c1, c2, ec, dh);
139#else
140 if (s_sshtr.prefer_rsa)
141 snprintf(out, cap, "%s,%s,%s,%s,ext-info-s", dh, ec, c1, c2);
142 else
143 snprintf(out, cap, "%s,%s,%s,%s,ext-info-s", c1, c2, ec, dh);
144#endif
145}
146static void build_hostkey_list(char *out, size_t cap)
147{
148 // Both rsa-sha2-512 and rsa-sha2-256 are backed by the one "ssh-rsa" host key
149 // (RFC 8332): advertise 512 before 256 (OpenSSH's order). Filter to keys we hold.
150 const bool rsa = hostkey_rsa_available();
151 const bool ed = ssh_hostkey_ed25519_available();
152 const bool ec = ssh_hostkey_ecdsa_available();
153 struct HostkeyCand
154 {
155 const char *name;
156 bool ok;
157 };
158 HostkeyCand cand[4];
159 if (s_sshtr.prefer_rsa)
160 {
161 cand[0] = {HOSTKEY_RSA_SHA512, rsa};
162 cand[1] = {HOSTKEY_RSA_SHA256, rsa};
163 cand[2] = {HOSTKEY_ECDSA, ec};
164 cand[3] = {HOSTKEY_ED, ed};
165 }
166 else
167 {
168 cand[0] = {HOSTKEY_ED, ed};
169 cand[1] = {HOSTKEY_ECDSA, ec};
170 cand[2] = {HOSTKEY_RSA_SHA512, rsa};
171 cand[3] = {HOSTKEY_RSA_SHA256, rsa};
172 }
173 out[0] = '\0';
174 for (int k = 0; k < 4; k++)
175 {
176 if (!cand[k].ok)
177 continue;
178 size_t l = strnlen(out, cap);
179 snprintf(out + l, cap - l, "%s%s", l ? "," : "", cand[k].name);
180 }
181}
182
183// ---------------------------------------------------------------------------
184// Byte-writer helpers
185// ---------------------------------------------------------------------------
186
187namespace
188{
189struct Writer
190{
191 uint8_t *p;
192 size_t cap;
193 size_t len;
194 bool ok;
195};
196
197void w_bytes(Writer &w, const void *src, size_t n)
198{
199 if (!w.ok || w.len + n > w.cap)
200 {
201 w.ok = false;
202 return;
203 }
204 memcpy(w.p + w.len, src, n); // NOSONAR - bound proven above; analyzer follows an infeasible path
205 w.len += n;
206}
207
208void w_u8(Writer &w, uint8_t v)
209{
210 w_bytes(w, &v, 1);
211}
212
213void w_u32(Writer &w, uint32_t v)
214{
215 uint8_t b[4] = {(uint8_t)(v >> 24), (uint8_t)(v >> 16), (uint8_t)(v >> 8), (uint8_t)v};
216 w_bytes(w, b, 4);
217}
218
219// Write an SSH name-list: uint32 length + comma-separated names.
220void w_namelist(Writer &w, const char *list)
221{
222 uint32_t n = (uint32_t)strnlen(list, w.cap);
223 w_u32(w, n);
224 w_bytes(w, list, n);
225}
226
227// Write an SSH string: uint32 length + raw bytes.
228void w_string(Writer &w, const uint8_t *data, size_t n)
229{
230 w_u32(w, (uint32_t)n);
231 w_bytes(w, data, n);
232}
233
234// Write an SSH mpint from a fixed-width big-endian integer: strip leading zero
235// bytes, prepend 0x00 if the top bit is set.
236void w_mpint(Writer &w, const uint8_t *be, size_t len)
237{
238 size_t off = 0;
239 while (off < len && be[off] == 0)
240 off++;
241 if (off == len)
242 {
243 w_u32(w, 0); // zero → empty string
244 return;
245 }
246 bool pad = (be[off] & 0x80u) != 0;
247 w_u32(w, (uint32_t)(len - off) + (pad ? 1u : 0u));
248 if (pad)
249 w_u8(w, 0x00);
250 w_bytes(w, be + off, len - off);
251}
252} // namespace
253
254// ---------------------------------------------------------------------------
255// name-list membership test (RFC 4253 §7.1 - comma-separated, no spaces)
256// ---------------------------------------------------------------------------
257
258// Returns true if @p want appears as a complete element of the comma-separated
259// list [list, list+len).
260static bool namelist_contains(const uint8_t *list, uint32_t len, const char *want)
261{
262 size_t wl = strnlen(want, (size_t)len + 1);
263 uint32_t start = 0;
264 for (uint32_t i = 0; i <= len; i++)
265 {
266 if (i == len || list[i] == ',')
267 {
268 uint32_t elen = i - start;
269 if (elen == wl && memcmp(list + start, want, wl) == 0)
270 return true;
271 start = i + 1;
272 }
273 }
274 return false;
275}
276
277// One negotiation candidate: an algorithm name, the tag we store if it is chosen, and
278// whether we can actually perform it (e.g. we hold the matching host key).
279// A negotiation candidate: the wire name, the enum value it maps to, and whether we can perform it.
280// Templated on the algorithm enum so each family (SshKexAlg / SshHostkeyAlg / cipher / mac / SshCompAlg)
281// keeps its own type end to end - no type-erased int tag to cast into and back out of.
282template <typename E> struct AlgCand
283{
284 const char *name;
286 bool avail;
287};
288
289// Steer-to-preferred negotiation: pick the FIRST candidate (in OUR preference order) that the client
290// also offers and that we can perform; write it to @p out and return true, or return false if none match.
291template <typename E>
292static bool negotiate_alg(const uint8_t *client_list, uint32_t nlen, const AlgCand<E> *cands, int n, E *out)
293{
294 for (int i = 0; i < n; i++)
295 if (cands[i].avail && namelist_contains(client_list, nlen, cands[i].name))
296 {
297 *out = cands[i].tag;
298 return true;
299 }
300 return false;
301}
302
303// ---------------------------------------------------------------------------
304// Init
305// ---------------------------------------------------------------------------
306
307void ssh_transport_init(uint8_t i)
308{
309 if (i >= MAX_SSH_CONNS)
310 return;
311 SshSession *s = &ssh_sess[i];
312 memset(s, 0, sizeof(*s));
314}
315
316// ---------------------------------------------------------------------------
317// Identification string exchange (RFC 4253 §4.2)
318// ---------------------------------------------------------------------------
319
320int ssh_transport_server_banner(uint8_t *out, size_t *out_len, size_t cap)
321{
322 size_t vlen = sizeof(SSH_SERVER_VERSION) - 1;
323 if (vlen + 2 > cap)
324 return -1;
325 memcpy(out, SSH_SERVER_VERSION, vlen);
326 out[vlen] = '\r';
327 out[vlen + 1] = '\n';
328 *out_len = vlen + 2;
329 return 0;
330}
331
332int ssh_transport_recv_banner(uint8_t i, const uint8_t *data, size_t len, size_t *consumed)
333{
334 if (i >= MAX_SSH_CONNS)
335 return -1;
336 SshSession *s = &ssh_sess[i];
337
338 size_t k = 0;
339 while (k < len)
340 {
341 uint8_t c = data[k++];
342 if (c == '\n')
343 {
344 // End of a line. Strip a trailing CR if present.
345 uint16_t n = s->banner_len;
346 if (n > 0 && s->banner_buf[n - 1] == '\r')
347 n--;
348
349 // RFC 4253 §4.2: the server may receive other lines before the
350 // identification string; only the line starting with "SSH-" counts.
351 if (n >= 4 && memcmp(s->banner_buf, "SSH-", 4) == 0)
352 {
353 if (n >= SSH_VERSION_MAX)
354 return -1;
355 memcpy(s->v_c, s->banner_buf, n);
356 s->v_c[n] = '\0';
357 s->v_c_len = n;
358 s->banner_len = 0;
360 *consumed = k;
361 return 1;
362 }
363 // Not the SSH line - discard and keep scanning.
364 s->banner_len = 0;
365 continue;
366 }
367
368 if (s->banner_len >= SSH_VERSION_MAX)
369 return -1; // line too long
370 s->banner_buf[s->banner_len++] = c;
371 }
372
373 *consumed = k;
374 return 0; // need more data
375}
376
377// ---------------------------------------------------------------------------
378// KEXINIT (RFC 4253 §7.1)
379// ---------------------------------------------------------------------------
380
381int ssh_kexinit_build(uint8_t i, uint8_t *payload, size_t *len, size_t cap)
382{
383 if (i >= MAX_SSH_CONNS)
384 return -1;
385 SshSession *s = &ssh_sess[i];
386
387 Writer w = {payload, cap, 0, true};
388 w_u8(w, SSH_MSG_KEXINIT);
389
390 uint8_t cookie[16];
391 ssh_rng_fill(cookie, sizeof(cookie));
392 w_bytes(w, cookie, sizeof(cookie));
393
394 char kexlist[192];
395 char hklist[48];
396 build_kex_list(kexlist, sizeof(kexlist));
397 build_hostkey_list(hklist, sizeof(hklist));
398 w_namelist(w, kexlist); // kex_algorithms (preference-ordered, + ext-info-s)
399 w_namelist(w, hklist); // server_host_key_algorithms (only keys we hold)
400 w_namelist(w, ALG_CIPHER_LIST); // encryption c2s (chacha20-poly1305 preferred, aes256-ctr fallback)
401 w_namelist(w, ALG_CIPHER_LIST); // encryption s2c
402 w_namelist(w, ALG_MAC_LIST); // mac c2s (used only with aes256-ctr; ignored for the AEAD cipher)
403 w_namelist(w, ALG_MAC_LIST); // mac s2c
404 w_namelist(w, ALG_COMP); // compression c2s (always none)
405 w_namelist(w, ALG_COMP_S2C); // compression s2c (zlib@openssh.com / zlib when built in)
406 w_namelist(w, ""); // languages c2s
407 w_namelist(w, ""); // languages s2c
408 w_u8(w, 0); // first_kex_packet_follows = false
409 w_u32(w, 0); // reserved
410
411 if (!w.ok)
412 return -1;
413
414 // Retain a copy as I_S for the exchange hash.
415 if (w.len > SSH_KEXINIT_S_MAX) // GCOVR_EXCL_LINE the server's fixed algorithm lists never exceed SSH_KEXINIT_S_MAX
416 return -1; // GCOVR_EXCL_LINE
417 memcpy(s->i_s, payload, w.len);
418 s->i_s_len = (uint16_t)w.len;
419
420 *len = w.len;
421 return 0;
422}
423
424// Read a name-list field at offset *off; set *list/*nlen to point into payload.
425// Returns true on success and advances *off past the field.
426static bool read_namelist(const uint8_t *p, size_t len, size_t *off, const uint8_t **list, uint32_t *nlen)
427{
428 if (*off + 4 > len)
429 return false;
430 uint32_t n = ((uint32_t)p[*off] << 24) | ((uint32_t)p[*off + 1] << 16) | ((uint32_t)p[*off + 2] << 8) |
431 (uint32_t)p[*off + 3];
432 *off += 4;
433 if (*off + n > len)
434 return false;
435 *list = p + *off;
436 *nlen = n;
437 *off += n;
438 return true;
439}
440
441// Negotiate the key-exchange method from the client's kex_algorithms name-list, in our preference order
442// (PQC hybrid first when enabled; RSA group first when prefer_rsa). false = no mutual method.
443static bool negotiate_kex(const uint8_t *list, uint32_t nlen, SshKexAlg *out)
444{
445 AlgCand<SshKexAlg> kc[5];
446 int nk = 0;
447#if DETWS_ENABLE_PQC_KEX
448 kc[nk++] = {KEX_MLKEM768, SshKexAlg::SSH_KEX_MLKEM768_X25519, true}; // hybrid first (PQC-preferred)
449#endif
450 if (s_sshtr.prefer_rsa)
451 {
452 kc[nk++] = {KEX_DH, SshKexAlg::SSH_KEX_DH_GROUP14, true};
453 kc[nk++] = {KEX_ECDH_NISTP256, SshKexAlg::SSH_KEX_ECDH_NISTP256, true};
454 kc[nk++] = {KEX_C25519, SshKexAlg::SSH_KEX_CURVE25519, true};
455 kc[nk++] = {KEX_C25519_LIBSSH, SshKexAlg::SSH_KEX_CURVE25519, true};
456 }
457 else
458 {
459 kc[nk++] = {KEX_C25519, SshKexAlg::SSH_KEX_CURVE25519, true};
460 kc[nk++] = {KEX_C25519_LIBSSH, SshKexAlg::SSH_KEX_CURVE25519, true};
461 kc[nk++] = {KEX_ECDH_NISTP256, SshKexAlg::SSH_KEX_ECDH_NISTP256, true};
462 kc[nk++] = {KEX_DH, SshKexAlg::SSH_KEX_DH_GROUP14, true};
463 }
464 return negotiate_alg(list, nlen, kc, nk, out);
465}
466
467// Negotiate the host-key algorithm, restricted to keys we actually hold. rsa-sha2-512/256 share the one
468// RSA key (RFC 8332), ecdsa-sha2-nistp256 is a distinct P-256 key. false = no mutual algorithm.
469static bool negotiate_hostkey(const uint8_t *list, uint32_t nlen, SshHostkeyAlg *out)
470{
471 const bool rsa = hostkey_rsa_available();
472 const bool ed = ssh_hostkey_ed25519_available();
473 const bool ec = ssh_hostkey_ecdsa_available();
475 if (s_sshtr.prefer_rsa)
476 {
477 hc[0] = {HOSTKEY_RSA_SHA512, SshHostkeyAlg::SSH_HOSTKEY_RSA_SHA512, rsa};
478 hc[1] = {HOSTKEY_RSA_SHA256, SshHostkeyAlg::SSH_HOSTKEY_RSA_SHA256, rsa};
479 hc[2] = {HOSTKEY_ECDSA, SshHostkeyAlg::SSH_HOSTKEY_ECDSA_NISTP256, ec};
480 hc[3] = {HOSTKEY_ED, SshHostkeyAlg::SSH_HOSTKEY_ED25519, ed};
481 }
482 else
483 {
484 hc[0] = {HOSTKEY_ED, SshHostkeyAlg::SSH_HOSTKEY_ED25519, ed};
485 hc[1] = {HOSTKEY_ECDSA, SshHostkeyAlg::SSH_HOSTKEY_ECDSA_NISTP256, ec};
486 hc[2] = {HOSTKEY_RSA_SHA512, SshHostkeyAlg::SSH_HOSTKEY_RSA_SHA512, rsa};
487 hc[3] = {HOSTKEY_RSA_SHA256, SshHostkeyAlg::SSH_HOSTKEY_RSA_SHA256, rsa};
488 }
489 return negotiate_alg(list, nlen, hc, 4, out);
490}
491
492int ssh_kexinit_parse(uint8_t i, const uint8_t *payload, size_t len)
493{
494 if (i >= MAX_SSH_CONNS)
495 return -1;
496 SshSession *s = &ssh_sess[i];
497
498 if (len < 1 + 16 || payload[0] != SSH_MSG_KEXINIT)
499 return -1;
500
501 // Retain a copy as I_C for the exchange hash.
502 if (len > SSH_KEXINIT_MAX)
503 return -1;
504 memcpy(s->i_c, payload, len);
505 s->i_c_len = (uint16_t)len;
506
507 size_t off = 1 + 16; // skip msg type + 16-byte cookie
508
509 const uint8_t *list;
510 uint32_t nlen;
511
512 // kex_algorithms: negotiate the KEX method in our preference order.
513 if (!read_namelist(payload, len, &off, &list, &nlen))
514 return -1;
515 // RFC 8308: if the client offers ext-info-c we will send SSH_MSG_EXT_INFO.
516 s->ext_info_c = namelist_contains(list, nlen, EXT_INFO_C);
517 if (!negotiate_kex(list, nlen, &s->kex_alg))
518 return -1; // no mutual KEX
519 // server_host_key_algorithms: negotiate, restricted to keys we actually hold.
520 if (!read_namelist(payload, len, &off, &list, &nlen))
521 return -1;
522 if (!negotiate_hostkey(list, nlen, &s->hostkey_alg))
523 return -1; // no mutual host-key algorithm
524 // encryption c2s / s2c: negotiate chacha20-poly1305@openssh.com or aes256-gcm@openssh.com (both
525 // AEADs) or aes256-ctr, in that preference order.
526 const AlgCand<decltype(SSH_CIPHER_AES256CTR)> cc[3] = {
527 {"chacha20-poly1305@openssh.com", SSH_CIPHER_CHACHA20POLY1305, true},
528 {ALG_CIPHER_GCM, SSH_CIPHER_AES256GCM, true},
529 {ALG_CIPHER, SSH_CIPHER_AES256CTR, true}};
530 if (!read_namelist(payload, len, &off, &list, &nlen))
531 return -1;
532 decltype(SSH_CIPHER_AES256CTR) c2s;
533 decltype(SSH_CIPHER_AES256CTR) s2c;
534 if (!negotiate_alg(list, nlen, cc, 3, &c2s))
535 return -1;
536 if (!read_namelist(payload, len, &off, &list, &nlen))
537 return -1;
538 if (!negotiate_alg(list, nlen, cc, 3, &s2c) || s2c != c2s) // require the same cipher both directions
539 return -1;
540 s->cipher_alg = c2s;
541 // mac c2s / s2c: negotiated only for aes256-ctr (both AEAD ciphers carry their own MAC). Prefer
542 // the encrypt-then-MAC variants (OpenSSH's default), require the same MAC both directions.
543 const AlgCand<decltype(SSH_MAC_HMAC_SHA256)> mc[4] = {
544 {"hmac-sha2-256-etm@openssh.com", SSH_MAC_HMAC_SHA256_ETM, true},
545 {"hmac-sha2-512-etm@openssh.com", SSH_MAC_HMAC_SHA512_ETM, true},
546 {ALG_MAC, SSH_MAC_HMAC_SHA256, true},
547 {"hmac-sha2-512", SSH_MAC_HMAC_SHA512, true}};
548 bool need_mac = (s->cipher_alg == SSH_CIPHER_AES256CTR);
551 if (!read_namelist(payload, len, &off, &list, &nlen))
552 return -1;
553 if (need_mac && !negotiate_alg(list, nlen, mc, 4, &m_c2s))
554 return -1;
555 if (!read_namelist(payload, len, &off, &list, &nlen))
556 return -1;
557 if (need_mac && (!negotiate_alg(list, nlen, mc, 4, &m_s2c) || m_s2c != m_c2s))
558 return -1;
559 s->mac_alg = m_c2s;
560 // compression c2s: we only decompress "none" (client->server compression is not implemented).
561 if (!read_namelist(payload, len, &off, &list, &nlen) || !namelist_contains(list, nlen, ALG_COMP))
562 return -1;
563 // compression s2c: negotiate zlib@openssh.com > zlib > none (server preference).
564 if (!read_namelist(payload, len, &off, &list, &nlen))
565 return -1;
566#if DETWS_ENABLE_SSH_ZLIB
567 {
568 const AlgCand<SshCompAlg> compc[3] = {{"zlib@openssh.com", SshCompAlg::SSH_COMP_ZLIB_DELAYED, true},
569 {"zlib", SshCompAlg::SSH_COMP_ZLIB, true},
570 {"none", SshCompAlg::SSH_COMP_NONE, true}};
571 SshCompAlg comp;
572 if (!negotiate_alg(list, nlen, compc, 3, &comp))
573 return -1;
574 ssh_comp_set_s2c(i, comp);
575 }
576#else
577 if (!namelist_contains(list, nlen, ALG_COMP))
578 return -1;
579#endif
580
582 return 0;
583}
584
585int ssh_extinfo_build(uint8_t *out, size_t *len, size_t cap)
586{
587 // byte SSH_MSG_EXT_INFO || uint32 nr-extensions || (string name, string value)*
588 Writer w = {out, cap, 0, true};
589 w_u8(w, SSH_MSG_EXT_INFO);
590 w_u32(w, 1); // one extension
591 w_namelist(w, "server-sig-algs"); // extension name
592 // Accepted client public-key signature algorithms for userauth. All are always
593 // verifiable (independent of which host key we hold); ordered by our preference so a
594 // modern client picks the steered-to type. A client uses this to choose a key to offer.
595 // Both RSA hashes are offered (rsa-sha2-512 first, RFC 8332); ssh_rsa_verify picks the
596 // hash from the client's chosen algorithm name. ecdsa-sha2-nistp256 (RFC 5656) and
597 // ssh-ed25519 are also verifiable, so all four are advertised in preference order.
598 const char *siglist = s_sshtr.prefer_rsa ? "rsa-sha2-512,rsa-sha2-256,ecdsa-sha2-nistp256,ssh-ed25519"
599 : "ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256";
600 w_namelist(w, siglist); // value: accepted client-sig algorithms
601 if (!w.ok)
602 return -1;
603 *len = w.len;
604 return 0;
605}
606
607// ---------------------------------------------------------------------------
608// Exchange hash H (RFC 4253 §8) - streamed into SHA-256, no large buffer.
609// ---------------------------------------------------------------------------
610
611// Hash a 4-byte big-endian length prefix.
612static void hash_u32(SshSha256Ctx *ctx, uint32_t v)
613{
614 uint8_t b[4] = {(uint8_t)(v >> 24), (uint8_t)(v >> 16), (uint8_t)(v >> 8), (uint8_t)v};
615 ssh_sha256_update(ctx, b, 4);
616}
617
618// Hash an SSH string: uint32 length + raw bytes.
619static void hash_string(SshSha256Ctx *ctx, const uint8_t *data, size_t len)
620{
621 hash_u32(ctx, (uint32_t)len);
622 ssh_sha256_update(ctx, data, len);
623}
624
625// Hash an SSH mpint from a fixed-width big-endian integer: strip leading zero
626// bytes, prepend a 0x00 if the top bit is set (to keep it positive).
627static void hash_mpint(SshSha256Ctx *ctx, const uint8_t *be, size_t len)
628{
629 size_t off = 0;
630 while (off < len && be[off] == 0)
631 off++;
632 if (off == len)
633 {
634 // Value is zero → mpint is an empty string.
635 hash_u32(ctx, 0);
636 return;
637 }
638 bool pad = (be[off] & 0x80u) != 0;
639 uint32_t mlen = (uint32_t)(len - off) + (pad ? 1u : 0u);
640 hash_u32(ctx, mlen);
641 if (pad)
642 {
643 uint8_t zero = 0;
644 ssh_sha256_update(ctx, &zero, 1);
645 }
646 ssh_sha256_update(ctx, be + off, len - off);
647}
648
649// Method-neutral exchange hash. The client/server public values are hashed as SSH
650// strings for an ECDH KEX (Q_C, Q_S; RFC 8731) or the PQ/T hybrid (C_INIT, S_REPLY), or as mpints
651// for a finite-field DH KEX (e, f; RFC 4253 §8). K is an mpint for the classical methods but a plain
652// string for the hybrid (its K is a fixed-length HASH output, RFC 4251 §5 / draft-ietf-sshm). cpub/
653// spub are big-endian, right-aligned in their buffers, so hash_mpint / hash_string produce the
654// canonical minimal encoding.
655static int compute_exchange_hash(uint8_t i, bool pub_is_string, const uint8_t *cpub, size_t cpub_len,
656 const uint8_t *spub, size_t spub_len, const uint8_t *k_be, size_t k_len,
657 const uint8_t *ks, size_t ks_len, uint8_t out[SSH_SHA256_DIGEST_LEN], bool k_is_string)
658{
659 if (i >= MAX_SSH_CONNS)
660 return -1;
661 SshSession *s = &ssh_sess[i];
662
663 static const char *const v_s = SSH_SERVER_VERSION;
664
665 SshSha256Ctx ctx;
666 ssh_sha256_init(&ctx);
667 hash_string(&ctx, (const uint8_t *)s->v_c, s->v_c_len); // V_C
668 hash_string(&ctx, (const uint8_t *)v_s, sizeof(SSH_SERVER_VERSION) - 1); // V_S
669 hash_string(&ctx, s->i_c, s->i_c_len); // I_C
670 hash_string(&ctx, s->i_s, s->i_s_len); // I_S
671 hash_string(&ctx, ks, ks_len); // K_S
672 if (pub_is_string)
673 {
674 hash_string(&ctx, cpub, cpub_len); // Q_C
675 hash_string(&ctx, spub, spub_len); // Q_S
676 }
677 else
678 {
679 hash_mpint(&ctx, cpub, cpub_len); // e
680 hash_mpint(&ctx, spub, spub_len); // f
681 }
682 if (k_is_string)
683 hash_string(&ctx, k_be, k_len); // hybrid: K is a fixed-length HASH output (RFC 4251 string)
684 else
685 hash_mpint(&ctx, k_be, k_len); // classical: K is an mpint
686 ssh_sha256_final(&ctx, out);
687 return 0;
688}
689
690int ssh_kex_exchange_hash(uint8_t i, const uint8_t *e_be, const uint8_t *f_be, const uint8_t *k_be, const uint8_t *ks,
691 size_t ks_len, uint8_t out[SSH_SHA256_DIGEST_LEN])
692{
693 return compute_exchange_hash(i, false, e_be, 256, f_be, 256, k_be, 256, ks, ks_len, out, false);
694}
695
696// ---------------------------------------------------------------------------
697// KEXDH (RFC 4253 §8)
698// ---------------------------------------------------------------------------
699
700int ssh_kexdh_parse_init(const uint8_t *payload, size_t len, uint8_t e_be[256])
701{
702 if (len < 1 + 4 || payload[0] != SSH_MSG_KEXDH_INIT)
703 return -1;
704
705 uint32_t n = ((uint32_t)payload[1] << 24) | ((uint32_t)payload[2] << 16) | ((uint32_t)payload[3] << 8) |
706 (uint32_t)payload[4];
707 if ((size_t)5 + n > len)
708 return -1;
709
710 const uint8_t *m = payload + 5;
711 size_t off = 0;
712 while (off < n && m[off] == 0) // strip sign/leading-zero bytes
713 off++;
714 size_t vlen = n - off;
715 if (vlen > 256)
716 return -1; // e exceeds 2048 bits
717
718 memset(e_be, 0, 256);
719 memcpy(e_be + (256 - vlen), m + off, vlen);
720 return 0;
721}
722
723int ssh_kexdh_build_reply(const uint8_t *ks, size_t ks_len, const uint8_t *f_be, const uint8_t *sig, size_t sig_len,
724 uint8_t *out, size_t *out_len, size_t cap)
725{
726 Writer w = {out, cap, 0, true};
727 w_u8(w, SSH_MSG_KEXDH_REPLY);
728 w_string(w, ks, ks_len); // K_S
729 w_mpint(w, f_be, 256); // f
730
731 // signature = string( string("rsa-sha2-256") || string(sig) )
732 uint32_t inner = 4 + 12 + 4 + (uint32_t)sig_len;
733 w_u32(w, inner);
734 w_string(w, (const uint8_t *)"rsa-sha2-256", 12);
735 w_string(w, sig, sig_len);
736
737 if (!w.ok)
738 return -1;
739 *out_len = w.len;
740 return 0;
741}
742
743// Parse SSH_MSG_KEX_ECDH_INIT (msg 30, shares the number with KEXDH_INIT):
744// byte(30) || string(Q_C). Q_C must be exactly 32 bytes for X25519 (RFC 8731).
745static int parse_ecdh_init(const uint8_t *payload, size_t len, uint8_t qc[32])
746{
747 if (len < 1 + 4 || payload[0] != SSH_MSG_KEXDH_INIT)
748 return -1;
749 uint32_t n = ((uint32_t)payload[1] << 24) | ((uint32_t)payload[2] << 16) | ((uint32_t)payload[3] << 8) |
750 (uint32_t)payload[4];
751 if (n != 32 || (size_t)5 + n > len)
752 return -1;
753 memcpy(qc, payload + 5, 32);
754 return 0;
755}
756
757// Parse SSH_MSG_KEX_ECDH_INIT for ecdh-sha2-nistp256 (RFC 5656 §4): byte(30) || string(Q_C),
758// where Q_C is the 65-byte uncompressed client point 0x04 || X || Y.
759static int parse_ecdh_init_p256(const uint8_t *payload, size_t len, uint8_t qc[SSH_ECDSA_P256_PUB_LEN])
760{
761 if (len < 1 + 4 || payload[0] != SSH_MSG_KEXDH_INIT)
762 return -1;
763 uint32_t n = ((uint32_t)payload[1] << 24) | ((uint32_t)payload[2] << 16) | ((uint32_t)payload[3] << 8) |
764 (uint32_t)payload[4];
765 if (n != SSH_ECDSA_P256_PUB_LEN || (size_t)5 + n > len)
766 return -1;
767 memcpy(qc, payload + 5, SSH_ECDSA_P256_PUB_LEN);
768 return 0;
769}
770
771// Encode the server host-key blob K_S for the negotiated host-key algorithm.
772// rsa-sha2-256/512 → "ssh-rsa" blob (ssh_rsa_encode_pubkey)
773// ssh-ed25519 → string("ssh-ed25519") || string(pub32) (RFC 8709 §4)
774// ecdsa-sha2-nistp256 → string(name) || string("nistp256") || string(Q) (RFC 5656 §3.1)
775static int encode_hostkey(uint8_t i, uint8_t *ks, size_t *ks_len, size_t cap)
776{
777 if (ssh_sess[i].hostkey_alg == SshHostkeyAlg::SSH_HOSTKEY_ED25519)
778 {
779 Writer w = {ks, cap, 0, true};
780 w_string(w, (const uint8_t *)HOSTKEY_ED, sizeof(HOSTKEY_ED) - 1);
781 w_string(w, s_sshtr.ed_pub, 32);
782 if (!w.ok) // GCOVR_EXCL_LINE the ed25519 blob (~51B) always fits the RSA-sized ks buffer
783 return -1; // GCOVR_EXCL_LINE
784 *ks_len = w.len;
785 return 0;
786 }
788 {
789 Writer w = {ks, cap, 0, true};
790 w_string(w, (const uint8_t *)HOSTKEY_ECDSA, sizeof(HOSTKEY_ECDSA) - 1);
791 w_string(w, (const uint8_t *)"nistp256", 8); // RFC 5656 curve identifier
792 w_string(w, s_sshtr.ecdsa_pub, SSH_ECDSA_P256_PUB_LEN);
793 if (!w.ok) // GCOVR_EXCL_LINE the ecdsa blob (~104B) always fits the RSA-sized ks buffer
794 return -1; // GCOVR_EXCL_LINE
795 *ks_len = w.len;
796 return 0;
797 }
798 return ssh_rsa_encode_pubkey(ks, ks_len, cap);
799}
800
801// Sign the exchange hash H with the negotiated host key. Writes the raw signature (no
802// SSH framing) plus its algorithm name; the caller wraps it as the signature blob.
803static int sign_hash(uint8_t i, const uint8_t H[SSH_SHA256_DIGEST_LEN], uint8_t *sig, size_t *sig_len, size_t sig_cap,
804 const char **sig_name)
805{
806 if (ssh_sess[i].hostkey_alg == SshHostkeyAlg::SSH_HOSTKEY_ED25519)
807 {
808 if (sig_cap < 64) // GCOVR_EXCL_LINE the caller's sig buffer is SSH_RSA_SIG_BYTES (256) >= 64
809 return -1; // GCOVR_EXCL_LINE
811 *sig_len = 64;
812 *sig_name = HOSTKEY_ED; // "ssh-ed25519"
813 return 0;
814 }
816 {
817 uint8_t raw[SSH_ECDSA_P256_SIG_LEN]; // r || s (32 + 32)
819 return -1; // GCOVR_EXCL_LINE key is available (negotiated) and sign is infallible for a valid d
820 // ECDSA signature blob is mpint(r) || mpint(s) (RFC 5656 §3.1.2).
821 Writer w = {sig, sig_cap, 0, true};
822 w_mpint(w, raw, SSH_ECDSA_P256_COORD_LEN);
823 w_mpint(w, raw + SSH_ECDSA_P256_COORD_LEN, SSH_ECDSA_P256_COORD_LEN);
824 if (!w.ok) // GCOVR_EXCL_LINE the mpint blob (~74B) always fits the 256B sig buffer
825 return -1; // GCOVR_EXCL_LINE
826 *sig_len = w.len;
827 *sig_name = HOSTKEY_ECDSA; // "ecdsa-sha2-nistp256"
828 return 0;
829 }
830 // rsa-sha2-512 and rsa-sha2-256 share the one "ssh-rsa" key; the negotiated
831 // algorithm only chooses the signature hash (RFC 8332).
834 if (sig_cap < SSH_RSA_SIG_BYTES ||
835 ssh_rsa_sign(H, SSH_SHA256_DIGEST_LEN, rh, sig) != 0) // GCOVR_EXCL_LINE sig buffer is 256B and the negotiated
836 return -1; // GCOVR_EXCL_LINE RSA key is loaded (available), so neither the size nor the sign can fail
837 *sig_len = SSH_RSA_SIG_BYTES;
838 *sig_name = sha512 ? HOSTKEY_RSA_SHA512 : HOSTKEY_RSA_SHA256;
839 return 0;
840}
841
842// Assemble SSH_MSG_KEXDH_REPLY (== KEX_ECDH_REPLY / KEX_HYBRID_REPLY, msg 31):
843// byte(31) || string(K_S) || (mpint f | string Q_S | string S_REPLY) || string( string(sig_name) || string(sig) )
844static int build_kex_reply(uint8_t i, const uint8_t *ks, size_t ks_len, const uint8_t *spub, size_t spub_len,
845 const char *sig_name, const uint8_t *sig, size_t sig_len, uint8_t *out, size_t *out_len,
846 size_t cap)
847{
848 Writer w = {out, cap, 0, true};
849 w_u8(w, SSH_MSG_KEXDH_REPLY);
850 w_string(w, ks, ks_len); // K_S
851 if (ssh_sess[i].kex_alg == SshKexAlg::SSH_KEX_DH_GROUP14)
852 w_mpint(w, spub, spub_len); // f (mpint)
853 else
854 w_string(w, spub, spub_len); // Q_S (curve25519) or S_REPLY (hybrid), a raw string
855 uint32_t nl = (uint32_t)strnlen(sig_name, w.cap);
856 w_u32(w, 4 + nl + 4 + (uint32_t)sig_len); // signature blob length
857 w_string(w, (const uint8_t *)sig_name, nl);
858 w_string(w, sig, sig_len);
859 if (!w.ok)
860 return -1;
861 *out_len = w.len;
862 return 0;
863}
864
865int ssh_kex_generate(uint8_t i)
866{
867 if (i >= MAX_SSH_CONNS)
868 return -1;
870 bool curve = (a == SshKexAlg::SSH_KEX_CURVE25519);
871#if DETWS_ENABLE_PQC_KEX
872 curve = curve || (a == SshKexAlg::SSH_KEX_MLKEM768_X25519); // the hybrid's classical half is X25519
873#endif
874 if (curve)
875 {
876 // X25519 ephemeral: random 32-byte scalar, public = X25519(scalar, base point).
877 ssh_rng_fill(ssh_sess[i].ecdh_sk, 32);
878 ssh_x25519_base(ssh_sess[i].ecdh_pk, ssh_sess[i].ecdh_sk);
879 return 0;
880 }
882 {
883 // P-256 ECDH ephemeral: a random scalar d in [1, n) stored in ecdh_sk. The 65-byte public
884 // point Q_S = d*G is re-derived in ssh_kexdh_handle (avoids a curve-specific session field).
885 // Re-draw on the negligible chance a raw 32-byte value is 0 or >= n (an invalid P-256 scalar).
886 uint8_t qtmp[SSH_ECDSA_P256_PUB_LEN];
887 for (int t = 0; t < 8; t++)
888 {
889 ssh_rng_fill(ssh_sess[i].ecdh_sk, 32);
890 if (ssh_ecdsa_p256_pubkey(qtmp, ssh_sess[i].ecdh_sk))
891 return 0;
892 }
893 return -1; // GCOVR_EXCL_LINE a random 32-byte scalar is a valid P-256 key with overwhelming probability
894 }
895 return ssh_dh_generate(i);
896}
897
898#if DETWS_ENABLE_PQC_KEX
899// mlkem768x25519-sha256 (draft-ietf-sshm-mlkem-hybrid-kex): from the client's SSH_MSG_KEX_HYBRID_INIT
900// (byte 30 || string C_INIT, C_INIT = ek(1184) || Q_C(32)), ML-KEM-Encaps to the peer's key and X25519
901// against Q_C, then combine K = SHA256(K_PQ || K_CL). Writes S_REPLY = ciphertext(1088) || Q_S(32) and
902// the 32-byte shared secret. Returns 0, or -1 on a malformed C_INIT, bad ML-KEM key, or low-order point.
903static int hybrid_mlkem_x25519(uint8_t i, const uint8_t *payload, size_t len, uint8_t s_reply[MLKEM768_CT_BYTES + 32],
904 uint8_t k_out[32])
905{
906 if (len < 1 + 4 || payload[0] != SSH_MSG_KEXDH_INIT)
907 return -1;
908 uint32_t n = ((uint32_t)payload[1] << 24) | ((uint32_t)payload[2] << 16) | ((uint32_t)payload[3] << 8) |
909 (uint32_t)payload[4];
910 if (n != MLKEM768_EK_BYTES + 32 || (size_t)5 + n > len)
911 return -1;
912 const uint8_t *ek = payload + 5; // C_PK2: ML-KEM-768 encapsulation key
913 const uint8_t *qc = payload + 5 + MLKEM768_EK_BYTES; // C_PK1: client X25519 public
914
915 uint8_t m[32];
916 ssh_rng_fill(m, sizeof(m));
917 uint8_t k_pq[32];
918 bool ok = mlkem768_encaps(ek, m, s_reply, k_pq); // ciphertext -> s_reply[0..1087]
919 ssh_wipe(m, sizeof(m));
920 if (!ok)
921 return -1; // malformed encapsulation key (FIPS 203 modulus check)
922
923 uint8_t k_cl[32];
924 ssh_x25519(k_cl, ssh_sess[i].ecdh_sk, qc);
925 uint8_t zacc = 0;
926 for (int b = 0; b < 32; b++)
927 zacc |= k_cl[b];
928 if (zacc == 0) // low-order X25519 point (RFC 7748 §6.1)
929 {
930 ssh_wipe(k_pq, sizeof(k_pq));
931 ssh_wipe(k_cl, sizeof(k_cl));
932 return -1;
933 }
934 memcpy(s_reply + MLKEM768_CT_BYTES, ssh_sess[i].ecdh_pk, 32); // S_PK1: server X25519 public
935
936 SshSha256Ctx hc;
937 ssh_sha256_init(&hc);
938 ssh_sha256_update(&hc, k_pq, sizeof(k_pq)); // K = SHA256(K_PQ || K_CL) (RFC 9370 concat combiner)
939 ssh_sha256_update(&hc, k_cl, sizeof(k_cl));
940 ssh_sha256_final(&hc, k_out);
941 ssh_wipe(k_pq, sizeof(k_pq));
942 ssh_wipe(k_cl, sizeof(k_cl));
943 return 0;
944}
945#endif
946
947int ssh_kexdh_handle(uint8_t i, const uint8_t *payload, size_t len, uint8_t *reply_out, size_t *reply_len, size_t cap)
948{
949 if (i >= MAX_SSH_CONNS)
950 return -1;
951 SshSession *s = &ssh_sess[i];
952
953 // 1. Shared secret K + the two public values, per negotiated KEX method. cpub_p / spub_p point at
954 // the values hashed into H (local buffers for DH / curve, the larger C_INIT / S_REPLY blobs for
955 // the hybrid); k_hash / k_hash_len select K's encoding (an mpint for DH / curve, a fixed 32-byte
956 // string for the hybrid). k_be holds K right-aligned so hash_mpint / the KDF strip to minimal.
957 uint8_t k_be[256];
958 memset(k_be, 0, sizeof(k_be));
959 uint8_t cpub[256];
960 uint8_t spub[256]; // client / server public value (right-aligned) for DH / curve25519
961 const uint8_t *cpub_p = cpub;
962 const uint8_t *spub_p = spub;
963 size_t cpub_len = 256;
964 size_t spub_len = 256;
965 const uint8_t *k_hash = k_be;
966 size_t k_hash_len = 256;
967 bool pub_is_string = false;
968 bool k_is_string = false;
969#if DETWS_ENABLE_PQC_KEX
970 uint8_t s_reply[MLKEM768_CT_BYTES + 32]; // hybrid S_REPLY = ciphertext(1088) || Q_S(32)
971#endif
972
974 {
975 // curve25519-sha256 (RFC 8731): K = X25519(sk, Q_C); Q_C/Q_S hashed as strings.
976 uint8_t qc[32];
977 uint8_t kk[32];
978 if (parse_ecdh_init(payload, len, qc) != 0)
979 return -1;
980 ssh_x25519(kk, s->ecdh_sk, qc);
981 // Reject a low-order client point (all-zero shared secret) - RFC 7748 §6.1.
982 uint8_t zacc = 0;
983 for (int b = 0; b < 32; b++)
984 zacc |= kk[b];
985 if (zacc == 0)
986 {
987 ssh_wipe(kk, sizeof(kk));
988 return -1;
989 }
990 memcpy(k_be + (256 - 32), kk, 32);
991 memcpy(cpub, qc, 32);
992 memcpy(spub, s->ecdh_pk, 32);
993 cpub_len = spub_len = 32;
994 pub_is_string = true;
995 ssh_wipe(kk, sizeof(kk));
996 }
997#if DETWS_ENABLE_PQC_KEX
999 {
1000 // mlkem768x25519-sha256: K = SHA256(K_PQ || K_CL); C_INIT / S_REPLY and K hashed as strings.
1001 if (hybrid_mlkem_x25519(i, payload, len, s_reply, k_be + (256 - 32)) != 0)
1002 return -1;
1003 cpub_p = payload + 5; // C_INIT (ek || Q_C), hashed verbatim as a string
1004 cpub_len = MLKEM768_EK_BYTES + 32;
1005 spub_p = s_reply; // S_REPLY (ciphertext || Q_S)
1006 spub_len = MLKEM768_CT_BYTES + 32;
1007 k_hash = k_be + (256 - 32); // K is exactly 32 bytes, string-encoded (not mpint)
1008 k_hash_len = 32;
1009 pub_is_string = true;
1010 k_is_string = true;
1011 }
1012#endif
1014 {
1015 // ecdh-sha2-nistp256 (RFC 5656 §4): K = X(d_S * Q_C). Q_C/Q_S are 65-byte point strings; K an mpint.
1016 uint8_t qc[SSH_ECDSA_P256_PUB_LEN];
1017 if (parse_ecdh_init_p256(payload, len, qc) != 0)
1018 return -1;
1019 uint8_t qs[SSH_ECDSA_P256_PUB_LEN];
1020 uint8_t kk[SSH_ECDSA_P256_COORD_LEN];
1021 // Re-derive our ephemeral public Q_S, then the shared secret. ssh_ecdsa_p256_ecdh validates
1022 // Q_C is on-curve and the product is not the identity (RFC 5656 §4 point checks).
1023 if (!ssh_ecdsa_p256_pubkey(qs, s->ecdh_sk) || !ssh_ecdsa_p256_ecdh(kk, qc, s->ecdh_sk))
1024 return -1;
1025 memcpy(k_be + (256 - SSH_ECDSA_P256_COORD_LEN), kk, SSH_ECDSA_P256_COORD_LEN);
1026 memcpy(cpub, qc, SSH_ECDSA_P256_PUB_LEN);
1027 memcpy(spub, qs, SSH_ECDSA_P256_PUB_LEN);
1028 cpub_len = SSH_ECDSA_P256_PUB_LEN;
1029 spub_len = SSH_ECDSA_P256_PUB_LEN;
1030 pub_is_string = true;
1031 ssh_wipe(kk, sizeof(kk));
1032 }
1033 else
1034 {
1035 // diffie-hellman-group14-sha256 (RFC 4253 §8): K = e^y mod p; e/f are mpints.
1036 uint8_t e_be[256];
1037 if (ssh_kexdh_parse_init(payload, len, e_be) != 0)
1038 return -1;
1039 SshBigNum e;
1040 bn_from_bytes(&e, e_be, 256);
1041 if (bn_dh_validate(&e) != 0)
1042 return -1;
1043 SshBigNum K;
1044 bn_expmod_group14(&K, &e, &ssh_dh[i].y);
1045 bn_to_bytes(k_be, &K);
1046 ssh_wipe(&K, sizeof(K));
1047 memcpy(cpub, e_be, 256);
1048 bn_to_bytes(spub, &ssh_dh[i].f);
1049 }
1050
1051 // 2. Host-key blob K_S (per negotiated host-key algorithm).
1052 uint8_t ks[SSH_RSA_PUBKEY_BLOB_MAX];
1053 size_t ks_len = 0;
1054 if (encode_hostkey(i, ks, &ks_len, sizeof(ks)) != 0) // GCOVR_EXCL_LINE encode_hostkey cannot fail: ks is
1055 { // GCOVR_EXCL_LINE SSH_RSA_PUBKEY_BLOB_MAX, sized for either blob
1056 ssh_wipe(k_be, sizeof(k_be)); // GCOVR_EXCL_LINE
1057 return -1; // GCOVR_EXCL_LINE
1058 }
1059
1060 // 3. Exchange hash H; capture the session id on the first KEX.
1061 uint8_t H[SSH_SHA256_DIGEST_LEN];
1062 compute_exchange_hash(i, pub_is_string, cpub_p, cpub_len, spub_p, spub_len, k_hash, k_hash_len, ks, ks_len, H,
1063 k_is_string);
1064 if (!s->have_session_id)
1065 {
1066 memcpy(s->session_id, H, SSH_SHA256_DIGEST_LEN);
1067 s->have_session_id = true;
1068 }
1069
1070 // 4. Sign H with the negotiated host key (rsa-sha2-512/256 or ssh-ed25519).
1071 uint8_t sig[SSH_RSA_SIG_BYTES]; // 256 bytes: fits an RSA-2048 sig and a 64-byte ed25519 sig
1072 size_t sig_len = 0;
1073 const char *sig_name = nullptr;
1074 if (sign_hash(i, H, sig, &sig_len, sizeof(sig), &sig_name) != 0) // GCOVR_EXCL_LINE sign_hash cannot fail here:
1075 { // GCOVR_EXCL_LINE 256B sig buffer + a loaded key
1076 ssh_wipe(k_be, sizeof(k_be)); // GCOVR_EXCL_LINE
1077 return -1; // GCOVR_EXCL_LINE
1078 }
1079
1080 // 5. Assemble the reply, then derive the six session keys (id fixed at first KEX's H).
1081 if (build_kex_reply(i, ks, ks_len, spub_p, spub_len, sig_name, sig, sig_len, reply_out, reply_len, cap) != 0)
1082 {
1083 ssh_wipe(k_be, sizeof(k_be));
1084 return -1;
1085 }
1086 ssh_dh_derive_keys_sid(i, k_be, H, s->session_id, s->cipher_alg, s->mac_alg, k_is_string);
1087 ssh_wipe(k_be, sizeof(k_be));
1088
1090 return 0;
1091}
1092
1093void ssh_newkeys_sent(uint8_t i)
1094{
1095 if (i >= MAX_SSH_CONNS)
1096 return;
1097 // We have emitted our SSH_MSG_NEWKEYS: our outbound direction is now encrypted (RFC 4253 sec 7.3).
1098 ssh_pkt[i].enc_out = true;
1099#if DETWS_ENABLE_SSH_ZLIB
1100 // "zlib" (non-delayed) starts its s2c (outbound) stream here; idempotent, so a re-key does not restart it.
1101 ssh_comp_on_newkeys(i);
1102#endif
1103}
1104
1106{
1107 if (i >= MAX_SSH_CONNS)
1108 return;
1109 // We have received the peer's SSH_MSG_NEWKEYS: our inbound direction is now encrypted. Both directions
1110 // are keyed once we get here (the server always sends its NEWKEYS first), so the KEX is complete.
1111 ssh_pkt[i].enc_in = true;
1112 ssh_pkt[i].kex_active = false;
1113 // On the first KEX advance to the service phase; on a re-key the connection
1114 // is already authenticated, so resume the open (channel) phase.
1116 // Reset the re-key timer: the volume/time budget is measured from this completed KEX.
1118}
1119
1120bool ssh_rekey_needed(uint8_t i)
1121{
1122 if (i >= MAX_SSH_CONNS)
1123 return false;
1125}
1126
1127bool ssh_rekey_due(uint32_t seq_send, uint32_t seq_recv, uint32_t elapsed_ms, uint32_t pkt_threshold,
1128 uint32_t time_threshold_ms)
1129{
1130 if (seq_send >= pkt_threshold || seq_recv >= pkt_threshold)
1131 return true; // volume budget (RFC 4253 sec 9: ~1 GB)
1132 if (time_threshold_ms && elapsed_ms >= time_threshold_ms)
1133 return true; // time budget (~1 hour)
1134 return false;
1135}
1136
1137int ssh_transport_begin_rekey(uint8_t i, uint8_t *out, size_t *out_len, size_t cap)
1138{
1139 if (i >= MAX_SSH_CONNS)
1140 return -1;
1141 // Fresh server KEXINIT (re-stores I_S for the new exchange hash).
1142 if (ssh_kexinit_build(i, out, out_len, cap) != 0)
1143 return -1;
1144 // New ephemeral for forward secrecy across the re-key (re-generated for the finally
1145 // negotiated method once the peer's KEXINIT arrives; see the KEXINIT dispatch).
1146 if (ssh_kex_generate(i) != 0) // GCOVR_EXCL_LINE i < MAX_SSH_CONNS is checked above and ssh_kex_generate only
1147 return -1; // GCOVR_EXCL_LINE fails for i >= MAX_SSH_CONNS
1149 return 0;
1150}
#define SSH_KEXINIT_MAX
Max stored size of the CLIENT KEXINIT payload (I_C, for the exchange hash).
#define MAX_SSH_CONNS
Maximum simultaneous SSH connections.
#define SSH_REKEY_PACKET_THRESHOLD
Re-key when either packet sequence number reaches this value.
Pluggable monotonic clock for all library timing.
uint32_t detws_millis(void)
The library's monotonic time at 1000 Hz (milliseconds).
Definition clock.h:71
ML-KEM-768 encapsulation (FIPS 203), responder side only.
void bn_expmod_group14(SshBigNum *out, const SshBigNum *base, const SshBigNum *exp)
Compute out = base^exp mod group14_p.
void bn_to_bytes(uint8_t bytes[256], const SshBigNum *in)
Write a SshBigNum as a 256-byte big-endian array.
int bn_dh_validate(const SshBigNum *v)
Validate a received DH public value.
void bn_from_bytes(SshBigNum *out, const uint8_t *bytes, size_t len)
Read a big-endian byte array of len bytes into a SshBigNum.
2048-bit big-integer arithmetic for DH-group14 and RSA-2048.
SSH per-connection compression owner (server-to-client zlib / zlib@openssh.com).
void ssh_x25519_base(uint8_t out[32], const uint8_t scalar[32])
X25519 with the standard base point u=9: out = scalar * G.
void ssh_x25519(uint8_t out[32], const uint8_t scalar[32], const uint8_t point[32])
X25519 scalar multiplication: out = scalar * point (RFC 7748 §5).
Curve25519 field arithmetic + X25519 (RFC 7748) for the curve25519-sha256 KEX.
void ssh_dh_derive_keys_sid(uint8_t i, const uint8_t K_be[256], const uint8_t H[SSH_SHA256_DIGEST_LEN], const uint8_t session_id[SSH_SHA256_DIGEST_LEN], uint8_t cipher_alg, uint8_t mac_alg, bool k_is_string)
Derive session keys with an explicit session id (RFC 4253 §7.2).
Definition ssh_dh.cpp:151
void ssh_rng_fill(uint8_t *buf, size_t len)
Fill len bytes of buf with cryptographically random data.
Definition ssh_dh.cpp:18
int ssh_dh_generate(uint8_t i)
Generate the server ephemeral DH key pair for connection slot i.
Definition ssh_dh.cpp:27
DH-group14-SHA256 key exchange (RFC 4253 §8 + RFC 8268).
bool ssh_ecdsa_p256_ecdh(uint8_t shared_x[SSH_ECDSA_P256_COORD_LEN], const uint8_t peer_pub[SSH_ECDSA_P256_PUB_LEN], const uint8_t priv[SSH_ECDSA_P256_PRIV_LEN])
P-256 ECDH: the shared-secret X coordinate of d * Q_peer (RFC 5656 §4 / RFC 5903).
bool ssh_ecdsa_p256_sign(uint8_t sig[SSH_ECDSA_P256_SIG_LEN], const uint8_t *msg, size_t mlen, const uint8_t priv[SSH_ECDSA_P256_PRIV_LEN])
Sign mlen bytes of msg with a P-256 private key (ECDSA, SHA-256).
bool ssh_ecdsa_p256_pubkey(uint8_t pub[SSH_ECDSA_P256_PUB_LEN], const uint8_t priv[SSH_ECDSA_P256_PRIV_LEN])
Derive the uncompressed public point Q = d*G from a P-256 private scalar.
Definition ssh_ecdsa.cpp:79
NIST P-256 primitives for SSH: ECDSA signatures and ECDH (RFC 5656 / FIPS 186-4).
void ssh_ed25519_sign(uint8_t sig[64], const uint8_t *msg, size_t mlen, const uint8_t seed[32])
void ssh_ed25519_pubkey(uint8_t pub[32], const uint8_t seed[32])
Ed25519 signatures (RFC 8032) for ssh-ed25519 host keys + client auth.
SshDhState ssh_dh[MAX_SSH_CONNS]
Pool of ephemeral DH state, one entry per MAX_SSH_CONNS.
@ SSH_CIPHER_CHACHA20POLY1305
chacha20-poly1305@openssh.com (AEAD; no separate MAC)
Definition ssh_keymat.h:119
@ SSH_CIPHER_AES256GCM
aes256-gcm@openssh.com (AEAD, RFC 5647; no separate MAC)
Definition ssh_keymat.h:120
@ SSH_CIPHER_AES256CTR
aes256-ctr + a separate HMAC (the fallback)
Definition ssh_keymat.h:118
@ SSH_MAC_HMAC_SHA256_ETM
hmac-sha2-256-etm@openssh.com (encrypt-then-MAC)
Definition ssh_keymat.h:128
@ SSH_MAC_HMAC_SHA256
hmac-sha2-256 (encrypt-and-MAC, RFC 4253)
Definition ssh_keymat.h:126
@ SSH_MAC_HMAC_SHA512
hmac-sha2-512 (encrypt-and-MAC)
Definition ssh_keymat.h:127
@ SSH_MAC_HMAC_SHA512_ETM
hmac-sha2-512-etm@openssh.com (encrypt-then-MAC)
Definition ssh_keymat.h:129
SshPacketState ssh_pkt[MAX_SSH_CONNS]
Static packet state pool (BSS). One entry per SSH slot.
SSH binary packet protocol: framing, AES-256-CTR encryption, HMAC-SHA2-256 integrity,...
#define SSH_MSG_KEXINIT
Definition ssh_packet.h:102
#define SSH_MSG_KEXDH_REPLY
Definition ssh_packet.h:105
#define SSH_MSG_KEXDH_INIT
Definition ssh_packet.h:104
#define SSH_MSG_EXT_INFO
Definition ssh_packet.h:101
int ssh_rsa_sign(const uint8_t *msg, size_t msg_len, SshRsaHash hash, uint8_t sig[SSH_RSA_SIG_BYTES])
Sign msg using the RSA host key (PKCS#1 v1.5, rsa-sha2-256/512).
Definition ssh_rsa.cpp:147
SshRsaPubKey ssh_host_pubkey
Static host public key (BSS). Set by ssh_rsa_load_pubkey().
Definition ssh_rsa.cpp:39
int ssh_rsa_encode_pubkey(uint8_t *out, size_t *out_len, size_t out_cap)
Serialize the RSA public host key into an SSH "ssh-rsa" key blob.
Definition ssh_rsa.cpp:576
RSA-SHA2-256/512 host-key signing and public-key serialization.
SshRsaHash
Hash algorithm selecting the RSA signature scheme (RFC 8332).
Definition ssh_rsa.h:106
@ SHA256
rsa-sha2-256
@ SHA512
rsa-sha2-512
#define SSH_RSA_SIG_BYTES
PKCS#1 v1.5 signature size for RSA-2048 in bytes.
Definition ssh_rsa.h:122
#define SSH_RSA_PUBKEY_BLOB_MAX
Maximum byte length of the serialized RSA public key blob.
Definition ssh_rsa.h:301
void ssh_sha256_init(SshSha256Ctx *ctx)
Initialize a streaming SHA-256 context.
void ssh_sha256_update(SshSha256Ctx *ctx, const uint8_t *data, size_t len)
Feed len bytes of data into the running hash.
void ssh_sha256_final(SshSha256Ctx *ctx, uint8_t digest[SSH_SHA256_DIGEST_LEN])
Finalize the hash and write the 32-byte digest.
SHA-256 (FIPS 180-4) - streaming context and one-shot API.
#define SSH_SHA256_DIGEST_LEN
SHA-256 digest length in bytes.
Definition ssh_sha256.h:29
int ssh_kex_generate(uint8_t i)
Generate the server ephemeral for the negotiated KEX method (call after parse).
bool ssh_kex_prefer_rsa(void)
Current negotiation preference (true = prefer RSA/DH, the ESP32-accelerated path).
bool ssh_hostkey_ed25519_available(void)
True if an ssh-ed25519 host key has been installed.
int ssh_transport_recv_banner(uint8_t i, const uint8_t *data, size_t len, size_t *consumed)
Feed raw bytes while awaiting the client identification string.
void ssh_hostkey_ed25519_set(const uint8_t seed[32])
Install an ssh-ed25519 host key from its 32-byte seed (RFC 8032 private key).
void ssh_hostkey_ecdsa_set(const uint8_t priv[SSH_ECDSA_P256_PRIV_LEN])
int ssh_extinfo_build(uint8_t *out, size_t *len, size_t cap)
Build SSH_MSG_EXT_INFO advertising server-sig-algs (RFC 8308).
int ssh_kexinit_build(uint8_t i, uint8_t *payload, size_t *len, size_t cap)
Build the server KEXINIT payload for slot i (RFC 4253 §7.1).
void ssh_newkeys_complete(uint8_t i)
Complete the NEWKEYS exchange: activate the inbound direction and advance phase.
bool ssh_rekey_needed(uint8_t i)
True if slot i has reached the re-key threshold (RFC 4253 §9).
void ssh_newkeys_sent(uint8_t i)
Activate the outbound direction after emitting our SSH_MSG_NEWKEYS.
int ssh_transport_begin_rekey(uint8_t i, uint8_t *out, size_t *out_len, size_t cap)
Begin a server-initiated re-key by emitting a fresh KEXINIT.
bool ssh_hostkey_ecdsa_available(void)
True if an ecdsa-sha2-nistp256 host key has been installed.
bool ssh_rekey_due(uint32_t seq_send, uint32_t seq_recv, uint32_t elapsed_ms, uint32_t pkt_threshold, uint32_t time_threshold_ms)
Pure re-key decision (RFC 4253 §9: "after each gigabyte ... or after each hour").
int ssh_kexdh_build_reply(const uint8_t *ks, size_t ks_len, const uint8_t *f_be, const uint8_t *sig, size_t sig_len, uint8_t *out, size_t *out_len, size_t cap)
Build SSH_MSG_KEXDH_REPLY (RFC 4253 §8, RFC 8332 §3).
SshSession ssh_sess[MAX_SSH_CONNS]
Static pool of SSH session state (BSS), one per SSH slot.
void ssh_kex_set_prefer_rsa(bool prefer)
Steer KEX / host-key negotiation toward RSA + DH-group14 (default) or toward the modern curve25519 + ...
int ssh_kexinit_parse(uint8_t i, const uint8_t *payload, size_t len)
Parse and negotiate the client KEXINIT payload (RFC 4253 §7.1).
void ssh_transport_init(uint8_t i)
Reset transport state for slot i to the start of a handshake.
int ssh_kexdh_handle(uint8_t i, const uint8_t *payload, size_t len, uint8_t *reply_out, size_t *reply_len, size_t cap)
Handle KEXDH/ECDH_INIT (msg 30) end-to-end and produce the reply payload.
int ssh_kex_exchange_hash(uint8_t i, const uint8_t *e_be, const uint8_t *f_be, const uint8_t *k_be, const uint8_t *ks, size_t ks_len, uint8_t out[SSH_SHA256_DIGEST_LEN])
Compute the SSH exchange hash H (RFC 4253 §8).
int ssh_transport_server_banner(uint8_t *out, size_t *out_len, size_t cap)
Write the server identification string ("SSH-2.0-…\r\n") to out.
int ssh_kexdh_parse_init(const uint8_t *payload, size_t len, uint8_t e_be[256])
Parse SSH_MSG_KEXDH_INIT, extracting the client DH value e.
SSH transport-layer protocol state machine (RFC 4253).
#define SSH_VERSION_MAX
Max stored length of an SSH identification string (RFC 4253 §4.2: 255).
SshHostkeyAlg
Negotiated host-key / signature algorithm.
@ SSH_HOSTKEY_RSA_SHA512
rsa-sha2-512 (same "ssh-rsa" key, SHA-512 signature; RFC 8332)
@ SSH_HOSTKEY_ECDSA_NISTP256
ecdsa-sha2-nistp256 (NIST P-256, RFC 5656)
@ SSH_HOSTKEY_RSA_SHA256
rsa-sha2-256 (HW-accelerated on ESP32)
@ SSH_HOSTKEY_ED25519
ssh-ed25519 (RFC 8032)
SshKexAlg
SSH transport/session state for one connection (BSS pool).
@ SSH_KEX_MLKEM768_X25519
mlkem768x25519-sha256 (PQ/T hybrid, draft-ietf-sshm-mlkem-hybrid-kex)
@ SSH_KEX_ECDH_NISTP256
ecdh-sha2-nistp256 (NIST P-256 ECDH, RFC 5656 §4)
@ SSH_KEX_CURVE25519
curve25519-sha256 (RFC 8731, X25519)
@ SSH_KEX_DH_GROUP14
diffie-hellman-group14-sha256 (HW-accelerated MPI on ESP32)
@ SSH_PHASE_KEXINIT
Awaiting the client KEXINIT.
@ SSH_PHASE_SERVICE
Awaiting SERVICE_REQUEST ("ssh-userauth").
@ SSH_PHASE_OPEN
Authenticated; connection/channel protocol active.
@ SSH_PHASE_NEWKEYS
Awaiting SSH_MSG_NEWKEYS.
@ SSH_PHASE_BANNER
Awaiting the client identification string.
@ SSH_PHASE_DH_INIT
Awaiting SSH_MSG_KEXDH_INIT.
#define SSH_SERVER_VERSION
Server identification string (no CR LF; appended on the wire).
const char * name
A 2048-bit unsigned integer stored as 64 little-endian 32-bit limbs.
Definition ssh_bignum.h:96
bool enc_out
True once we have sent our NEWKEYS (outbound cipher/MAC active).
Definition ssh_packet.h:153
uint32_t seq_no_recv
Incoming sequence number (incremented per packet).
Definition ssh_packet.h:147
bool enc_in
True once we have received the peer's NEWKEYS (inbound cipher/MAC active).
Definition ssh_packet.h:154
bool kex_active
True while KEX is in progress (no user data).
Definition ssh_packet.h:148
uint32_t seq_no_send
Outgoing sequence number (incremented per packet).
Definition ssh_packet.h:146
bool loaded
True after ssh_rsa_load_pubkey() succeeds.
Definition ssh_rsa.h:211
bool ext_info_c
Client advertised ext-info-c (RFC 8308): send EXT_INFO.
uint8_t banner_buf[SSH_VERSION_MAX]
Accumulator for the inbound banner.
SshKexAlg kex_alg
negotiated in KEXINIT.
uint8_t ecdh_pk[32]
Server X25519 ephemeral public (curve25519 KEX only).
SshHostkeyAlg hostkey_alg
negotiated in KEXINIT.
uint16_t i_c_len
Length of i_c.
uint8_t session_id[SSH_SHA256_DIGEST_LEN]
H from the first KEX (RFC 4253 §7.2).
bool have_session_id
True once the first KEX completes.
SshPhase phase
Current handshake phase.
char v_c[SSH_VERSION_MAX]
Client identification string (no CR LF).
uint16_t i_s_len
Length of i_s.
bool authed
True after successful user authentication.
uint8_t mac_alg
SSH_MAC_* negotiated in KEXINIT (aes cipher only; 0 = hmac-sha2-256).
uint16_t banner_len
Bytes buffered in banner_buf.
uint8_t i_c[SSH_KEXINIT_MAX]
Client KEXINIT payload (for H).
uint32_t last_kex_ms
detws_millis() when the last KEX completed (server-initiated re-key timer).
uint8_t i_s[SSH_KEXINIT_S_MAX]
Server KEXINIT payload (for H).
uint8_t ecdh_sk[32]
Server X25519 ephemeral private (curve25519 KEX only; wiped after).
uint8_t cipher_alg
SSH_CIPHER_* negotiated in KEXINIT (0 = aes256-ctr).
uint16_t v_c_len
Length of v_c.
Streaming SHA-256 context.
Definition ssh_sha256.h:44
uint8_t ecdsa_priv[SSH_ECDSA_P256_PRIV_LEN]
P-256 host private scalar d.
uint8_t ed_pub[32]
uint8_t ecdsa_pub[SSH_ECDSA_P256_PUB_LEN]
P-256 host public point (0x04||X||Y).
uint8_t ed_seed[32]