ProtoCore v0.0.2
Deterministic, zero-heap network stack for embedded targets
Loading...
Searching...
No Matches
ssh_client.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_client.cpp
6 * @brief Outbound SSH client + reverse tunnel state machine (see ssh_client.h).
7 *
8 * Client-role driver over the shipped transport primitives: it reuses the role-aware binary packet
9 * layer (ssh_pkt_* with ssh_pkt_set_client), the curve25519 / ed25519 / chacha-poly crypto, and the
10 * RFC 4253 §7.2 KDF. Only the client-side handshake, auth, and forward logic lives here.
11 */
12
14#include "server/mmgr/secure.h"
15#include "shared_primitives/strbuf.h" // pc_sb frame builder
16
17#if PC_ENABLE_SSH_CLIENT
18
19#include "crypto/asymmetric/bignum.h" // bn_expmod_group14 (dh-group14 client)
20#include "crypto/asymmetric/curve25519.h" // pc_x25519 (curve25519-sha256)
21#include "crypto/asymmetric/ecdsa.h" // ecdh-sha2-nistp256 + ecdsa host-key verify
22#include "crypto/asymmetric/ed25519.h" // ssh-ed25519 host key + client auth
23#include "crypto/hash/sha256.h"
24#include "network_drivers/presentation/ssh/crypto/ssh_kexhash.h" // SshKexHash (SHA-256/SHA-512 by method)
25#include "network_drivers/presentation/ssh/crypto/ssh_rsa.h" // rsa-sha2-256/512 host-key verify
26#include "network_drivers/presentation/ssh/transport/ssh_dh.h" // ssh_dh_derive_keys_sid, ssh_rng_fill
27#include "network_drivers/presentation/ssh/transport/ssh_keymat.h" // ssh_keys[], SshKeyMat, SSH_CIPHER_*, SSH_MAC_*
30#include <string.h>
31
32#if PC_ENABLE_PQC_KEX
33#include "crypto/pqc/mlkem.h" // mlkem768x25519-sha256 hybrid (client: KeyGen + Decaps)
34#endif
35#if PC_ENABLE_SSH_SNTRUP761
36#include "crypto/pqc/sntrup761.h" // sntrup761x25519-sha512 hybrid (client: KeyGen + Decaps)
37#endif
38#if PC_ENABLE_PQC_KEX || PC_ENABLE_SSH_SNTRUP761
39#include "server/mmgr/scratch.h" // scratch_alloc for the large hybrid C_INIT
40#endif
41
42#if defined(ARDUINO)
43#include "network_drivers/session/worker.h" // pc_worker_set_self (own scratch slot)
44#include "network_drivers/transport/client.h" // pc_client_*
45#include "services/system/clock.h" // pc_millis, pcdelay
46#endif
47
48// ---------------------------------------------------------------------------
49// Constants
50// ---------------------------------------------------------------------------
51
52static const char CLIENT_BANNER[] = "SSH-2.0-PC_client_1.0";
53
54// Algorithm names, in the client's preference order per category (first = most preferred). The
55// client offers every algorithm and negotiates whatever the relay supports. It reuses the transport
56// crypto, so each primitive uses whatever hardware the target provides: RSA/DH on the MPI unit,
57// AES/SHA on their units, and NIST P-256 (ecdh-sha2-nistp256 + ecdsa-sha2-nistp256) on the ECC /
58// ECDSA accelerators where they exist - on the P4 mbedTLS's ecc_alt/ecdsa_alt route to them
59// (esp_ecc_point_multiply / esp_ecdsa_verify), HW-measured ~5 ms/point-mul. curve25519/ed25519 and
60// ML-KEM-768 have no hardware path on any ESP32 (the ECC unit does only NIST prime curves), so those
61// run in software on every variant.
62static const char NAME_ED25519[] = "ssh-ed25519";
63// KEX_NAMES and KEX_OF (defined after CliKex, below) are index-aligned: negotiate() returns an index
64// into KEX_NAMES, KEX_OF maps it to the CliKex. The PQ/T hybrid leads when built (PQC-preferred).
65static const char *const KEX_NAMES[] = {
66#if PC_ENABLE_PQC_KEX
67 "mlkem768x25519-sha256",
68#endif
69#if PC_ENABLE_SSH_SNTRUP761
70 "sntrup761x25519-sha512@openssh.com",
71#endif
72 "curve25519-sha256",
73 "curve25519-sha256@libssh.org",
74 "ecdh-sha2-nistp256",
75 "diffie-hellman-group14-sha256"};
76static const char *const HOSTKEY_NAMES[] = {"ssh-ed25519", "ecdsa-sha2-nistp256", "rsa-sha2-512", "rsa-sha2-256"};
77static const char *const CIPHER_NAMES[] = {"chacha20-poly1305@openssh.com", "aes256-gcm@openssh.com", "aes256-ctr"};
78static const char *const MAC_NAMES[] = {"hmac-sha2-256-etm@openssh.com", "hmac-sha2-256",
79 "hmac-sha2-512-etm@openssh.com", "hmac-sha2-512"};
80
81/** @brief Negotiated key-exchange method. */
82enum class CliKex : uint8_t
83{
84 CURVE25519,
85 ECDH_P256,
86 DH_GROUP14,
87#if PC_ENABLE_PQC_KEX
88 MLKEM768_X25519, ///< mlkem768x25519-sha256 PQ/T hybrid (PC_ENABLE_PQC_KEX).
89#endif
90#if PC_ENABLE_SSH_SNTRUP761
91 SNTRUP761_X25519, ///< sntrup761x25519-sha512@openssh.com PQ/T hybrid (PC_ENABLE_SSH_SNTRUP761).
92#endif
93};
94// Index-aligned with KEX_NAMES above (two curve25519 spellings map to the same method).
95static const CliKex KEX_OF[] = {
96#if PC_ENABLE_PQC_KEX
97 CliKex::MLKEM768_X25519,
98#endif
99#if PC_ENABLE_SSH_SNTRUP761
100 CliKex::SNTRUP761_X25519,
101#endif
102 CliKex::CURVE25519, CliKex::CURVE25519, CliKex::ECDH_P256, CliKex::DH_GROUP14};
103// The exchange hash + RFC 4253 sec 7.2 KDF run over SHA-512 for the -sha512 methods
104// (sntrup761x25519-sha512), SHA-256 for every other method (RFC 4253 sec 8).
105static inline bool cli_kex_is_sha512(CliKex k)
106{
107#if PC_ENABLE_SSH_SNTRUP761
108 return k == CliKex::SNTRUP761_X25519;
109#else
110 (void)k;
111 return false;
112#endif
113}
114/** @brief Negotiated host-key / signature algorithm. */
115enum class CliHostkey : uint8_t
116{
117 ED25519,
118 ECDSA_P256,
119 RSA_SHA512,
120 RSA_SHA256
121};
122
123#define SSH_CLI_SLOT 0 // the SSH packet/key pool slot the client borrows (MAX_SSH_CONNS >= 1)
124
125// ---------------------------------------------------------------------------
126// Wire helpers (SSH data types, RFC 4251 §5)
127// ---------------------------------------------------------------------------
128
129namespace
130{
131struct Wr
132{
133 uint8_t *buf;
134 size_t cap;
135 size_t off;
136 bool ok;
137};
138
139void w_u8(Wr *w, uint8_t v)
140{
141 if (w->off + 1 > w->cap)
142 {
143 w->ok = false;
144 return;
145 }
146 w->buf[w->off++] = v;
147}
148void w_u32(Wr *w, uint32_t v)
149{
150 if (w->off + 4 > w->cap)
151 {
152 w->ok = false;
153 return;
154 }
155 w->buf[w->off++] = (uint8_t)(v >> 24);
156 w->buf[w->off++] = (uint8_t)(v >> 16);
157 w->buf[w->off++] = (uint8_t)(v >> 8);
158 w->buf[w->off++] = (uint8_t)v;
159}
160void w_bytes(Wr *w, const uint8_t *d, size_t n)
161{
162 if (w->off + n > w->cap)
163 {
164 w->ok = false;
165 return;
166 }
167 memcpy(w->buf + w->off, d, n);
168 w->off += n;
169}
170void w_string(Wr *w, const void *d, size_t n)
171{
172 w_u32(w, (uint32_t)n);
173 w_bytes(w, (const uint8_t *)d, n);
174}
175void w_cstr(Wr *w, const char *s)
176{
177 w_string(w, s, strnlen(s, w->cap)); // a field can never exceed the writer's own capacity
178}
179
180// Reader over a payload with bounds checking.
181struct Rd
182{
183 const uint8_t *buf;
184 size_t len;
185 size_t off;
186 bool ok;
187};
188uint8_t r_u8(Rd *r)
189{
190 if (r->off + 1 > r->len)
191 {
192 r->ok = false;
193 return 0;
194 }
195 return r->buf[r->off++];
196}
197uint32_t r_u32(Rd *r)
198{
199 if (r->off + 4 > r->len)
200 {
201 r->ok = false;
202 return 0;
203 }
204 uint32_t v = ((uint32_t)r->buf[r->off] << 24) | ((uint32_t)r->buf[r->off + 1] << 16) |
205 ((uint32_t)r->buf[r->off + 2] << 8) | (uint32_t)r->buf[r->off + 3];
206 r->off += 4;
207 return v;
208}
209// Returns a pointer to an in-place string of length *n; advances past it. Fails closed on overflow.
210const uint8_t *r_string(Rd *r, uint32_t *n)
211{
212 uint32_t l = r_u32(r);
213 if (!r->ok || r->off + l > r->len)
214 {
215 r->ok = false;
216 *n = 0;
217 return nullptr;
218 }
219 const uint8_t *p = r->buf + r->off;
220 r->off += l;
221 *n = l;
222 return p;
223}
224
225// Does a comma-separated SSH name-list contain @p want as a whole entry?
226bool namelist_has(const uint8_t *list, uint32_t len, const char *want)
227{
228 size_t wl = strnlen(want, (size_t)len + 1); // a whole-entry match cannot exceed the list
229 uint32_t start = 0;
230 for (uint32_t i = 0; i <= len; i++)
231 {
232 if (i == len || list[i] == ',')
233 {
234 if (i - start == wl && memcmp(list + start, want, wl) == 0)
235 {
236 return true;
237 }
238 start = i + 1;
239 }
240 }
241 return false;
242}
243
244// Hash an SSH string (u32 length + bytes) into the running exchange hash (SHA-256 or SHA-512).
245void hash_string(SshKexHash *c, const uint8_t *d, size_t n)
246{
247 uint8_t l[4] = {(uint8_t)(n >> 24), (uint8_t)(n >> 16), (uint8_t)(n >> 8), (uint8_t)n};
248 ssh_kexhash_update(c, l, 4);
249 ssh_kexhash_update(c, d, n);
250}
251// Hash an SSH mpint (two's-complement, minimal, leading 0x00 when the high bit is set).
252void hash_mpint(SshKexHash *c, const uint8_t *be, size_t n)
253{
254 size_t i = 0;
255 while (i < n && be[i] == 0)
256 {
257 i++; // strip leading zeros
258 }
259 size_t mlen = n - i;
260 bool pad = (mlen > 0 && (be[i] & 0x80) != 0);
261 uint8_t l4[4] = {0, 0, 0, (uint8_t)(mlen + (pad ? 1 : 0))};
262 l4[2] = (uint8_t)((mlen + (pad ? 1 : 0)) >> 8);
263 ssh_kexhash_update(c, l4, 4);
264 if (pad)
265 {
266 uint8_t z = 0;
267 ssh_kexhash_update(c, &z, 1);
268 }
269 if (mlen)
270 {
271 ssh_kexhash_update(c, be + i, mlen);
272 }
273}
274} // namespace
275
276// ---------------------------------------------------------------------------
277// Client session state
278// ---------------------------------------------------------------------------
279
280enum class CliPhase : uint8_t
281{
282 IDLE,
283 BANNER, ///< reading the server identification string.
284 KEXINIT, ///< sent our KEXINIT; awaiting the server's.
285 KEXREPLY, ///< sent KEXDH_INIT; awaiting KEXDH_REPLY.
286 NEWKEYS, ///< sent our NEWKEYS; awaiting the server's.
287 SERVICE, ///< sent SERVICE_REQUEST; awaiting SERVICE_ACCEPT.
288 AUTH, ///< sent USERAUTH_REQUEST; awaiting SUCCESS/FAILURE.
289 FORWARD, ///< sent tcpip-forward; awaiting REQUEST_SUCCESS.
290 OPEN, ///< tunnel up; servicing forwarded-tcpip channels.
291 FAILED
292};
293
294// How many forwarded-tcpip connections the tunnel bridges at once (PC_SSH_CLIENT_MAX_CHANNELS,
295// defaulted per variant in protocore_config.h / the board profile): a relay forwarding to a web UI opens
296// one channel per inbound TCP connection, so a pool (not a single slot) avoids rapid / concurrent
297// requests getting "administratively prohibited". Each slot costs a CliChannel + a pc_client conn.
298
299// One forwarded-tcpip channel bridged to a local TCP connection.
300struct CliChannel
301{
302 bool used;
303 uint32_t local_id; ///< our channel id.
304 uint32_t remote_id; ///< the relay's channel id.
305 uint32_t send_win; ///< bytes we may still send to the relay (their window to us).
306 uint32_t recv_win; ///< bytes the relay may still send us before we WINDOW_ADJUST.
307 int local_cid; ///< pc_client id of the bridged local TCP connection, or -1.
308 bool eof_sent;
309 bool relay_eof; ///< the relay half-closed (peer done sending); tear down once the response drains.
310};
311
312struct SshClientCtx
313{
314 pc_ssh_tunnel_cfg cfg;
315 CliPhase phase;
316 pc_ssh_tunnel_state state;
317
318 int cid; ///< relay TCP connection (pc_client), or -1.
319 uint32_t deadline_ms;
320
321 CliKex kex; ///< negotiated key exchange.
322 CliHostkey hostkey; ///< negotiated host-key / signature type.
323 uint8_t cipher; ///< negotiated SSH_CIPHER_*.
324 uint8_t mac; ///< negotiated SSH_MAC_* (used only when cipher == aes256-ctr).
325
326 uint8_t kex_priv[32]; ///< our KEX private: X25519 scalar / P-256 d / DH exponent (wiped after K).
327 uint8_t qc[256]; ///< our KEX public Q_C: 32 (curve/hybrid X25519) / 65 (ecdh) / 256 (DH e, big-endian).
328 size_t qc_len;
329#if PC_ENABLE_PQC_KEX || PC_ENABLE_SSH_SNTRUP761
330 // Hybrid decapsulation key: persists from C_INIT (KeyGen) to S_REPLY (Decaps) across round-trips,
331 // so it cannot live in the per-dispatch scratch arena. Only one hybrid is negotiated per session, so
332 // ML-KEM's dk and sntrup761's sk share storage. Each embeds its public key (ek at [1152..], pk at
333 // PC_SNTRUP761_SK_PK_OFFSET), so the C_INIT / exchange-hash reconstruct it from here, not stored twice.
334 union {
335#if PC_ENABLE_PQC_KEX
336 uint8_t mlkem_dk[MLKEM768_DK_BYTES];
337#endif
338#if PC_ENABLE_SSH_SNTRUP761
339 uint8_t sntrup_sk[PC_SNTRUP761_SK_BYTES];
340#endif
341 } hyb;
342#endif
343
344 char v_s[256]; ///< server identification string (no CR LF).
345 uint16_t v_s_len;
346 uint8_t banner[256]; ///< inbound banner accumulator.
347 uint16_t banner_len;
348
349 uint8_t i_c[768]; ///< our KEXINIT payload (for H) - the full advertised suite is ~520 bytes.
350 uint16_t i_c_len;
351 uint8_t i_s[SSH_KEXINIT_MAX]; ///< server KEXINIT payload (for H); OpenSSH's is ~1.1 KB.
352 uint16_t i_s_len;
353
354 uint8_t session_id[SSH_KEXHASH_MAX_LEN]; ///< 32 (SHA-256 methods) or 64 (sntrup761 SHA-512).
355 uint8_t session_id_len;
356 bool have_sid;
357
358 CliChannel chan[PC_SSH_CLIENT_MAX_CHANNELS]; ///< the active forwarded channels.
359 uint32_t next_chan_id; ///< id to assign the next channel.
360
361 uint8_t wire[SSH_WIRE_CAP]; ///< staging buffer for one outgoing (framed/encrypted) packet.
362};
363
364static SshClientCtx s_cli;
365
366// Find an active channel by the id we assigned it (inbound messages address our local_id), or nullptr.
367static CliChannel *chan_by_local(uint32_t local_id)
368{
369 for (int i = 0; i < PC_SSH_CLIENT_MAX_CHANNELS; i++)
370 {
371 if (s_cli.chan[i].used && s_cli.chan[i].local_id == local_id)
372 {
373 return &s_cli.chan[i];
374 }
375 }
376 return nullptr;
377}
378
379// Claim a free channel slot, or nullptr if all are in use.
380static CliChannel *chan_alloc(void)
381{
382 for (int i = 0; i < PC_SSH_CLIENT_MAX_CHANNELS; i++)
383 {
384 if (!s_cli.chan[i].used)
385 {
386 return &s_cli.chan[i];
387 }
388 }
389 return nullptr;
390}
391
392#if defined(ARDUINO)
393
394// ---------------------------------------------------------------------------
395// Transmit
396// ---------------------------------------------------------------------------
397
398// Frame @p payload as a binary packet (encrypted once NEWKEYS is active) and write it to the relay.
399static bool cli_send(const uint8_t *payload, size_t len)
400{
401 size_t wlen = 0;
402 if (ssh_pkt_send(SSH_CLI_SLOT, payload, len, s_cli.wire, &wlen, sizeof(s_cli.wire)) != 0)
403 {
404 return false;
405 }
406 return pc_client_send(s_cli.cid, s_cli.wire, wlen);
407}
408
409// Log frames: each message's shape is fixed here, so nothing is parsed when one is emitted.
410static const pc_field LOG_TUNNEL_FAIL[] = {{PC_FK_LIT, 0, 12, "ssh-tunnel: "}, PC_STR, PC_END};
411static const pc_field LOG_TUNNEL_NEGOTIATED[] = {{PC_FK_LIT, 0, 27, "ssh-tunnel: negotiated kex="},
412 PC_STR,
413 {PC_FK_LIT, 0, 9, " hostkey="},
414 PC_STR,
415 {PC_FK_LIT, 0, 8, " cipher="},
416 PC_STR,
417 PC_END};
418static const pc_field LOG_TUNNEL_FWD_OPEN[] = {{PC_FK_LIT, 0, 49, "ssh-tunnel: forwarded-tcpip open, local connect(:"},
419 PC_U32,
420 {PC_FK_LIT, 0, 6, ") cid="},
421 PC_I64,
422 PC_END};
423static const pc_field LOG_TUNNEL_UP[] = {
424 {PC_FK_LIT, 0, 31, "ssh-tunnel: up (relay forward :"}, PC_U32, {PC_FK_LIT, 0, 1, ")"}, PC_END};
425
426static void cli_fail(const char *why)
427{
428 PC_LOGW(LOG_TUNNEL_FAIL, why);
429 s_cli.phase = CliPhase::FAILED;
430 s_cli.state = pc_ssh_tunnel_state::PC_TUN_FAILED;
431 for (int i = 0; i < PC_SSH_CLIENT_MAX_CHANNELS; i++)
432 {
433 if (s_cli.chan[i].used && s_cli.chan[i].local_cid >= 0)
434 {
435 pc_client_close(s_cli.chan[i].local_cid);
436 }
437 }
438 if (s_cli.cid >= 0)
439 {
440 pc_client_close(s_cli.cid);
441 }
442 s_cli.cid = -1;
443 ssh_keymat_wipe(SSH_CLI_SLOT);
444 pc_secure_wipe(s_cli.kex_priv, sizeof(s_cli.kex_priv));
445}
446
447// ---------------------------------------------------------------------------
448// Algorithm negotiation + KEXINIT (client)
449// ---------------------------------------------------------------------------
450
451// Write a comma-joined SSH name-list from @p names into the writer as one string.
452static void w_namelist(Wr *w, const char *const *names, size_t n)
453{
454 char tmp[256];
455 size_t o = 0;
456 for (size_t i = 0; i < n; i++)
457 {
458 size_t l = strnlen(names[i], sizeof(tmp));
459 if (i && o + 1 <= sizeof(tmp))
460 {
461 tmp[o++] = ',';
462 }
463 if (o + l <= sizeof(tmp))
464 {
465 memcpy(tmp + o, names[i], l);
466 o += l;
467 }
468 }
469 w_string(w, tmp, o);
470}
471
472// RFC 4253 §7.1: the negotiated algorithm is the first on the CLIENT's list that the server also
473// offers. Returns the client-preference index, or -1 if there is no overlap.
474static int negotiate(const uint8_t *slist, uint32_t slen, const char *const *prefs, size_t nprefs)
475{
476 for (size_t i = 0; i < nprefs; i++)
477 {
478 if (namelist_has(slist, slen, prefs[i]))
479 {
480 return (int)i;
481 }
482 }
483 return -1;
484}
485
486// Copy an mpint's value (the string content) right-aligned, big-endian, into out[width]; strips a
487// leading 0x00 sign byte. Returns false if the magnitude does not fit @p width.
488static bool mpint_to_fixed(const uint8_t *v, uint32_t vlen, uint8_t *out, size_t width)
489{
490 uint32_t i = 0;
491 while (i < vlen && v[i] == 0)
492 {
493 i++;
494 }
495 uint32_t mag = vlen - i;
496 if (mag > width)
497 {
498 return false;
499 }
500 memset(out, 0, width);
501 memcpy(out + (width - mag), v + i, mag);
502 return true;
503}
504
505static bool build_kexinit(void)
506{
507 Wr w = {s_cli.i_c, sizeof(s_cli.i_c), 0, true};
508 w_u8(&w, SSH_MSG_KEXINIT);
509 uint8_t cookie[16];
510 ssh_rng_fill(cookie, 16);
511 w_bytes(&w, cookie, 16);
512 w_namelist(&w, KEX_NAMES, sizeof(KEX_NAMES) / sizeof(KEX_NAMES[0])); // kex
513 w_namelist(&w, HOSTKEY_NAMES, sizeof(HOSTKEY_NAMES) / sizeof(HOSTKEY_NAMES[0])); // host key
514 w_namelist(&w, CIPHER_NAMES, sizeof(CIPHER_NAMES) / sizeof(CIPHER_NAMES[0])); // enc c2s
515 w_namelist(&w, CIPHER_NAMES, sizeof(CIPHER_NAMES) / sizeof(CIPHER_NAMES[0])); // enc s2c
516 w_namelist(&w, MAC_NAMES, sizeof(MAC_NAMES) / sizeof(MAC_NAMES[0])); // mac c2s
517 w_namelist(&w, MAC_NAMES, sizeof(MAC_NAMES) / sizeof(MAC_NAMES[0])); // mac s2c
518 w_cstr(&w, "none"); // comp c2s
519 w_cstr(&w, "none"); // comp s2c
520 w_cstr(&w, ""); // lang c2s
521 w_cstr(&w, ""); // lang s2c
522 w_u8(&w, 0); // first_kex_packet_follows
523 w_u32(&w, 0); // reserved
524 if (!w.ok)
525 {
526 return false;
527 }
528 s_cli.i_c_len = (uint16_t)w.off;
529 return cli_send(s_cli.i_c, s_cli.i_c_len);
530}
531
532// Generate our KEX ephemeral for the negotiated method and build Q_C / e into s_cli.qc.
533static bool build_kex_public(void)
534{
535 switch (s_cli.kex)
536 {
537 case CliKex::CURVE25519:
538 ssh_rng_fill(s_cli.kex_priv, 32);
539 pc_x25519_base(s_cli.qc, s_cli.kex_priv);
540 s_cli.qc_len = 32;
541 return true;
542 case CliKex::ECDH_P256:
543 // Draw a valid P-256 scalar (pubkey derivation rejects 0 / >= group order).
544 for (int tries = 0; tries < 8; tries++)
545 {
546 ssh_rng_fill(s_cli.kex_priv, 32);
547 if (pc_ecdsa_p256_pubkey(s_cli.qc, s_cli.kex_priv))
548 {
549 s_cli.qc_len = PC_ECDSA_P256_PUB_LEN; // 65
550 return true;
551 }
552 }
553 return false;
554 case CliKex::DH_GROUP14: {
555 // e = g^x mod p, g = 2 (RFC 3526 group 14). x is a 256-bit exponent.
556 ssh_rng_fill(s_cli.kex_priv, 32);
557 pc_bignum g, x, e;
558 uint8_t two = 2;
559 bn_from_bytes(&g, &two, 1);
560 bn_from_bytes(&x, s_cli.kex_priv, 32);
561 bn_expmod_group14(&e, &g, &x);
562 bn_to_bytes(s_cli.qc, &e);
563 s_cli.qc_len = 256;
564 pc_secure_wipe(&x, sizeof(x));
565 pc_secure_wipe(&e, sizeof(e));
566 return true;
567 }
568#if PC_ENABLE_PQC_KEX
569 case CliKex::MLKEM768_X25519: {
570 // ML-KEM-768 keypair (dk kept for Decaps; ek is embedded in dk) + an X25519 ephemeral. C_INIT
571 // (ek || Q_C) is assembled at send time; Q_C lives in qc[0..31].
572 uint8_t d[32], z[32], ek[MLKEM768_EK_BYTES];
573 ssh_rng_fill(d, sizeof(d));
574 ssh_rng_fill(z, sizeof(z));
575 pc_mlkem768_keygen(d, z, ek, s_cli.hyb.mlkem_dk);
576 pc_secure_wipe(d, sizeof(d));
577 pc_secure_wipe(z, sizeof(z));
578 pc_secure_wipe(ek, sizeof(ek)); // ek persists inside mlkem_dk
579 ssh_rng_fill(s_cli.kex_priv, 32);
580 pc_x25519_base(s_cli.qc, s_cli.kex_priv);
581 s_cli.qc_len = 32;
582 return true;
583 }
584#endif
585#if PC_ENABLE_SSH_SNTRUP761
586 case CliKex::SNTRUP761_X25519: {
587 // sntrup761 keypair (sk kept for Decaps; pk is embedded in sk) + an X25519 ephemeral. C_INIT
588 // (pk || Q_C) is assembled at send time from sk; Q_C lives in qc[0..31]. pk is only needed
589 // transiently here (sk embeds a copy), so it borrows the scratch arena.
590 size_t mark = scratch_mark();
591 uint8_t *pk = (uint8_t *)scratch_alloc(PC_SNTRUP761_PK_BYTES, 1);
592 if (!pk)
593 {
594 return false;
595 }
596 pc_sntrup761_keypair(pk, s_cli.hyb.sntrup_sk);
597 scratch_release(mark); // pk persists inside sntrup_sk at PC_SNTRUP761_SK_PK_OFFSET
598 ssh_rng_fill(s_cli.kex_priv, 32);
599 pc_x25519_base(s_cli.qc, s_cli.kex_priv);
600 s_cli.qc_len = 32;
601 return true;
602 }
603#endif
604 }
605 return false;
606}
607
608// Parse the server KEXINIT, negotiate every category, store I_S, and send KEXDH_INIT.
609static bool handle_server_kexinit(const uint8_t *p, size_t len)
610{
611 if (len > sizeof(s_cli.i_s))
612 {
613 return false;
614 }
615 memcpy(s_cli.i_s, p, len);
616 s_cli.i_s_len = (uint16_t)len;
617
618 Rd r = {p, len, 0, true};
619 r_u8(&r); // msg type
620 r.off += 16; // cookie
621 uint32_t kn, hn, cn, mn;
622 const uint8_t *kex = r_string(&r, &kn);
623 const uint8_t *hk = r_string(&r, &hn);
624 const uint8_t *ec = r_string(&r, &cn); // enc c2s
625 r_string(&r, &cn); // enc s2c (same set advertised)
626 const uint8_t *mc = r_string(&r, &mn); // mac c2s
627 if (!r.ok)
628 {
629 return false;
630 }
631
632 int ki = negotiate(kex, kn, KEX_NAMES, sizeof(KEX_NAMES) / sizeof(KEX_NAMES[0]));
633 int hi = negotiate(hk, hn, HOSTKEY_NAMES, sizeof(HOSTKEY_NAMES) / sizeof(HOSTKEY_NAMES[0]));
634 int ci = negotiate(ec, cn, CIPHER_NAMES, sizeof(CIPHER_NAMES) / sizeof(CIPHER_NAMES[0]));
635 if (ki < 0 || hi < 0 || ci < 0)
636 {
637 return false;
638 }
639
640 s_cli.kex = KEX_OF[ki];
641 s_cli.hostkey = (CliHostkey)hi; // HOSTKEY_NAMES order == CliHostkey order
642 static const uint8_t cipher_of[] = {SSH_CIPHER_CHACHA20POLY1305, SSH_CIPHER_AES256GCM, SSH_CIPHER_AES256CTR};
643 s_cli.cipher = cipher_of[ci];
644 PC_LOGI(LOG_TUNNEL_NEGOTIATED, KEX_NAMES[ki], HOSTKEY_NAMES[hi], CIPHER_NAMES[ci]);
645
646 s_cli.mac = SSH_MAC_HMAC_SHA256;
647 if (s_cli.cipher == SSH_CIPHER_AES256CTR)
648 {
649 int mi = negotiate(mc, mn, MAC_NAMES, sizeof(MAC_NAMES) / sizeof(MAC_NAMES[0]));
650 if (mi < 0)
651 {
652 return false;
653 }
656 s_cli.mac = mac_of[mi];
657 }
658
659 if (!build_kex_public())
660 {
661 return false;
662 }
663
664#if PC_ENABLE_PQC_KEX
665 if (s_cli.kex == CliKex::MLKEM768_X25519)
666 {
667 // KEX_HYBRID_INIT (msg 30): string(C_INIT) where C_INIT = ek || Q_C (1216 B). Too large for
668 // the stack packet buffer, so build it in the client's scratch arena.
669 const uint8_t *ek = s_cli.hyb.mlkem_dk + 1152; // ek follows the 1152-byte dk_pke in dk
670 const size_t clen = MLKEM768_EK_BYTES + 32;
671 const size_t plen = 1 + 4 + clen;
672 size_t mark = scratch_mark();
673 uint8_t *out = (uint8_t *)scratch_alloc(plen, 1);
674 if (!out)
675 {
676 return false;
677 }
678 Wr w = {out, plen, 0, true};
679 w_u8(&w, SSH_MSG_KEXDH_INIT);
680 w_u32(&w, (uint32_t)clen);
681 w_bytes(&w, ek, MLKEM768_EK_BYTES);
682 w_bytes(&w, s_cli.qc, 32);
683 bool ok = w.ok && cli_send(out, w.off);
684 scratch_release(mark);
685 return ok;
686 }
687#endif
688#if PC_ENABLE_SSH_SNTRUP761
689 if (s_cli.kex == CliKex::SNTRUP761_X25519)
690 {
691 // KEX_HYBRID_INIT (msg 30): string(C_INIT) where C_INIT = sntrup761_pk || Q_C (1190 B). pk is
692 // reconstructed from sk (it is embedded there); too large for the stack packet buffer, so the
693 // packet is built in the client's scratch arena.
694 const uint8_t *pk = s_cli.hyb.sntrup_sk + PC_SNTRUP761_SK_PK_OFFSET;
695 const size_t clen = PC_SNTRUP761_PK_BYTES + 32;
696 const size_t plen = 1 + 4 + clen;
697 size_t mark = scratch_mark();
698 uint8_t *out = (uint8_t *)scratch_alloc(plen, 1);
699 if (!out)
700 {
701 return false;
702 }
703 Wr w = {out, plen, 0, true};
704 w_u8(&w, SSH_MSG_KEXDH_INIT);
705 w_u32(&w, (uint32_t)clen);
706 w_bytes(&w, pk, PC_SNTRUP761_PK_BYTES);
707 w_bytes(&w, s_cli.qc, 32);
708 bool ok = w.ok && cli_send(out, w.off);
709 scratch_release(mark);
710 return ok;
711 }
712#endif
713
714 // KEXDH_INIT (msg 30): string(Q_C) for curve/ecdh, mpint(e) for DH.
715 uint8_t out[1 + 4 + 260];
716 Wr w = {out, sizeof(out), 0, true};
717 w_u8(&w, SSH_MSG_KEXDH_INIT);
718 if (s_cli.kex == CliKex::DH_GROUP14)
719 {
720 // mpint(e): minimal, with a sign byte if the top bit is set.
721 uint32_t i = 0;
722 while (i < s_cli.qc_len && s_cli.qc[i] == 0)
723 {
724 i++;
725 }
726 size_t mag = s_cli.qc_len - i;
727 bool pad = (mag > 0 && (s_cli.qc[i] & 0x80) != 0);
728 w_u32(&w, (uint32_t)(mag + (pad ? 1 : 0)));
729 if (pad)
730 {
731 w_u8(&w, 0);
732 }
733 w_bytes(&w, s_cli.qc + i, mag);
734 }
735 else
736 {
737 w_string(&w, s_cli.qc, s_cli.qc_len);
738 }
739 return w.ok && cli_send(out, w.off);
740}
741
742// ---------------------------------------------------------------------------
743// KEXDH_REPLY: compute K, exchange hash H, verify the host signature, derive keys, send NEWKEYS
744// ---------------------------------------------------------------------------
745
746// Compute the shared secret K (right-aligned into k_be[256]) for the negotiated method.
747static bool compute_k(const uint8_t *srv_pub, uint32_t srv_pub_len, uint8_t k_be[256])
748{
749 memset(k_be, 0, 256);
750 switch (s_cli.kex)
751 {
752 case CliKex::CURVE25519: {
753 if (srv_pub_len != 32)
754 {
755 return false;
756 }
757 uint8_t k32[32];
758 pc_x25519(k32, s_cli.kex_priv, srv_pub);
759 memcpy(k_be + (256 - 32), k32, 32);
760 pc_secure_wipe(k32, 32);
761 return true;
762 }
763 case CliKex::ECDH_P256: {
764 if (srv_pub_len != PC_ECDSA_P256_PUB_LEN)
765 {
766 return false;
767 }
768 uint8_t k32[PC_ECDSA_P256_COORD_LEN];
769 if (!pc_ecdsa_p256_ecdh(k32, srv_pub, s_cli.kex_priv))
770 {
771 return false;
772 }
773 memcpy(k_be + (256 - 32), k32, 32);
774 pc_secure_wipe(k32, 32);
775 return true;
776 }
777 case CliKex::DH_GROUP14: {
778 pc_bignum f, x, K;
779 bn_from_bytes(&f, srv_pub, srv_pub_len);
780 if (bn_dh_validate(&f) != 0) // 0 = valid (1 < f < p-1)
781 {
782 return false;
783 }
784 bn_from_bytes(&x, s_cli.kex_priv, 32);
785 bn_expmod_group14(&K, &f, &x);
786 bn_to_bytes(k_be, &K);
787 pc_secure_wipe(&x, sizeof(x));
788 pc_secure_wipe(&K, sizeof(K));
789 return true;
790 }
791#if PC_ENABLE_PQC_KEX
792 case CliKex::MLKEM768_X25519: {
793 // S_REPLY = ciphertext(1088) || Q_S(32). Decaps recovers K_PQ; X25519 gives K_CL. The hybrid's
794 // combined secret K = SHA256(K_PQ || K_CL) is a fixed 32-byte string (right-aligned in k_be).
795 if (srv_pub_len != MLKEM768_CT_BYTES + 32)
796 {
797 return false;
798 }
799 uint8_t k_pq[32], k_cl[32];
800 pc_mlkem768_decaps(s_cli.hyb.mlkem_dk, srv_pub, k_pq);
801 pc_x25519(k_cl, s_cli.kex_priv, srv_pub + MLKEM768_CT_BYTES);
803 pc_sha256_init(&c);
804 pc_sha256_update(&c, k_pq, 32);
805 pc_sha256_update(&c, k_cl, 32);
806 pc_sha256_final(&c, k_be + (256 - 32));
807 pc_secure_wipe(k_pq, sizeof(k_pq));
808 pc_secure_wipe(k_cl, sizeof(k_cl));
809 return true;
810 }
811#endif
812#if PC_ENABLE_SSH_SNTRUP761
813 case CliKex::SNTRUP761_X25519: {
814 // S_REPLY = ciphertext(1039) || Q_S(32). Decaps recovers K_PQ; X25519 gives K_CL. The combined
815 // secret K = SHA512(K_PQ || K_CL) is a fixed 64-byte string (right-aligned in k_be).
816 if (srv_pub_len != PC_SNTRUP761_CT_BYTES + 32)
817 {
818 return false;
819 }
820 uint8_t k_pq[PC_SNTRUP761_SS_BYTES], k_cl[32];
821 pc_sntrup761_dec(s_cli.hyb.sntrup_sk, srv_pub, k_pq);
822 pc_x25519(k_cl, s_cli.kex_priv, srv_pub + PC_SNTRUP761_CT_BYTES);
824 pc_sha512_init(&c);
825 pc_sha512_update(&c, k_pq, sizeof(k_pq));
826 pc_sha512_update(&c, k_cl, 32);
827 pc_sha512_final(&c, k_be + (256 - 64));
828 pc_secure_wipe(k_pq, sizeof(k_pq));
829 pc_secure_wipe(k_cl, sizeof(k_cl));
830 return true;
831 }
832#endif
833 }
834 return false;
835}
836
837// Compute the exchange hash H over the negotiated method's field encodings (RFC 4253 §8 / RFC 8731),
838// under the method's hash (SHA-256, or SHA-512 for sntrup761x25519-sha512). Returns the digest length.
839static size_t compute_h(const uint8_t *ks, uint32_t ks_len, const uint8_t *srv_pub, uint32_t srv_pub_len,
840 const uint8_t *k_be, uint8_t H[SSH_KEXHASH_MAX_LEN])
841{
842 const bool is512 = cli_kex_is_sha512(s_cli.kex);
843 SshKexHash c;
844 ssh_kexhash_init(&c, is512);
845 hash_string(&c, (const uint8_t *)CLIENT_BANNER, strnlen(CLIENT_BANNER, sizeof(CLIENT_BANNER))); // V_C
846 hash_string(&c, (const uint8_t *)s_cli.v_s, s_cli.v_s_len); // V_S
847 hash_string(&c, s_cli.i_c, s_cli.i_c_len); // I_C
848 hash_string(&c, s_cli.i_s, s_cli.i_s_len); // I_S
849 hash_string(&c, ks, ks_len); // K_S
850#if PC_ENABLE_PQC_KEX || PC_ENABLE_SSH_SNTRUP761
851 bool hybrid = false;
852 const uint8_t *cpk = nullptr; // the hybrid public embedded in the C_INIT string (ek / sntrup761 pk)
853 size_t cpk_len = 0, k_slen = 0;
854#if PC_ENABLE_PQC_KEX
855 if (s_cli.kex == CliKex::MLKEM768_X25519)
856 {
857 hybrid = true;
858 cpk = s_cli.hyb.mlkem_dk + 1152; // ek follows the 1152-byte dk_pke
859 cpk_len = MLKEM768_EK_BYTES;
860 k_slen = 32; // K = SHA256(K_PQ || K_CL), 32-byte string
861 }
862#endif
863#if PC_ENABLE_SSH_SNTRUP761
864 if (s_cli.kex == CliKex::SNTRUP761_X25519)
865 {
866 hybrid = true;
867 cpk = s_cli.hyb.sntrup_sk + PC_SNTRUP761_SK_PK_OFFSET;
868 cpk_len = PC_SNTRUP761_PK_BYTES;
869 k_slen = 64; // K = SHA512(K_PQ || K_CL), 64-byte string
870 }
871#endif
872 if (hybrid)
873 {
874 // C_INIT = cpk || Q_C hashed as one SSH string; S_REPLY (ct || Q_S) is srv_pub; K is a fixed
875 // 32/64-byte string, not an mpint (draft-ietf-sshm-mlkem-hybrid-kex / RFC 9370).
876 uint32_t clen = (uint32_t)(cpk_len + 32);
877 uint8_t lb[4] = {(uint8_t)(clen >> 24), (uint8_t)(clen >> 16), (uint8_t)(clen >> 8), (uint8_t)clen};
878 ssh_kexhash_update(&c, lb, 4);
879 ssh_kexhash_update(&c, cpk, cpk_len);
880 ssh_kexhash_update(&c, s_cli.qc, 32);
881 hash_string(&c, srv_pub, srv_pub_len); // S_REPLY
882 hash_string(&c, k_be + (256 - k_slen), k_slen); // K (32/64-byte string)
883 }
884 else
885#endif
886 if (s_cli.kex == CliKex::DH_GROUP14)
887 {
888 hash_mpint(&c, s_cli.qc, s_cli.qc_len); // e
889 hash_mpint(&c, srv_pub, srv_pub_len); // f
890 hash_mpint(&c, k_be, 256); // K
891 }
892 else
893 {
894 hash_string(&c, s_cli.qc, s_cli.qc_len); // Q_C
895 hash_string(&c, srv_pub, srv_pub_len); // Q_S
896 hash_mpint(&c, k_be, 256); // K
897 }
898 return ssh_kexhash_final(&c, H);
899}
900
901// Verify the relay's signature over H (h_len bytes) with the host key from K_S, per the host-key type.
902static bool verify_host_sig(const uint8_t *ks, uint32_t ks_len, const uint8_t *sig, uint32_t sig_len, const uint8_t *H,
903 size_t h_len)
904{
905 Rd rk = {ks, ks_len, 0, true};
906 uint32_t tn;
907 const uint8_t *ktype = r_string(&rk, &tn);
908 Rd rs = {sig, sig_len, 0, true};
909 uint32_t sn;
910 const uint8_t *stype = r_string(&rs, &sn);
911 if (!rk.ok || !rs.ok)
912 {
913 return false;
914 }
915
916 switch (s_cli.hostkey)
917 {
918 case CliHostkey::ED25519: {
919 uint32_t pn;
920 const uint8_t *pub = r_string(&rk, &pn);
921 uint32_t rl;
922 const uint8_t *raw = r_string(&rs, &rl);
923 return rk.ok && rs.ok && pn == 32 && rl == 64 && pc_ed25519_verify(pub, H, h_len, raw);
924 }
925 case CliHostkey::ECDSA_P256: {
926 uint32_t cn;
927 r_string(&rk, &cn); // "nistp256"
928 uint32_t qn;
929 const uint8_t *q = r_string(&rk, &qn);
930 // signature: string( mpint(r) || mpint(s) ) -> 64-byte raw r||s.
931 uint32_t bl;
932 const uint8_t *blob = r_string(&rs, &bl);
933 if (!rk.ok || !rs.ok || qn != PC_ECDSA_P256_PUB_LEN)
934 {
935 return false;
936 }
937 Rd rb = {blob, bl, 0, true};
938 uint32_t rlen, slen;
939 const uint8_t *rr = r_string(&rb, &rlen);
940 const uint8_t *ss = r_string(&rb, &slen);
941 uint8_t raw[64];
942 if (!rb.ok || !mpint_to_fixed(rr, rlen, raw, 32) || !mpint_to_fixed(ss, slen, raw + 32, 32))
943 {
944 return false;
945 }
946 return pc_ecdsa_p256_verify(q, H, h_len, raw);
947 }
948 case CliHostkey::RSA_SHA256:
949 case CliHostkey::RSA_SHA512: {
950 // K_S = string("ssh-rsa") || mpint(e) || mpint(n).
951 uint32_t elen, nlen;
952 const uint8_t *e = r_string(&rk, &elen);
953 const uint8_t *n = r_string(&rk, &nlen);
954 uint32_t rawlen;
955 const uint8_t *raw = r_string(&rs, &rawlen); // the RSA signature bytes
956 (void)ktype;
957 (void)stype;
958 uint8_t e4[4], n256[256];
959 if (!rk.ok || !rs.ok || !mpint_to_fixed(e, elen, e4, 4) || !mpint_to_fixed(n, nlen, n256, 256))
960 {
961 return false;
962 }
963 pc_rsa_hash h = (s_cli.hostkey == CliHostkey::RSA_SHA512) ? pc_rsa_hash::SHA512 : pc_rsa_hash::SHA256;
964 return pc_rsa_verify(n256, e4, H, h_len, raw, rawlen, h) == 0;
965 }
966 }
967 return false;
968}
969
970static bool handle_kexdh_reply(const uint8_t *p, size_t len)
971{
972 Rd r = {p, len, 0, true};
973 if (r_u8(&r) != SSH_MSG_KEXDH_REPLY)
974 {
975 return false;
976 }
977 uint32_t ks_len;
978 const uint8_t *ks = r_string(&r, &ks_len); // K_S host-key blob
979 uint32_t sp_len;
980 const uint8_t *srv_pub = r_string(&r, &sp_len); // Q_S (string) or f (mpint)
981 uint32_t sig_len;
982 const uint8_t *sig = r_string(&r, &sig_len); // signature blob
983 if (!r.ok)
984 {
985 return false;
986 }
987
988 // Pin the relay by the SHA-256 fingerprint of its host-key blob (type-agnostic, like known_hosts).
989 uint8_t fp[32];
990 pc_sha256_ctx fc;
991 pc_sha256_init(&fc);
992 pc_sha256_update(&fc, ks, ks_len);
993 pc_sha256_final(&fc, fp);
994 if (memcmp(fp, s_cli.cfg.host_pin, 32) != 0)
995 {
996 cli_fail("relay host key does not match the pin");
997 return false;
998 }
999
1000 uint8_t k_be[256];
1001 if (!compute_k(srv_pub, sp_len, k_be))
1002 {
1003 return false;
1004 }
1005
1006 uint8_t H[SSH_KEXHASH_MAX_LEN];
1007 const size_t h_len = compute_h(ks, ks_len, srv_pub, sp_len, k_be, H); // 32 or 64 by method
1008
1009 if (!verify_host_sig(ks, ks_len, sig, sig_len, H, h_len))
1010 {
1011 pc_secure_wipe(k_be, sizeof(k_be));
1012 cli_fail("relay signature verification failed");
1013 return false;
1014 }
1015
1016 if (!s_cli.have_sid)
1017 {
1018 memcpy(s_cli.session_id, H, h_len);
1019 s_cli.session_id_len = (uint8_t)h_len;
1020 s_cli.have_sid = true;
1021 }
1022
1023 // ssh_dh_derive_keys_sid populates c2s/s2c per the RFC 4253 §7.2 letters for the negotiated
1024 // cipher/MAC; the packet layer's is_client flag selects the send/receive direction. The hybrids
1025 // encode K as a fixed 32/64-byte string (k_is_string); the classical methods as an mpint. The
1026 // -sha512 method derives over SHA-512 (is512), so H and the session_id are 64 bytes.
1027 const bool is512 = cli_kex_is_sha512(s_cli.kex);
1028 bool k_is_string = false;
1029#if PC_ENABLE_PQC_KEX
1030 k_is_string = k_is_string || (s_cli.kex == CliKex::MLKEM768_X25519);
1031#endif
1032#if PC_ENABLE_SSH_SNTRUP761
1033 k_is_string = k_is_string || (s_cli.kex == CliKex::SNTRUP761_X25519);
1034#endif
1035 ssh_dh_derive_keys_sid(SSH_CLI_SLOT, k_be, H, s_cli.session_id, s_cli.cipher, s_cli.mac, k_is_string, h_len,
1036 s_cli.session_id_len, is512);
1037 pc_secure_wipe(k_be, sizeof(k_be));
1038 pc_secure_wipe(s_cli.kex_priv, sizeof(s_cli.kex_priv));
1039#if PC_ENABLE_PQC_KEX || PC_ENABLE_SSH_SNTRUP761
1040 pc_secure_wipe((uint8_t *)&s_cli.hyb, sizeof(s_cli.hyb));
1041#endif
1042
1043 uint8_t nk = SSH_MSG_NEWKEYS;
1044 if (!cli_send(&nk, 1))
1045 {
1046 return false;
1047 }
1048 ssh_pkt[SSH_CLI_SLOT].enc_out = true;
1049 return true;
1050}
1051
1052// ---------------------------------------------------------------------------
1053// Auth (publickey, ssh-ed25519)
1054// ---------------------------------------------------------------------------
1055
1056static bool send_service_request(void)
1057{
1058 uint8_t out[1 + 4 + 12];
1059 Wr w = {out, sizeof(out), 0, true};
1060 w_u8(&w, SSH_MSG_SERVICE_REQUEST);
1061 w_cstr(&w, "ssh-userauth");
1062 return w.ok && cli_send(out, w.off);
1063}
1064
1065static bool send_userauth_publickey(void)
1066{
1067 const char *user = s_cli.cfg.user;
1068 uint8_t pub[32];
1069 pc_ed25519_pubkey(pub, s_cli.cfg.auth_seed);
1070
1071 // The device's public-key blob: string("ssh-ed25519") || string(pub32).
1072 uint8_t pkblob[4 + 11 + 4 + 32];
1073 Wr pw = {pkblob, sizeof(pkblob), 0, true};
1074 w_cstr(&pw, NAME_ED25519);
1075 w_string(&pw, pub, 32);
1076 if (!pw.ok)
1077 {
1078 return false;
1079 }
1080
1081 // Data to sign (RFC 4252 §7): string(session_id) || the userauth request up to (and including)
1082 // the public-key blob, with the "signature present" flag set. session_id is 32 or 64 bytes (the
1083 // -sha512 KEX), so the buffer carries SSH_KEXHASH_MAX_LEN of headroom over the 32-byte base.
1084 uint8_t signed_data[256 + SSH_KEXHASH_MAX_LEN];
1085 Wr sd = {signed_data, sizeof(signed_data), 0, true};
1086 w_string(&sd, s_cli.session_id, s_cli.session_id_len);
1087 w_u8(&sd, SSH_MSG_USERAUTH_REQUEST);
1088 w_cstr(&sd, user);
1089 w_cstr(&sd, "ssh-connection");
1090 w_cstr(&sd, "publickey");
1091 w_u8(&sd, 1); // signature present
1092 w_cstr(&sd, NAME_ED25519);
1093 w_string(&sd, pkblob, pw.off);
1094 if (!sd.ok)
1095 {
1096 return false;
1097 }
1098
1099 uint8_t sig[64];
1100 pc_ed25519_sign(sig, signed_data, sd.off, s_cli.cfg.auth_seed);
1101
1102 // Signature blob: string("ssh-ed25519") || string(sig64).
1103 uint8_t sigblob[4 + 11 + 4 + 64];
1104 Wr sg = {sigblob, sizeof(sigblob), 0, true};
1105 w_cstr(&sg, NAME_ED25519);
1106 w_string(&sg, sig, 64);
1107 if (!sg.ok)
1108 {
1109 return false;
1110 }
1111
1112 // The full USERAUTH_REQUEST is the signed prefix (minus the session_id) plus the signature.
1113 uint8_t out[300];
1114 Wr w = {out, sizeof(out), 0, true};
1115 w_u8(&w, SSH_MSG_USERAUTH_REQUEST);
1116 w_cstr(&w, user);
1117 w_cstr(&w, "ssh-connection");
1118 w_cstr(&w, "publickey");
1119 w_u8(&w, 1);
1120 w_cstr(&w, NAME_ED25519);
1121 w_string(&w, pkblob, pw.off);
1122 w_string(&w, sigblob, sg.off);
1123 return w.ok && cli_send(out, w.off);
1124}
1125
1126// ---------------------------------------------------------------------------
1127// tcpip-forward
1128// ---------------------------------------------------------------------------
1129
1130static bool send_tcpip_forward(void)
1131{
1132 uint8_t out[128];
1133 Wr w = {out, sizeof(out), 0, true};
1134 w_u8(&w, SSH_MSG_GLOBAL_REQUEST);
1135 w_cstr(&w, "tcpip-forward");
1136 w_u8(&w, 1); // want reply
1137 w_cstr(&w, s_cli.cfg.bind_addr ? s_cli.cfg.bind_addr : "");
1138 w_u32(&w, s_cli.cfg.bind_port);
1139 return w.ok && cli_send(out, w.off);
1140}
1141
1142// ---------------------------------------------------------------------------
1143// forwarded-tcpip channel + bridge
1144// ---------------------------------------------------------------------------
1145
1146#define SSH_CLI_WINDOW 32768u
1147#define SSH_CLI_MAXPKT 16384u
1148
1149static void handle_channel_open(const uint8_t *p, size_t len)
1150{
1151 Rd r = {p, len, 0, true};
1152 r_u8(&r); // msg
1153 uint32_t tn;
1154 const uint8_t *type = r_string(&r, &tn);
1155 uint32_t their_id = r_u32(&r);
1156 uint32_t their_win = r_u32(&r);
1157 r_u32(&r); // their max packet
1158 if (!r.ok || tn != 15 || memcmp(type, "forwarded-tcpip", 15) != 0)
1159 {
1160 // Refuse anything else.
1161 uint8_t out[64];
1162 Wr w = {out, sizeof(out), 0, true};
1164 w_u32(&w, their_id);
1165 w_u32(&w, 1); // administratively prohibited
1166 w_cstr(&w, "only forwarded-tcpip");
1167 w_cstr(&w, "");
1168 if (w.ok)
1169 {
1170 cli_send(out, w.off);
1171 }
1172 return;
1173 }
1174
1175 // Claim a channel slot; refuse if the pool is full.
1176 CliChannel *ch = chan_alloc();
1177 if (!ch)
1178 {
1179 uint8_t out[48];
1180 Wr w = {out, sizeof(out), 0, true};
1182 w_u32(&w, their_id);
1183 w_u32(&w, 4); // resource shortage
1184 w_cstr(&w, "busy");
1185 w_cstr(&w, "");
1186 if (w.ok)
1187 {
1188 cli_send(out, w.off);
1189 }
1190 return;
1191 }
1192
1193 // Open the local bridge connection (to the device's own service).
1194 int lc = pc_client_open("127.0.0.1", s_cli.cfg.local_port, 3000);
1195 PC_LOGD(LOG_TUNNEL_FWD_OPEN, (uint32_t)s_cli.cfg.local_port, (int64_t)lc);
1196 if (lc < 0)
1197 {
1198 uint8_t out[64];
1199 Wr w = {out, sizeof(out), 0, true};
1201 w_u32(&w, their_id);
1202 w_u32(&w, 2); // connect failed
1203 w_cstr(&w, "local connect failed");
1204 w_cstr(&w, "");
1205 if (w.ok)
1206 {
1207 cli_send(out, w.off);
1208 }
1209 return;
1210 }
1211
1212 ch->used = true;
1213 ch->remote_id = their_id;
1214 ch->local_id = s_cli.next_chan_id++;
1215 ch->send_win = their_win;
1216 ch->recv_win = SSH_CLI_WINDOW;
1217 ch->local_cid = lc;
1218 ch->eof_sent = false;
1219 ch->relay_eof = false; // fully self-init this slot; do not lean on channel_close having zeroed it
1220
1221 uint8_t out[64];
1222 Wr w = {out, sizeof(out), 0, true};
1224 w_u32(&w, their_id);
1225 w_u32(&w, ch->local_id);
1226 w_u32(&w, SSH_CLI_WINDOW);
1227 w_u32(&w, SSH_CLI_MAXPKT);
1228 if (w.ok)
1229 {
1230 cli_send(out, w.off);
1231 }
1232}
1233
1234static void channel_close(CliChannel *ch)
1235{
1236 if (!ch || !ch->used)
1237 {
1238 return;
1239 }
1240 uint8_t out[8];
1241 Wr w = {out, sizeof(out), 0, true};
1242 w_u8(&w, SSH_MSG_CHANNEL_CLOSE);
1243 w_u32(&w, ch->remote_id);
1244 if (w.ok)
1245 {
1246 cli_send(out, w.off);
1247 }
1248 if (ch->local_cid >= 0)
1249 {
1250 pc_client_close(ch->local_cid);
1251 }
1252 memset(ch, 0, sizeof(*ch));
1253 ch->local_cid = -1;
1254}
1255
1256// Relay -> device: CHANNEL_DATA is written to the addressed channel's local service.
1257static void handle_channel_data(const uint8_t *p, size_t len)
1258{
1259 Rd r = {p, len, 0, true};
1260 r_u8(&r);
1261 uint32_t rid = r_u32(&r); // our channel id (recipient)
1262 uint32_t dn;
1263 const uint8_t *d = r_string(&r, &dn);
1264 CliChannel *ch = chan_by_local(rid);
1265 if (!r.ok || !ch)
1266 {
1267 return;
1268 }
1269 if (ch->local_cid >= 0 && dn)
1270 {
1271 pc_client_send(ch->local_cid, d, dn);
1272 }
1273
1274 // Refill the relay's window as we consume, so it can keep sending.
1275 if (ch->recv_win >= dn)
1276 {
1277 ch->recv_win -= dn;
1278 }
1279 else
1280 {
1281 ch->recv_win = 0;
1282 }
1283 if (ch->recv_win < SSH_CLI_WINDOW / 2)
1284 {
1285 uint32_t add = SSH_CLI_WINDOW - ch->recv_win;
1286 uint8_t out[16];
1287 Wr w = {out, sizeof(out), 0, true};
1289 w_u32(&w, ch->remote_id);
1290 w_u32(&w, add);
1291 if (w.ok && cli_send(out, w.off))
1292 {
1293 ch->recv_win += add;
1294 }
1295 }
1296}
1297
1298// Device -> relay: drain one channel's local service and forward as CHANNEL_DATA, honoring the peer
1299// window; when its local side has closed and drained, half-close (EOF) then CLOSE that channel.
1300static void pump_channel(CliChannel *ch)
1301{
1302 if (!ch->used || ch->local_cid < 0)
1303 {
1304 return;
1305 }
1306 uint8_t buf[1024];
1307 while (ch->send_win > 0)
1308 {
1309 size_t want = sizeof(buf);
1310 if (want > ch->send_win)
1311 {
1312 want = ch->send_win;
1313 }
1314 if (want > SSH_CLI_MAXPKT)
1315 {
1316 want = SSH_CLI_MAXPKT;
1317 }
1318 size_t got = pc_client_read(ch->local_cid, buf, want);
1319 if (got == 0)
1320 {
1321 break;
1322 }
1323 uint8_t hdr[16];
1324 Wr w = {hdr, sizeof(hdr), 0, true};
1325 w_u8(&w, SSH_MSG_CHANNEL_DATA);
1326 w_u32(&w, ch->remote_id);
1327 w_u32(&w, (uint32_t)got);
1328 // Assemble header + data into the wire staging buffer via one payload.
1329 uint8_t payload[9 + sizeof(buf)];
1330 memcpy(payload, hdr, w.off);
1331 memcpy(payload + w.off, buf, got);
1332 if (!cli_send(payload, w.off + got))
1333 {
1334 break;
1335 }
1336 ch->send_win -= (uint32_t)got;
1337 }
1338 // Tear the channel down once its reply has drained and either side is done: the local service
1339 // closed, or the relay half-closed (relay_eof - the forwarded peer finished, e.g. an HTTP client
1340 // that already has the full response). The drain loop above exits with got==0, so all currently
1341 // available local bytes have been forwarded before we half-close.
1342 bool local_done = pc_client_is_closed(ch->local_cid) && pc_client_available(ch->local_cid) == 0;
1343 if ((local_done || ch->relay_eof) && !ch->eof_sent)
1344 {
1345 uint8_t out[8];
1346 Wr w = {out, sizeof(out), 0, true};
1347 w_u8(&w, SSH_MSG_CHANNEL_EOF);
1348 w_u32(&w, ch->remote_id);
1349 if (w.ok)
1350 {
1351 cli_send(out, w.off);
1352 }
1353 ch->eof_sent = true;
1354 channel_close(ch);
1355 }
1356}
1357
1358// Pump every active channel once per poll.
1359static void pump_local_to_relay(void)
1360{
1361 for (int i = 0; i < PC_SSH_CLIENT_MAX_CHANNELS; i++)
1362 {
1363 if (s_cli.chan[i].used)
1364 {
1365 pump_channel(&s_cli.chan[i]);
1366 }
1367 }
1368}
1369
1370// ---------------------------------------------------------------------------
1371// Inbound message dispatch (called by ssh_pkt_recv per verified packet)
1372// ---------------------------------------------------------------------------
1373
1374static void cli_msg_handler(uint8_t slot, uint8_t type, const uint8_t *payload, size_t len)
1375{
1376 (void)slot;
1377 switch (s_cli.phase)
1378 {
1379 case CliPhase::KEXINIT:
1380 if (type == SSH_MSG_KEXINIT)
1381 {
1382 if (handle_server_kexinit(payload, len))
1383 {
1384 s_cli.phase = CliPhase::KEXREPLY;
1385 }
1386 else
1387 {
1388 cli_fail("KEXINIT negotiation failed");
1389 }
1390 }
1391 break;
1392 case CliPhase::KEXREPLY:
1393 if (type == SSH_MSG_KEXDH_REPLY)
1394 {
1395 if (handle_kexdh_reply(payload, len))
1396 {
1397 s_cli.phase = CliPhase::NEWKEYS;
1398 }
1399 else if (s_cli.phase != CliPhase::FAILED)
1400 {
1401 cli_fail("KEXDH_REPLY invalid");
1402 }
1403 }
1404 break;
1405 case CliPhase::NEWKEYS:
1406 if (type == SSH_MSG_NEWKEYS)
1407 {
1408 ssh_pkt[SSH_CLI_SLOT].enc_in = true;
1409 ssh_pkt[SSH_CLI_SLOT].kex_active = false;
1410 if (send_service_request())
1411 {
1412 s_cli.phase = CliPhase::SERVICE;
1413 }
1414 else
1415 {
1416 cli_fail("service request send failed");
1417 }
1418 }
1419 break;
1420 case CliPhase::SERVICE:
1421 if (type == SSH_MSG_SERVICE_ACCEPT)
1422 {
1423 if (send_userauth_publickey())
1424 {
1425 s_cli.phase = CliPhase::AUTH;
1426 }
1427 else
1428 {
1429 cli_fail("userauth send failed");
1430 }
1431 }
1432 break;
1433 case CliPhase::AUTH:
1434 if (type == SSH_MSG_USERAUTH_SUCCESS)
1435 {
1436 if (send_tcpip_forward())
1437 {
1438 s_cli.phase = CliPhase::FORWARD;
1439 }
1440 else
1441 {
1442 cli_fail("tcpip-forward send failed");
1443 }
1444 }
1445 else if (type == SSH_MSG_USERAUTH_FAILURE)
1446 {
1447 cli_fail("authentication rejected by the relay");
1448 }
1449 break;
1450 case CliPhase::FORWARD:
1451 if (type == SSH_MSG_REQUEST_SUCCESS)
1452 {
1453 s_cli.phase = CliPhase::OPEN;
1454 s_cli.state = pc_ssh_tunnel_state::PC_TUN_UP;
1455 PC_LOGI(LOG_TUNNEL_UP, (uint32_t)s_cli.cfg.bind_port);
1456 }
1457 else if (type == SSH_MSG_REQUEST_FAILURE)
1458 {
1459 cli_fail("relay refused the remote forward");
1460 }
1461 break;
1462 case CliPhase::OPEN:
1463 switch (type)
1464 {
1466 handle_channel_open(payload, len);
1467 break;
1469 handle_channel_data(payload, len);
1470 break;
1472 Rd r = {payload, len, 0, true};
1473 r_u8(&r);
1474 uint32_t rid = r_u32(&r);
1475 uint32_t add = r_u32(&r);
1476 CliChannel *ch = chan_by_local(rid);
1477 if (r.ok && ch)
1478 {
1479 ch->send_win += add;
1480 }
1481 break;
1482 }
1483 case SSH_MSG_CHANNEL_EOF: {
1484 // The relay's write side closed - the forwarded peer is done sending (for a request/
1485 // response bridge, the response has already been delivered). Mark it so the channel tears
1486 // down as soon as the local reply drains, instead of lingering until the relay's CLOSE and
1487 // starving the channel pool. A keep-alive local server never closes on its own.
1488 Rd r = {payload, len, 0, true};
1489 r_u8(&r);
1490 uint32_t rid = r_u32(&r);
1491 CliChannel *ch = chan_by_local(rid);
1492 if (r.ok && ch)
1493 {
1494 ch->relay_eof = true;
1495 }
1496 break;
1497 }
1498 case SSH_MSG_CHANNEL_CLOSE: {
1499 Rd r = {payload, len, 0, true};
1500 r_u8(&r);
1501 uint32_t rid = r_u32(&r);
1502 if (r.ok)
1503 {
1504 channel_close(chan_by_local(rid));
1505 }
1506 break;
1507 }
1509 // A want_reply keepalive gets a REQUEST_FAILURE (we support no global requests inbound).
1510 Rd r = {payload, len, 0, true};
1511 r_u8(&r);
1512 uint32_t nn;
1513 r_string(&r, &nn);
1514 uint8_t wr = r_u8(&r);
1515 if (r.ok && wr)
1516 {
1517 uint8_t f = SSH_MSG_REQUEST_FAILURE;
1518 cli_send(&f, 1);
1519 }
1520 break;
1521 }
1522 default:
1523 break;
1524 }
1525 break;
1526 default:
1527 break;
1528 }
1529}
1530
1531// ---------------------------------------------------------------------------
1532// Public API
1533// ---------------------------------------------------------------------------
1534
1535bool pc_ssh_tunnel_begin(const pc_ssh_tunnel_cfg *cfg)
1536{
1537 if (!cfg || !cfg->host || !cfg->user || !cfg->auth_seed || !cfg->host_pin)
1538 {
1539 return false;
1540 }
1541
1542 pc_ssh_tunnel_end();
1543 memset(&s_cli, 0, sizeof(s_cli));
1544 s_cli.cfg = *cfg;
1545 for (int i = 0; i < PC_SSH_CLIENT_MAX_CHANNELS; i++)
1546 {
1547 s_cli.chan[i].local_cid = -1;
1548 }
1549 s_cli.next_chan_id = 1;
1550
1551 // Own a dedicated scratch arena, distinct from the server's worker(s): packet decryption borrows
1552 // from the shared scratch, and that arena is single-accessor-per-task. begin() and poll() run in
1553 // the same task, so claiming the slot here makes every later decrypt use the client's own arena.
1554 pc_worker_set_self(PC_SSH_CLIENT_SCRATCH_SLOT);
1555
1556 uint16_t port = cfg->port ? cfg->port : 22;
1557 s_cli.cid = pc_client_open(cfg->host, port, 8000);
1558 if (s_cli.cid < 0)
1559 {
1560 s_cli.state = pc_ssh_tunnel_state::PC_TUN_FAILED;
1561 return false;
1562 }
1563
1564 ssh_pkt_init(SSH_CLI_SLOT);
1565 ssh_pkt_set_client(SSH_CLI_SLOT);
1566 ssh_keymat_wipe(SSH_CLI_SLOT);
1567
1568 // Send our identification string, then our KEXINIT.
1569 char banner[64];
1570 pc_sb sb_banner = {banner, sizeof(banner), 0, true};
1571 pc_sb_put(&sb_banner, CLIENT_BANNER);
1572 pc_sb_put(&sb_banner, "\r\n");
1573 int n = (int)pc_sb_finish(&sb_banner);
1574 if (n <= 0 || !pc_client_send(s_cli.cid, (const uint8_t *)banner, (size_t)n))
1575 {
1576 cli_fail("banner send failed");
1577 return false;
1578 }
1579 s_cli.phase = CliPhase::BANNER;
1580 s_cli.state = pc_ssh_tunnel_state::PC_TUN_CONNECTING;
1581 s_cli.deadline_ms = pc_millis() + 15000;
1582 return true;
1583}
1584
1585// Accumulate the server identification line, then hand the rest to the packet layer.
1586static void drain_banner(const uint8_t *data, size_t len, size_t *consumed)
1587{
1588 *consumed = 0;
1589 for (size_t i = 0; i < len; i++)
1590 {
1591 uint8_t ch = data[i];
1592 if (s_cli.banner_len < sizeof(s_cli.banner))
1593 {
1594 s_cli.banner[s_cli.banner_len++] = ch;
1595 }
1596 if (ch == '\n')
1597 {
1598 // A complete line. Only "SSH-..." is the identification; earlier lines are allowed banners.
1599 uint16_t l = s_cli.banner_len;
1600 while (l && (s_cli.banner[l - 1] == '\n' || s_cli.banner[l - 1] == '\r'))
1601 {
1602 l--;
1603 }
1604 if (l >= 4 && memcmp(s_cli.banner, "SSH-", 4) == 0)
1605 {
1606 memcpy(s_cli.v_s, s_cli.banner, l);
1607 s_cli.v_s_len = l;
1608 *consumed = i + 1;
1609 s_cli.phase = CliPhase::KEXINIT;
1610 if (!build_kexinit())
1611 {
1612 cli_fail("KEXINIT send failed");
1613 }
1614 return;
1615 }
1616 s_cli.banner_len = 0; // skip a pre-banner line, keep reading
1617 }
1618 }
1619 *consumed = len; // whole chunk consumed into the accumulator
1620}
1621
1622void pc_ssh_tunnel_poll(void)
1623{
1624 if (s_cli.cid < 0 || s_cli.phase == CliPhase::IDLE || s_cli.phase == CliPhase::FAILED)
1625 {
1626 return;
1627 }
1628
1629 if (pc_client_is_closed(s_cli.cid) && pc_client_available(s_cli.cid) == 0)
1630 {
1631 cli_fail("relay closed the connection");
1632 return;
1633 }
1634 if (s_cli.state == pc_ssh_tunnel_state::PC_TUN_CONNECTING && (int32_t)(pc_millis() - s_cli.deadline_ms) > 0)
1635 {
1636 cli_fail("handshake timed out");
1637 return;
1638 }
1639
1640 uint8_t buf[1024];
1641 size_t got = pc_client_read(s_cli.cid, buf, sizeof(buf));
1642 if (got)
1643 {
1644 size_t off = 0;
1645 if (s_cli.phase == CliPhase::BANNER)
1646 {
1647 size_t consumed = 0;
1648 drain_banner(buf, got, &consumed);
1649 off = consumed;
1650 }
1651 if (off < got && s_cli.phase != CliPhase::FAILED)
1652 {
1653 if (ssh_pkt_recv(SSH_CLI_SLOT, buf + off, got - off, cli_msg_handler) != 0)
1654 {
1655 cli_fail("packet error (MAC / framing)");
1656 }
1657 }
1658 }
1659
1660 if (s_cli.phase == CliPhase::OPEN)
1661 {
1662 pump_local_to_relay();
1663 }
1664}
1665
1666void pc_ssh_tunnel_end(void)
1667{
1668 for (int i = 0; i < PC_SSH_CLIENT_MAX_CHANNELS; i++)
1669 {
1670 if (s_cli.chan[i].used && s_cli.chan[i].local_cid >= 0)
1671 {
1672 pc_client_close(s_cli.chan[i].local_cid);
1673 }
1674 }
1675 if (s_cli.cid >= 0)
1676 {
1677 pc_client_close(s_cli.cid);
1678 }
1679 ssh_keymat_wipe(SSH_CLI_SLOT);
1680 pc_secure_wipe(s_cli.kex_priv, sizeof(s_cli.kex_priv));
1681 memset(&s_cli, 0, sizeof(s_cli));
1682 s_cli.cid = -1;
1683 s_cli.phase = CliPhase::IDLE;
1684 s_cli.state = pc_ssh_tunnel_state::PC_TUN_IDLE;
1685}
1686
1687pc_ssh_tunnel_state pc_ssh_tunnel_state_get(void)
1688{
1689 return s_cli.state;
1690}
1691
1692bool pc_ssh_tunnel_up(void)
1693{
1694 return s_cli.state == pc_ssh_tunnel_state::PC_TUN_UP;
1695}
1696
1697#else // !ARDUINO - host builds have no lwIP client transport; the tunnel is device-only.
1698
1699bool pc_ssh_tunnel_begin(const pc_ssh_tunnel_cfg *)
1700{
1701 return false;
1702}
1703void pc_ssh_tunnel_poll(void)
1704{
1705}
1706void pc_ssh_tunnel_end(void)
1707{
1708}
1709pc_ssh_tunnel_state pc_ssh_tunnel_state_get(void)
1710{
1711 return pc_ssh_tunnel_state::PC_TUN_IDLE;
1712}
1713bool pc_ssh_tunnel_up(void)
1714{
1715 return false;
1716}
1717
1718#endif // ARDUINO
1719
1720// Available on both host and device: pure key derivation for provisioning.
1721void pc_ssh_tunnel_pubkey(const uint8_t seed[32], uint8_t pub[32])
1722{
1723 pc_ed25519_pubkey(pub, seed);
1724}
1725
1726#endif // PC_ENABLE_SSH_CLIENT
void bn_to_bytes(uint8_t bytes[256], const pc_bignum *in)
Write a pc_bignum as a 256-byte big-endian array.
Definition bignum.cpp:96
void bn_from_bytes(pc_bignum *out, const uint8_t *bytes, size_t len)
Read a big-endian byte array of len bytes into a pc_bignum.
Definition bignum.cpp:84
int bn_dh_validate(const pc_bignum *v)
Validate a received DH public value.
Definition bignum.cpp:125
2048-bit big-integer arithmetic for DH-group14 and RSA-2048.
#define PC_SSH_CLIENT_MAX_CHANNELS
Definition c2_defaults.h:91
bool pc_client_send(int, const void *, size_t)
Queue len wire bytes for transmission (marshaled tcp_write + output).
Definition client.cpp:366
size_t pc_client_read(int, uint8_t *, size_t)
Drain up to cap buffered wire bytes into buf; returns the count.
Definition client.cpp:374
bool pc_client_is_closed(int)
True once the peer closed (FIN) or the connection errored.
Definition client.cpp:362
int pc_client_open(const char *, uint16_t, uint32_t)
Resolve host (dotted-quad fast path, else DNS) and connect to host : port, blocking up to timeout_ms.
Definition client.cpp:354
size_t pc_client_available(int)
Wire bytes currently buffered and ready to read.
Definition client.cpp:370
void pc_client_close(int)
Tear down the connection (marshaled) and return the slot to the pool.
Definition client.cpp:378
Layer 4 outbound TCP client transport - the client-side peer of the (server) transport in tcp....
Pluggable monotonic clock for all library timing.
uint32_t pc_millis(void)
The library's monotonic time at 1000 Hz (milliseconds).
Definition clock.h:71
void pc_x25519(uint8_t out[32], const uint8_t scalar[32], const uint8_t point[32])
X25519 scalar multiplication: out = scalar * point (RFC 7748 §5).
void pc_x25519_base(uint8_t out[32], const uint8_t scalar[32])
X25519 with the standard base point u=9: out = scalar * G.
Curve25519 field arithmetic + X25519 (RFC 7748) for the curve25519-sha256 KEX.
bool pc_ecdsa_p256_ecdh(uint8_t shared_x[PC_ECDSA_P256_COORD_LEN], const uint8_t peer_pub[PC_ECDSA_P256_PUB_LEN], const uint8_t priv[PC_ECDSA_P256_PRIV_LEN])
P-256 ECDH: the shared-secret X coordinate of d * Q_peer (RFC 5656 §4 / RFC 5903).
Definition ecdsa.cpp:191
bool pc_ecdsa_p256_verify(const uint8_t pub[PC_ECDSA_P256_PUB_LEN], const uint8_t *msg, size_t mlen, const uint8_t sig[PC_ECDSA_P256_SIG_LEN])
Verify a P-256 ECDSA signature (SHA-256) against an uncompressed public point.
Definition ecdsa.cpp:159
bool pc_ecdsa_p256_pubkey(uint8_t pub[PC_ECDSA_P256_PUB_LEN], const uint8_t priv[PC_ECDSA_P256_PRIV_LEN])
Derive the uncompressed public point Q = d*G from a P-256 private scalar.
Definition ecdsa.cpp:98
NIST P-256 primitives for SSH: ECDSA signatures and ECDH (RFC 5656 / FIPS 186-4).
#define PC_ECDSA_P256_COORD_LEN
P-256 coordinate length (one of X, Y).
Definition ecdsa.h:57
#define PC_ECDSA_P256_PUB_LEN
P-256 uncompressed public point length: 0x04 || X || Y.
Definition ecdsa.h:59
void pc_ed25519_pubkey(uint8_t pub[32], const uint8_t seed[32])
Definition ed25519.cpp:584
bool pc_ed25519_verify(const uint8_t pub[32], const uint8_t *msg, size_t mlen, const uint8_t sig[64])
Definition ed25519.cpp:647
void pc_ed25519_sign(uint8_t sig[64], const uint8_t *msg, size_t mlen, const uint8_t seed[32])
Definition ed25519.cpp:594
Ed25519 signatures (RFC 8032) for ssh-ed25519 host keys + client auth.
#define PC_END
Definition frame.h:110
#define PC_U32
Definition frame.h:104
@ PC_FK_LIT
literal text from lit; takes no argument
Definition frame.h:56
#define PC_I64
Definition frame.h:106
#define PC_STR
Definition frame.h:103
Abstract logging whose disabled levels cost nothing at all (PC_LOG_LEVEL).
#define PC_LOGI(...)
Definition log.h:83
#define PC_LOGW(...)
Definition log.h:89
#define PC_LOGD(...)
Definition log.h:77
ML-KEM-768 (FIPS 203): Encaps (responder) + KeyGen and Decaps (initiator).
void bn_expmod_group14(pc_bignum *out, const pc_bignum *base, const pc_bignum *exp)
#define SSH_KEXINIT_MAX
Max stored size of the CLIENT KEXINIT payload (I_C, for the exchange hash).
int pc_rsa_verify(const uint8_t n_be[PC_RSA_KEY_BYTES], const uint8_t e_be4[4], const uint8_t *msg, size_t msg_len, const uint8_t *sig, size_t sig_len, pc_rsa_hash hash)
Verify an RSA-2048 PKCS#1 v1.5 signature over msg.
Definition rsa.cpp:55
pc_rsa_hash
Hash algorithm selecting the RSA signature scheme (RFC 8017 §9.2).
Definition rsa.h:39
@ SHA256
RSASSA-PKCS1-v1.5 with SHA-256.
@ SHA512
RSASSA-PKCS1-v1.5 with SHA-512.
size_t scratch_mark(void)
Capture the current arena offset (a savepoint for scratch_release()).
Definition scratch.cpp:165
void * scratch_alloc(size_t n, size_t align)
Borrow n bytes of scratch, aligned to align.
Definition scratch.cpp:135
void scratch_release(size_t mark)
Reclaim everything allocated since mark (LIFO).
Definition scratch.cpp:172
Shared per-dispatch scratch arena (Layer 5, session-scoped memory).
Secure pool accessor - borrows that hold key material.
PC_CRYPTO_HOT void pc_sha256_init(pc_sha256_ctx *ctx)
Initialize a streaming SHA-256 context (ctx must not be NULL).
Definition sha256.cpp:29
void pc_sha256_final(pc_sha256_ctx *ctx, uint8_t digest[PC_SHA256_DIGEST_LEN])
Finalize the hash and write the 32-byte digest. The context is undefined afterwards; call init() agai...
Definition sha256.cpp:48
void pc_sha256_update(pc_sha256_ctx *ctx, const uint8_t *data, size_t len)
Feed len bytes of data into the running hash.
Definition sha256.cpp:39
SHA-256 (FIPS 180-4) - streaming context and one-shot API.
void pc_sha512_update(pc_sha512_ctx *ctx, const uint8_t *data, size_t len)
Feed len bytes of data into the running hash.
Definition sha512.cpp:38
PC_CRYPTO_HOT void pc_sha512_init(pc_sha512_ctx *ctx)
Initialize a streaming SHA-512 context (ctx must not be NULL).
Definition sha512.cpp:28
void pc_sha512_final(pc_sha512_ctx *ctx, uint8_t digest[PC_SHA512_DIGEST_LEN])
Finalize the hash and write the 64-byte digest. The context is undefined afterwards; call init() agai...
Definition sha512.cpp:47
Streamlined NTRU Prime sntrup761 KEM - responder (encapsulation) only.
Outbound SSH client + reverse tunnel (PC_ENABLE_SSH_CLIENT).
void ssh_rng_fill(uint8_t *buf, size_t len)
Fill len bytes of buf with cryptographically random data.
Definition ssh_dh.cpp:20
void ssh_dh_derive_keys_sid(uint8_t i, const uint8_t K_be[256], const uint8_t *H, const uint8_t *session_id, uint8_t cipher_alg, uint8_t mac_alg, bool k_is_string, size_t h_len, size_t sid_len, bool is512)
Derive session keys with an explicit session id (RFC 4253 §7.2).
Definition ssh_dh.cpp:167
DH-group14-SHA256 key exchange (RFC 4253 §8 + RFC 8268).
One key-exchange digest that dispatches SHA-256 or SHA-512 by the negotiated method.
#define SSH_KEXHASH_MAX_LEN
longest exchange-hash / session_id (SHA-512)
Definition ssh_kexhash.h:25
SSH session key material - types, pools, and security model.
@ SSH_CIPHER_CHACHA20POLY1305
chacha20-poly1305@openssh.com (AEAD; no separate MAC)
Definition ssh_keymat.h:121
@ SSH_CIPHER_AES256GCM
aes256-gcm@openssh.com (AEAD, RFC 5647; no separate MAC)
Definition ssh_keymat.h:122
@ SSH_CIPHER_AES256CTR
aes256-ctr + a separate HMAC (the fallback)
Definition ssh_keymat.h:120
@ SSH_MAC_HMAC_SHA256_ETM
hmac-sha2-256-etm@openssh.com (encrypt-then-MAC)
Definition ssh_keymat.h:130
@ SSH_MAC_HMAC_SHA256
hmac-sha2-256 (encrypt-and-MAC, RFC 4253)
Definition ssh_keymat.h:128
@ SSH_MAC_HMAC_SHA512
hmac-sha2-512 (encrypt-and-MAC)
Definition ssh_keymat.h:129
@ SSH_MAC_HMAC_SHA512_ETM
hmac-sha2-512-etm@openssh.com (encrypt-then-MAC)
Definition ssh_keymat.h:131
SshPacketState ssh_pkt[MAX_SSH_CONNS]
Static packet state pool (BSS). One entry per SSH slot.
int ssh_pkt_send(uint8_t i, const uint8_t *payload, size_t payload_len, uint8_t *out, size_t *out_len, size_t out_cap)
Build and send one SSH binary packet.
void ssh_pkt_init(uint8_t i)
Initialize the packet state for SSH connection slot i.
void ssh_pkt_set_client(uint8_t i)
Mark slot i as the SSH client role (call once, right after ssh_pkt_init).
int ssh_pkt_recv(uint8_t i, const uint8_t *data, size_t len, ssh_msg_handler_t handler)
Receive and process one or more SSH binary packets from data.
SSH binary packet protocol: framing, AES-256-CTR encryption, HMAC-SHA2-256 integrity,...
#define SSH_MSG_CHANNEL_EOF
Definition ssh_packet.h:122
#define SSH_MSG_CHANNEL_DATA
Definition ssh_packet.h:121
#define SSH_MSG_NEWKEYS
Definition ssh_packet.h:103
#define SSH_MSG_CHANNEL_OPEN_CONFIRM
Definition ssh_packet.h:118
#define SSH_MSG_KEXINIT
Definition ssh_packet.h:102
#define SSH_MSG_USERAUTH_REQUEST
Definition ssh_packet.h:106
#define SSH_MSG_SERVICE_ACCEPT
Definition ssh_packet.h:100
#define SSH_MSG_KEXDH_REPLY
Definition ssh_packet.h:105
#define SSH_MSG_CHANNEL_OPEN
Definition ssh_packet.h:117
#define SSH_MSG_CHANNEL_OPEN_FAILURE
Definition ssh_packet.h:119
#define SSH_MSG_REQUEST_SUCCESS
Definition ssh_packet.h:115
#define SSH_MSG_SERVICE_REQUEST
Definition ssh_packet.h:99
#define SSH_MSG_KEXDH_INIT
Definition ssh_packet.h:104
#define SSH_MSG_USERAUTH_SUCCESS
Definition ssh_packet.h:108
#define SSH_MSG_CHANNEL_CLOSE
Definition ssh_packet.h:123
#define SSH_MSG_USERAUTH_FAILURE
Definition ssh_packet.h:107
#define SSH_MSG_REQUEST_FAILURE
Definition ssh_packet.h:116
#define SSH_WIRE_CAP
Definition ssh_packet.h:191
#define SSH_MSG_GLOBAL_REQUEST
Definition ssh_packet.h:114
#define SSH_MSG_CHANNEL_WINDOW_ADJUST
Definition ssh_packet.h:120
SSH RSA host-key layer: NVS-backed host key, host-key signing, and "ssh-rsa" blob encoding.
Bounded no-heap string builder that fails closed on overflow (one shared copy).
size_t pc_sb_finish(pc_sb *b)
NUL-terminate and return the built length, or 0 if the build overflowed.
Definition strbuf.h:648
void pc_sb_put(pc_sb *b, const char *s)
Append NUL-terminated s; leaves the buffer untouched and clears ok if it would not fit.
Definition strbuf.h:60
A key-exchange digest bound to one of the SSH KEX hashes (SHA-256 or SHA-512).
Definition ssh_kexhash.h:29
bool enc_out
True once we have sent our NEWKEYS (outbound cipher/MAC active).
Definition ssh_packet.h:157
bool enc_in
True once we have received the peer's NEWKEYS (inbound cipher/MAC active).
Definition ssh_packet.h:158
bool kex_active
True while KEX is in progress (no user data).
Definition ssh_packet.h:152
A 2048-bit unsigned integer stored as 64 little-endian 32-bit limbs.
Definition bignum.h:92
One field of a frame. Frames are static const pc_field[], so they live in rodata.
Definition frame.h:80
Bump-append target; ok latches false once an append would overflow cap.
Definition strbuf.h:30
Streaming SHA-256 context.
Definition sha256.h:40
Streaming SHA-512 context.
Definition sha512.h:33
void pc_worker_set_self(int id)
Bind the calling task/thread to worker id id (worker entry / tests).
Definition worker.cpp:41
Layer 5 (Session) - server worker identity.