ProtoCore v0.0.1
Deterministic, zero-heap network stack for embedded targets
Loading...
Searching...
No Matches
ssh_keymat.h
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_keymat.h
6 * @brief SSH session key material - types, pools, and security model.
7 *
8 * ═══════════════════════════════════════════════════════════════════════════
9 * SECURITY MODEL - READ BEFORE MODIFYING ANYTHING IN THIS FILE
10 * ═══════════════════════════════════════════════════════════════════════════
11 *
12 * The fundamental threat this layout defends against is **buffer-overflow
13 * key extraction**: an attacker who can cause an out-of-bounds read or write
14 * in the packet receive path (ssh_pool[].pkt_buf) should not be able to
15 * reach AES session keys or HMAC keys in the same memory operation.
16 *
17 * DEFENSE 1 - Physical BSS separation
18 * ─────────────────────────────────────
19 * All three pools are SEPARATE static symbols:
20 *
21 * ssh_pool[MAX_SSH_CONNS] - packet assembly buffers, protocol state
22 * ssh_keys[MAX_SSH_CONNS] - AES-256 key schedules + HMAC keys
23 * ssh_dh[MAX_SSH_CONNS] - ephemeral DH scalar (y), server public (f), K
24 *
25 * The linker places these as independent objects. An overflow inside
26 * pkt_buf must cross the entire ssh_pool[] symbol, then any objects the
27 * linker placed between it and ssh_keys[], before reaching key material.
28 * On ESP32 with the default linker script this is a different RAM region
29 * than a linear overflow from pkt_buf would reach.
30 *
31 * An attacker relying on a single linear write cannot bridge both gaps in
32 * one step. This is not mitigation-by-obscurity - it is the same "separate
33 * key store" principle used by HSMs, but implemented in software via linker
34 * symbol separation.
35 *
36 * DEFENSE 2 - RSA host private key is NEVER stored in static memory
37 * ──────────────────────────────────────────────────────────────────
38 * The RSA-2048 private key (d, p, q, dp, dq, qInv) is loaded from NVS
39 * (encrypted flash on ESP32) into a LOCAL STACK FRAME inside
40 * ssh_rsa_sign(). It is explicitly zeroed (via volatile memset, which
41 * the compiler cannot elide) before ssh_rsa_sign() returns.
42 *
43 * Consequences:
44 * - A static memory scan never finds the private key.
45 * - Cold-boot attacks recover only the public key from BSS.
46 * - The exposure window is the duration of a single mbedtls_rsa_pkcs1_sign
47 * call, typically < 1 ms.
48 *
49 * DEFENSE 3 - DH ephemeral scalar zeroing
50 * ─────────────────────────────────────────
51 * y (2048-bit private DH scalar) lives in ssh_dh[slot].y. After
52 * ssh_dh_finish() derives K it calls pc_secure_wipe() on the entire SshDhState
53 * struct, which uses a volatile loop to zero all 801 bytes including y,
54 * f, and K. K must be zeroed after session keys are derived from it
55 * (RFC 4253 §7.2 makes no requirement, but it reduces long-term exposure).
56 *
57 * DEFENSE 4 - crypto_work scratch buffer zeroing
58 * ────────────────────────────────────────────────
59 * The Montgomery multiplication working set is borrowed from the secure pool
60 * temporaries (up to 516 bytes of intermediate products that contain
61 * combinations of y, K, and d fragments). It is zeroed via pc_secure_wipe()
62 * immediately after every call to bn_expmod_group14() or ssh_rsa_sign().
63 *
64 * DEFENSE 5 - MAC-verify-before-use (all packet input)
65 * ──────────────────────────────────────────────────────
66 * After key exchange, every inbound SSH binary packet is:
67 * 1. Received into pkt_buf.
68 * 2. Decrypted in place (AES-256-CTR).
69 * 3. HMAC-SHA2-256 verified over (seq_num_be32 || plaintext_packet).
70 * 4. ONLY THEN forwarded to the protocol handler.
71 *
72 * If HMAC verification fails the connection is closed immediately. No
73 * plaintext bytes are acted upon before the MAC is confirmed valid.
74 * This prevents padding oracle and chosen-ciphertext attacks.
75 *
76 * DEFENSE 6 - Sequence number overflow guard
77 * ────────────────────────────────────────────
78 * RFC 4253 §9.3.4 requires rekeying before the sequence number wraps.
79 * If seq_c2s or seq_s2c reaches 0xFFFFFFFF the connection is closed.
80 * (Rekeying is not yet implemented; close-on-wrap is the safe fallback.)
81 *
82 * WHAT THIS DOES NOT PROTECT AGAINST
83 * ────────────────────────────────────
84 * - An attacker with arbitrary-read in the process can still read
85 * ssh_keys[] - physical separation only raises the bar, not a hard wall.
86 * - Timing side-channels in the software Montgomery/AES paths are not
87 * addressed. On ESP32 the hardware AES path (mbedtls) has
88 * implementation-level timing properties that are mbedtls's concern.
89 * - Cold-boot attacks on SRAM after power-loss are partially mitigated by
90 * the zeroing policies above, but ESP32 SRAM may retain data briefly.
91 *
92 * ═══════════════════════════════════════════════════════════════════════════
93 */
94
95#ifndef PROTOCORE_SSH_KEYMAT_H
96#define PROTOCORE_SSH_KEYMAT_H
97
98#include "crypto/aead/aesgcm.h"
102#include "protocore_config.h"
103#include "server/mmgr/secure.h" // pc_secure_wipe (the canonical secure wipe)
104#include "server/mmgr/secure.h"
105#include <stddef.h>
106#include <stdint.h>
107#include <string.h>
108
109// These two intentionally stay anonymous *plain* enums - not enum class, not a namespacing struct.
110// The KEX negotiator templates AlgCand<E> / negotiate_alg<E> on decltype(SSH_CIPHER_AES256CTR), so it
111// needs a distinct type per family (cipher vs MAC) for type-safe candidate lists; yet the negotiated
112// value also flows through many uint8_t params/fields (ssh_dh_derive_keys_sid, ssh_mac_is_etm,
113// ssh_mac_len, cipher_alg / mac_mode). A plain enum uniquely gives BOTH - a distinct decltype type and
114// an implicit uint8_t conversion - so it is cast-free. enum class would force a cast at every uint8_t
115// boundary; a struct of static constexpr would collapse decltype to uint8_t and lose the distinction.
116
117/** @brief Negotiated bulk cipher for a session. */
118enum
119{
120 SSH_CIPHER_AES256CTR = 0, ///< aes256-ctr + a separate HMAC (the fallback)
121 SSH_CIPHER_CHACHA20POLY1305 = 1, ///< chacha20-poly1305@openssh.com (AEAD; no separate MAC)
122 SSH_CIPHER_AES256GCM = 2, ///< aes256-gcm@openssh.com (AEAD, RFC 5647; no separate MAC)
123};
124
125/** @brief Negotiated MAC for the aes256-ctr cipher (unused with the chacha AEAD). */
126enum
127{
128 SSH_MAC_HMAC_SHA256 = 0, ///< hmac-sha2-256 (encrypt-and-MAC, RFC 4253)
129 SSH_MAC_HMAC_SHA512 = 1, ///< hmac-sha2-512 (encrypt-and-MAC)
130 SSH_MAC_HMAC_SHA256_ETM = 2, ///< hmac-sha2-256-etm@openssh.com (encrypt-then-MAC)
131 SSH_MAC_HMAC_SHA512_ETM = 3, ///< hmac-sha2-512-etm@openssh.com (encrypt-then-MAC)
132};
133
134/** @brief True if @p mac_mode is an encrypt-then-MAC variant (length in the clear, MAC over ciphertext). */
135static inline bool ssh_mac_is_etm(uint8_t mac_mode)
136{
137 return mac_mode == SSH_MAC_HMAC_SHA256_ETM || mac_mode == SSH_MAC_HMAC_SHA512_ETM;
138}
139/** @brief MAC tag / key length in bytes for @p mac_mode (32 for SHA-256, 64 for SHA-512). */
140static inline uint8_t ssh_mac_len(uint8_t mac_mode)
141{
142 return (mac_mode == SSH_MAC_HMAC_SHA512 || mac_mode == SSH_MAC_HMAC_SHA512_ETM) ? 64 : 32;
143}
144
145// Secure wipe: the canonical pc_secure_wipe() lives in crypto/crypto_scratch.h (included above). Use it for
146// any buffer that held key material - a volatile store the compiler may not elide, unlike a dead memset.
147
148// ---------------------------------------------------------------------------
149// Session key material (one entry per SSH connection)
150// ---------------------------------------------------------------------------
151
152/**
153 * @brief AES-256-CTR + HMAC-SHA2-256 session keys for one SSH connection.
154 *
155 * This struct occupies a separate BSS symbol (ssh_keys[]) from the packet
156 * receive buffer (ssh_pool[].pkt_buf). See the security model at the top
157 * of this file for why that separation matters.
158 *
159 * Key derivation follows RFC 4253 §7.2. After the DH exchange hash H is
160 * known and K is available, six values are derived:
161 *
162 * IV_c2s = SHA256(K || H || "A" || session_id) [16 bytes]
163 * IV_s2c = SHA256(K || H || "B" || session_id) [16 bytes]
164 * key_c2s = SHA256(K || H || "C" || session_id) [32 bytes]
165 * key_s2c = SHA256(K || H || "D" || session_id) [32 bytes]
166 * mac_c2s = SHA256(K || H || "E" || session_id) [32 bytes]
167 * mac_s2c = SHA256(K || H || "F" || session_id) [32 bytes]
168 *
169 * aes_key/aes_ctr C→S are the client-to-server key + counter (server decrypts inbound); S→C are the
170 * reverse (server encrypts outbound).
171 */
173{
174 // aes256-ctr stores the raw 32-byte key and the 16-byte IV per direction and rebuilds its key schedule
175 // per packet in the shared crypto scratch, so no expanded CTR key lingers here. The IV is the running
176 // 128-bit counter (see aes256ctr.h).
177 //
178 // aes256-gcm@openssh.com shares aes_iv_* (the modes are mutually exclusive) but NOT aes_key_* - see the
179 // keyed contexts below.
180 uint8_t aes_key_c2s[PC_AES256CTR_KEY_LEN]; ///< AES key C→S (server decrypts inbound).
181 uint8_t aes_key_s2c[PC_AES256CTR_KEY_LEN]; ///< AES key S→C (server encrypts outbound).
182 uint8_t aes_iv_c2s[PC_AES256CTR_CTR_LEN]; ///< AES IV C→S (CTR counter / GCM nonce); advances per packet.
183 uint8_t aes_iv_s2c[PC_AES256CTR_CTR_LEN]; ///< AES IV S→C (CTR counter / GCM nonce); advances per packet.
184
185 uint8_t mac_key_c2s[64]; ///< HMAC key, client-to-server (aes mode); 32 bytes for SHA-256, 64 for SHA-512.
186 uint8_t mac_key_s2c[64]; ///< HMAC key, server-to-client (aes mode).
187 uint8_t mac_mode; ///< SSH_MAC_* selected for the aes256-ctr cipher (0 = hmac-sha2-256 E&M).
188
189 uint8_t cipher_mode; ///< SSH_CIPHER_* selected for this session (0 = aes256-ctr).
190 // chacha20-poly1305@openssh.com: 512-bit key per direction (K_main || K_header); no IV, no MAC key.
191 uint8_t chacha_key_c2s[PC_CHACHAPOLY_KEY_LEN]; ///< client-to-server, used only in chacha mode.
192 uint8_t chacha_key_s2c[PC_CHACHAPOLY_KEY_LEN]; ///< server-to-client, used only in chacha mode.
193
194 // aes256-gcm@openssh.com (RFC 5647) reuses aes_iv_* above (mode-exclusive with CTR): the low 12 bytes
195 // are the nonce, advanced per packet by pc_aesgcm_iv_increment. No separate MAC key.
196 //
197 // It does NOT use aes_key_*. A GCM key becomes a keyed context at install time and the raw key is
198 // wiped there, so in this mode the expanded schedule is the only key material resident - strictly
199 // less than CTR mode keeps. The context stays for the life of the key because standing one up costs
200 // ~9,200 cycles on an ESP32-S3, a fixed price per packet that dominates small interactive traffic
201 // (see aesgcm.h). Wiped on rekey and by ssh_keymat_wipe() on close.
202 alignas(8) uint8_t gcm_ctx_c2s[PC_WORK_AESGCM]; ///< keyed GCM context C→S (server opens inbound).
203 alignas(8) uint8_t gcm_ctx_s2c[PC_WORK_AESGCM]; ///< keyed GCM context S→C (server seals outbound).
204
205 bool active; ///< True once keys are installed after successful KEX.
206};
207
208/**
209 * @brief Pool of session key material, one entry per MAX_SSH_CONNS.
210 *
211 * Separate BSS symbol from ssh_pool[] - see security model.
212 * Zeroed on connection close by ssh_keymat_wipe(slot).
213 */
215
216// ---------------------------------------------------------------------------
217// DH ephemeral state (one entry per SSH connection, zeroed after KEX)
218// ---------------------------------------------------------------------------
219
220/**
221 * @brief Ephemeral Diffie-Hellman state for one SSH connection.
222 *
223 * The three pc_bignum fields (y, f, K) together hold 768 bytes of sensitive
224 * material. The entire struct is wiped by ssh_dh_wipe() immediately after
225 * session keys are derived from K.
226 *
227 * FIELD LIFETIME:
228 * y - generated by ssh_dh_generate(); zeroed in ssh_dh_wipe().
229 * f - computed in ssh_dh_generate() as g^y mod p; sent in KEXDH_REPLY;
230 * zeroed in ssh_dh_wipe().
231 * K - computed in ssh_dh_finish() as e^y mod p; used for key derivation;
232 * zeroed in ssh_dh_wipe() AFTER keys are installed.
233 * H - the exchange hash; becomes the session_id for the connection's
234 * lifetime (RFC 4253 §7.2); stored in H[], NOT zeroed (it is a
235 * commitment to the handshake, and is not secret).
236 */
238{
239 pc_bignum y; ///< Server ephemeral private DH scalar (SENSITIVE - wiped after KEX).
240 pc_bignum f; ///< Server DH public value = g^y mod p (sent to client).
241 pc_bignum K; ///< Shared DH secret = e^y mod p (SENSITIVE - wiped after key derivation).
242
243 uint8_t H[32]; ///< SHA-256 exchange hash; doubles as session_id after first KEX.
244 bool kex_done; ///< True once NEWKEYS has been sent and received.
245};
246
247/** @brief Pool of ephemeral DH state, one entry per MAX_SSH_CONNS. */
249
250// ---------------------------------------------------------------------------
251// Wipe helpers
252// ---------------------------------------------------------------------------
253
254/** @brief Zero all key material for slot @p i on disconnect or KEX failure. */
255static inline void ssh_keymat_wipe(uint8_t i)
256{
257 if (i < MAX_SSH_CONNS)
258 {
259 // A keyed GCM context owns a vendor allocation (mbedtls_gcm_setkey sets up a cipher context), so
260 // zeroing the bytes would leak it once per closed connection. Release first, then wipe.
261 if (ssh_keys[i].active && ssh_keys[i].cipher_mode == SSH_CIPHER_AES256GCM)
262 {
263 pc_aesgcm_key_wipe(reinterpret_cast<pc_aesgcm_key *>(ssh_keys[i].gcm_ctx_c2s));
264 pc_aesgcm_key_wipe(reinterpret_cast<pc_aesgcm_key *>(ssh_keys[i].gcm_ctx_s2c));
265 }
266 pc_secure_wipe(&ssh_keys[i], sizeof(SshKeyMat));
267 }
268}
269
270/** @brief Zero the ephemeral DH state for slot @p i after keys are derived. */
271static inline void ssh_dh_wipe(uint8_t i)
272{
273 if (i < MAX_SSH_CONNS)
274 {
275 pc_secure_wipe(&ssh_dh[i], sizeof(SshDhState));
276 }
277}
278
279#endif // PROTOCORE_SSH_KEYMAT_H
AES-256-CTR stream cipher (aes256-ctr, RFC 4344 §4) - stateless one-shot API.
#define PC_AES256CTR_CTR_LEN
AES-256-CTR counter/IV block length (bytes).
Definition aes256ctr.h:46
#define PC_AES256CTR_KEY_LEN
AES-256-CTR key length (bytes).
Definition aes256ctr.h:44
AES-256-GCM AEAD (RFC 5116) - stateless, detached-tag API.
2048-bit big-integer arithmetic for DH-group14 and RSA-2048.
#define MAX_SSH_CONNS
Definition c2_defaults.h:85
chacha20-poly1305@openssh.com AEAD cipher (OpenSSH PROTOCOL.chacha20poly1305).
#define PC_CHACHAPOLY_KEY_LEN
two 256-bit ChaCha20 keys
Definition chachapoly.h:30
void pc_aesgcm_key_wipe(pc_aesgcm_key *k)
Wipe the expanded schedule. Call on rekey and on close; the storage stays the caller's.
User-facing configuration for ProtoCore.
#define PC_WORK_AESGCM
Secure pool accessor - borrows that hold key material.
@ 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
SshDhState ssh_dh[MAX_SSH_CONNS]
Pool of ephemeral DH state, one entry per MAX_SSH_CONNS.
SshKeyMat ssh_keys[MAX_SSH_CONNS]
Pool of session key material, one entry per MAX_SSH_CONNS.
@ 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
Ephemeral Diffie-Hellman state for one SSH connection.
Definition ssh_keymat.h:238
uint8_t H[32]
SHA-256 exchange hash; doubles as session_id after first KEX.
Definition ssh_keymat.h:243
pc_bignum f
Server DH public value = g^y mod p (sent to client).
Definition ssh_keymat.h:240
bool kex_done
True once NEWKEYS has been sent and received.
Definition ssh_keymat.h:244
pc_bignum K
Shared DH secret = e^y mod p (SENSITIVE - wiped after key derivation).
Definition ssh_keymat.h:241
pc_bignum y
Server ephemeral private DH scalar (SENSITIVE - wiped after KEX).
Definition ssh_keymat.h:239
AES-256-CTR + HMAC-SHA2-256 session keys for one SSH connection.
Definition ssh_keymat.h:173
uint8_t aes_iv_c2s[PC_AES256CTR_CTR_LEN]
AES IV C→S (CTR counter / GCM nonce); advances per packet.
Definition ssh_keymat.h:182
uint8_t chacha_key_s2c[PC_CHACHAPOLY_KEY_LEN]
server-to-client, used only in chacha mode.
Definition ssh_keymat.h:192
uint8_t aes_iv_s2c[PC_AES256CTR_CTR_LEN]
AES IV S→C (CTR counter / GCM nonce); advances per packet.
Definition ssh_keymat.h:183
uint8_t mac_key_c2s[64]
HMAC key, client-to-server (aes mode); 32 bytes for SHA-256, 64 for SHA-512.
Definition ssh_keymat.h:185
uint8_t gcm_ctx_s2c[PC_WORK_AESGCM]
keyed GCM context S→C (server seals outbound).
Definition ssh_keymat.h:203
uint8_t aes_key_c2s[PC_AES256CTR_KEY_LEN]
AES key C→S (server decrypts inbound).
Definition ssh_keymat.h:180
uint8_t cipher_mode
SSH_CIPHER_* selected for this session (0 = aes256-ctr).
Definition ssh_keymat.h:189
uint8_t mac_key_s2c[64]
HMAC key, server-to-client (aes mode).
Definition ssh_keymat.h:186
bool active
True once keys are installed after successful KEX.
Definition ssh_keymat.h:205
uint8_t chacha_key_c2s[PC_CHACHAPOLY_KEY_LEN]
client-to-server, used only in chacha mode.
Definition ssh_keymat.h:191
uint8_t mac_mode
SSH_MAC_* selected for the aes256-ctr cipher (0 = hmac-sha2-256 E&M).
Definition ssh_keymat.h:187
uint8_t aes_key_s2c[PC_AES256CTR_KEY_LEN]
AES key S→C (server encrypts outbound).
Definition ssh_keymat.h:181
uint8_t gcm_ctx_c2s[PC_WORK_AESGCM]
keyed GCM context C→S (server opens inbound).
Definition ssh_keymat.h:202
A 2048-bit unsigned integer stored as 64 little-endian 32-bit limbs.
Definition bignum.h:92