Skip to content

Commit ef0dcd1

Browse files
committed
Add strict key exchange, the Terrapin mitigation
wolfSSH negotiates strict key exchange, the mitigation from draft-miller-sshm-strict-kex for CVE-2023-48795, and turns it on when the peer asks for it too. Nothing in the initial KEX is authenticated, so a packet spliced into it shifts the receiver's sequence number, and that shift is the attack. - SendKexInit() carries the marker on the initial KEXINIT only, sending both the -v00@openssh.com name deployed OpenSSH uses and the draft's unprefixed one - DoKexInit() matches either spelling and enables the mitigation when both sides asked for it; a marker in a rekey KEXINIT is ignored - DoPacket() and SendNewKeys() zero peerSeq and seq at every NEWKEYS, the outbound reset landing before SendPendingChannelWindowAdjust() can bundle a packet at the old number - IsMessageAllowed() takes an allow list until the peer's NEWKEYS arrives: DISCONNECT, KEXINIT, NEWKEYS and the key exchange range - a refused message ends the connection with SSH_MSG_DISCONNECT and KEY_EXCHANGE_FAILED rather than being dropped - wolfSSH_CTX_SetStrictKex() and wolfSSH_SetStrictKex() opt out per context or per session, the context seeding each session it makes - the handshake freezes the setting when it starts, so advertising the marker and enforcing it cannot read different values - ID_EXTINFO_{S,C} become ID_EXT_INFO_{S,C}, beside four new pseudo-KEX ids for the strict KEX names - reword WS_MSGID_NOT_ALLOWED_E, which no longer only covers userauth - cover the negotiation, the gate, the injections and the resets in regress.c, and the new calls in api.c - describe strict KEX and its opt-outs in README.md
1 parent dee2b1f commit ef0dcd1

7 files changed

Lines changed: 1355 additions & 24 deletions

File tree

‎README.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,40 @@ Notes:
766766
`-DWC_SIG_MIN_HASH_TYPE=WC_HASH_TYPE_SHA`. This re-enables a deprecated hash;
767767
prefer ECDSA unless RSA is mandated.
768768

769+
STRICT KEY EXCHANGE
770+
===================
771+
772+
wolfSSH implements strict key exchange, the mitigation for the Terrapin attack
773+
(CVE-2023-48795) described in `draft-miller-sshm-strict-kex`. It is negotiated
774+
in the initial KEXINIT and enabled whenever the peer asks for it too, so no
775+
configuration is needed for the usual case.
776+
777+
With strict KEX in force, wolfSSH accepts nothing but the key exchange itself
778+
and SSH_MSG_DISCONNECT until the peer's SSH_MSG_NEWKEYS arrives, and it zeroes
779+
the packet sequence numbers at every SSH_MSG_NEWKEYS. Together those stop an
780+
attacker splicing packets into the unauthenticated initial exchange to shift
781+
the sequence numbers. A message that arrives out of turn ends the connection
782+
with SSH_MSG_DISCONNECT rather than being ignored.
783+
784+
Note that wolfSSH offers neither `chacha20-poly1305@openssh.com` nor the
785+
`*-etm@openssh.com` MACs, the modes whose nonce comes from the sequence
786+
number. The full silent-truncation form of Terrapin needs one of those, so it
787+
was never reachable here; strict KEX closes the sequence-number shift the
788+
attack is built on.
789+
790+
A caller that has to interoperate with a peer that mishandles the marker can
791+
turn it off, per context or per session:
792+
793+
wolfSSH_CTX_SetStrictKex(ctx, 0); /* seeds every session from ctx */
794+
wolfSSH_SetStrictKex(ssh, 0); /* this session only */
795+
796+
Only the initial KEXINIT carries the marker, and only that exchange decides
797+
whether the mitigation is on, so the session call has to be made before the
798+
connection starts. A rekey neither re-advertises the marker nor revisits the
799+
decision, and a change made to a session already keying is ignored for the
800+
life of that session. The context call is the one that affects anything
801+
later: it seeds the sessions made after it.
802+
769803
WOLFSSH APPLICATIONS
770804
====================
771805

‎src/internal.c‎

Lines changed: 159 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -585,7 +585,7 @@ const char* GetErrorString(int err)
585585
return "not a regular file";
586586

587587
case WS_MSGID_NOT_ALLOWED_E:
588-
return "message not allowed before user authentication";
588+
return "message ID not allowed at this point in the connection";
589589

590590
case WS_ED25519_E:
591591
return "Ed25519 buffer error";
@@ -671,8 +671,9 @@ static INLINE int HighwaterCheck(WOLFSSH* ssh, byte side)
671671

672672
/* RFC 4344 Sec 3.1: rekey before the 32-bit SSH sequence number wraps
673673
* to prevent MAC/nonce reuse. Counter is per-key (resets on rekey),
674-
* not the absolute ssh->seq (which does not reset); default 2^31
675-
* keeps each epoch comfortably under the 2^32 wrap. */
674+
* not the absolute ssh->seq (which resets only at a strict KEX
675+
* NEWKEYS); default 2^31 keeps each epoch comfortably under the
676+
* 2^32 wrap. */
676677
if (!ssh->msgHighwaterFlag && ssh->msgHighwaterMark &&
677678
(ssh->txMsgCount >= ssh->msgHighwaterMark ||
678679
ssh->rxMsgCount >= ssh->msgHighwaterMark)) {
@@ -725,6 +726,27 @@ static HandshakeInfo* HandshakeInfoNew(void* heap)
725726
}
726727

727728

729+
/* The strict KEX setting this handshake runs on. Frozen the first time it
730+
* is read -- at our KEXINIT or at the peer's, whichever comes first -- so
731+
* that a caller toggling the setting mid-handshake cannot leave one side
732+
* advertising the marker and the other refusing to enforce it. A later
733+
* toggle takes effect on the next handshake, which allocates its own
734+
* HandshakeInfo. */
735+
static byte HandshakeStrictKex(WOLFSSH* ssh)
736+
{
737+
if (ssh->handshake == NULL) {
738+
return ssh->sendStrictKex;
739+
}
740+
741+
if (!ssh->handshake->strictKexSet) {
742+
ssh->handshake->strictKex = ssh->sendStrictKex ? 1 : 0;
743+
ssh->handshake->strictKexSet = 1;
744+
}
745+
746+
return ssh->handshake->strictKex;
747+
}
748+
749+
728750
static void HandshakeInfoFree(HandshakeInfo* hs, void* heap)
729751
{
730752
WOLFSSH_UNUSED(heap);
@@ -1019,6 +1041,27 @@ INLINE static int IsMessageAllowedClient(WOLFSSH *ssh, byte msg)
10191041
* send some of those ids the other way. */
10201042
INLINE static int IsMessageAllowed(WOLFSSH *ssh, byte msg, byte state)
10211043
{
1044+
/* Strict KEX (Terrapin mitigation). Nothing in the initial KEX is
1045+
* authenticated, so any packet spliced into it shifts the receiver's
1046+
* sequence number, and that shift is the attack. Take an allow list
1047+
* rather than name the messages to refuse: until the peer's NEWKEYS
1048+
* lands, the only things it can legitimately send are DISCONNECT and
1049+
* the key exchange itself. IGNORE, DEBUG, UNIMPLEMENTED, EXT_INFO and
1050+
* the unassigned transport IDs are all otherwise accepted here, and
1051+
* every one of them counts against peerSeq. DISCONNECT stays allowed
1052+
* so a peer can still tear the connection down. */
1053+
if (state == WS_MSG_RECV && ssh->strictKexEnabled &&
1054+
!ssh->initialKexDone) {
1055+
if (msg != MSGID_DISCONNECT && msg != MSGID_KEXINIT &&
1056+
msg != MSGID_NEWKEYS && !MSGIDLIMIT_TRANS_KEX(msg)) {
1057+
WLOG(WS_LOG_DEBUG,
1058+
"Message ID %u not allowed during the initial strict KEX",
1059+
msg);
1060+
ssh->error = WS_MSGID_NOT_ALLOWED_E;
1061+
return 0;
1062+
}
1063+
}
1064+
10221065
#ifndef NO_WOLFSSH_SERVER
10231066
if (ssh->ctx->side == WOLFSSH_ENDPOINT_SERVER) {
10241067
return IsMessageAllowedServer(ssh, msg);
@@ -1399,6 +1442,7 @@ WOLFSSH_CTX* CtxInit(WOLFSSH_CTX* ctx, byte side, void* heap)
13991442
ctx->windowSz = DEFAULT_WINDOW_SZ;
14001443
ctx->maxPacketSz = DEFAULT_MAX_PACKET_SZ;
14011444
ctx->maxAuthAttempts = DEFAULT_MAX_AUTH_ATTEMPTS;
1445+
ctx->sendStrictKex = 1; /* default-enabled, callers can opt out */
14021446
ctx->sshProtoIdStr = sshProtoIdStr;
14031447
ctx->sshProtoIdStrSz = (word32)(sizeof(sshProtoIdStr) - 1);
14041448
ctx->algoListKex = cannedKexAlgoNames;
@@ -1795,6 +1839,7 @@ WOLFSSH* SshInit(WOLFSSH* ssh, WOLFSSH_CTX* ctx)
17951839
ssh->acceptState = ACCEPT_BEGIN;
17961840
ssh->clientState = CLIENT_BEGIN;
17971841
ssh->isKeying = 0; /* initial state of not keying yet */
1842+
ssh->sendStrictKex = ctx->sendStrictKex;
17981843
ssh->authId = ID_USERAUTH_PUBLICKEY;
17991844
ssh->supportedAuth[0] = ID_USERAUTH_PUBLICKEY;
18001845
ssh->supportedAuth[1] = ID_USERAUTH_PASSWORD;
@@ -3787,8 +3832,16 @@ static const NameIdPair NameIdMap[] = {
37873832
{ ID_CURVE25519_SHA256, TYPE_KEX, "curve25519-sha256" },
37883833
{ ID_CURVE25519_SHA256_LIBSSH, TYPE_KEX, "curve25519-sha256@libssh.org" },
37893834
#endif
3790-
{ ID_EXTINFO_S, TYPE_OTHER, "ext-info-s" },
3791-
{ ID_EXTINFO_C, TYPE_OTHER, "ext-info-c" },
3835+
{ ID_EXT_INFO_S, TYPE_OTHER, "ext-info-s" },
3836+
{ ID_EXT_INFO_C, TYPE_OTHER, "ext-info-c" },
3837+
/* Strict KEX marker. draft-miller-sshm-strict-kex defines the
3838+
* unprefixed names for eventual IETF standardization; deployed
3839+
* OpenSSH only sends the -v00@openssh.com ones. Advertise and accept
3840+
* both spellings. */
3841+
{ ID_EXT_STRICT_KEX_S, TYPE_OTHER, "kex-strict-s" },
3842+
{ ID_EXT_STRICT_KEX_C, TYPE_OTHER, "kex-strict-c" },
3843+
{ ID_EXT_PRE_STRICT_KEX_S, TYPE_OTHER, "kex-strict-s-v00@openssh.com" },
3844+
{ ID_EXT_PRE_STRICT_KEX_C, TYPE_OTHER, "kex-strict-c-v00@openssh.com" },
37923845

37933846
/* Public Key IDs */
37943847
#ifndef WOLFSSH_NO_RSA
@@ -6651,12 +6704,45 @@ static int DoKexInit(WOLFSSH* ssh, byte* buf, word32 len, word32* idx)
66516704

66526705
/* Match the peer accepts extInfo. */
66536706
algoId = (side == WOLFSSH_ENDPOINT_SERVER)
6654-
? ID_EXTINFO_C : ID_EXTINFO_S;
6707+
? ID_EXT_INFO_C : ID_EXT_INFO_S;
66556708
extInfo = MatchIdLists(side, list, listSz, &algoId, 1);
66566709
ssh->sendExtInfo = extInfo == algoId;
66576710
}
66586711
}
66596712

6713+
/* Strict KEX marker (Terrapin mitigation). Only valid in the initial
6714+
* KEXINIT (sessionIdSz == 0); if the peer offers it during a rekey,
6715+
* ignore the marker per draft-miller-sshm-strict-kex. */
6716+
if (ret == WS_SUCCESS) {
6717+
if (ssh->sessionIdSz == 0) {
6718+
/* OpenSSH only ever sends the -v00@openssh.com name, so both
6719+
* spellings have to be accepted for the marker to negotiate
6720+
* against a real peer. */
6721+
byte expectedStrict[2];
6722+
byte matched;
6723+
6724+
if (side == WOLFSSH_ENDPOINT_SERVER) {
6725+
expectedStrict[0] = ID_EXT_STRICT_KEX_C;
6726+
expectedStrict[1] = ID_EXT_PRE_STRICT_KEX_C;
6727+
}
6728+
else {
6729+
expectedStrict[0] = ID_EXT_STRICT_KEX_S;
6730+
expectedStrict[1] = ID_EXT_PRE_STRICT_KEX_S;
6731+
}
6732+
matched = MatchIdLists(side, list, listSz, expectedStrict, 2);
6733+
ssh->peerStrictKex = (matched != ID_UNKNOWN);
6734+
ssh->strictKexEnabled =
6735+
ssh->peerStrictKex && HandshakeStrictKex(ssh);
6736+
if (ssh->strictKexEnabled) {
6737+
WLOG(WS_LOG_DEBUG, "DKI: strict KEX negotiated");
6738+
}
6739+
}
6740+
else {
6741+
WLOG(WS_LOG_DEBUG,
6742+
"DKI: rekey, ignoring any peer strict KEX marker");
6743+
}
6744+
}
6745+
66606746
/* Server Host Key Algorithms */
66616747
if (ret == WS_SUCCESS) {
66626748
WLOG(WS_LOG_DEBUG, "DKI: Server Host Key Algorithms");
@@ -13917,6 +14003,20 @@ static int DoPacket(WOLFSSH* ssh, byte* bufferConsumed)
1391714003

1391814004
msgAllowed = IsMessageAllowed(ssh, msg, WS_MSG_RECV);
1391914005

14006+
if (!msgAllowed && ssh->strictKexEnabled && !ssh->initialKexDone) {
14007+
/* Strict KEX calls for terminating the connection, not just
14008+
* dropping the packet, so tell the peer why on the way out. Every
14009+
* refused id ends it here, the unassigned ones an UNIMPLEMENTED
14010+
* would otherwise answer included. The error is relatched because
14011+
* the send path can overwrite it. */
14012+
if (!ssh->disconnected) {
14013+
(void)SendDisconnect(ssh,
14014+
WOLFSSH_DISCONNECT_KEY_EXCHANGE_FAILED);
14015+
ssh->error = WS_MSGID_NOT_ALLOWED_E;
14016+
}
14017+
return WS_MSGID_NOT_ALLOWED_E;
14018+
}
14019+
1392014020
if (!msgAllowed && (MsgIdKnown(msg) || MSGIDLIMIT_POST_USERAUTH(msg))) {
1392114021
/* RFC 4252 section 6: disconnect on a known id at the wrong time,
1392214022
* and on any id of 80 or higher, which IsMessageAllowed() refuses
@@ -14168,7 +14268,23 @@ static int DoPacket(WOLFSSH* ssh, byte* bufferConsumed)
1416814268
idx = len;
1416914269
}
1417014270
ssh->inputBuffer.idx = idx;
14171-
ssh->peerSeq++;
14271+
if (msg == MSGID_NEWKEYS) {
14272+
/* Strict KEX (Terrapin mitigation): once negotiated, every
14273+
* SSH_MSG_NEWKEYS resets the incoming sequence number so the
14274+
* next inbound packet starts at zero under the new keys. */
14275+
if (ssh->strictKexEnabled) {
14276+
ssh->peerSeq = 0;
14277+
}
14278+
else {
14279+
ssh->peerSeq++;
14280+
}
14281+
/* The peer's initial KEX is over, so IGNORE, DEBUG, and
14282+
* UNIMPLEMENTED are legal from it again. */
14283+
ssh->initialKexDone = 1;
14284+
}
14285+
else {
14286+
ssh->peerSeq++;
14287+
}
1417214288
ssh->rxMsgCount++;
1417314289
*bufferConsumed = 1;
1417414290

@@ -15260,12 +15376,36 @@ int SendKexInit(WOLFSSH* ssh)
1526015376
}
1526115377

1526215378
if (ret == WS_SUCCESS) {
15379+
/* The strict-kex marker is only advertised during the initial KEX
15380+
* (RFC draft-miller-sshm-strict-kex), distinguished here by an
15381+
* empty session id. The sendStrictKex flag lets callers opt out
15382+
* at runtime; the handshake freezes it so this and the enforcement
15383+
* decision in DoKexInit() cannot read different values. */
15384+
int includeStrictKex =
15385+
(ssh->sessionIdSz == 0) && HandshakeStrictKex(ssh);
15386+
1526315387
if (ssh->ctx->side == WOLFSSH_ENDPOINT_CLIENT) {
15264-
kexAlgoNamesPlus = ",ext-info-c";
15388+
if (includeStrictKex) {
15389+
kexAlgoNamesPlus =
15390+
",ext-info-c"
15391+
",kex-strict-c-v00@openssh.com"
15392+
",kex-strict-c";
15393+
}
15394+
else {
15395+
kexAlgoNamesPlus = ",ext-info-c";
15396+
}
1526515397
kexAlgoNamesPlusSz = (word32)WSTRLEN(kexAlgoNamesPlus);
1526615398
}
1526715399
else {
15268-
kexAlgoNamesPlus = ",ext-info-s";
15400+
if (includeStrictKex) {
15401+
kexAlgoNamesPlus =
15402+
",ext-info-s"
15403+
",kex-strict-s-v00@openssh.com"
15404+
",kex-strict-s";
15405+
}
15406+
else {
15407+
kexAlgoNamesPlus = ",ext-info-s";
15408+
}
1526915409
kexAlgoNamesPlusSz = (word32)WSTRLEN(kexAlgoNamesPlus);
1527015410
}
1527115411

@@ -18717,6 +18857,16 @@ int SendNewKeys(WOLFSSH* ssh)
1871718857
ssh->outputBuffer.length = idx;
1871818858

1871918859
ret = BundlePacket(ssh);
18860+
18861+
/* Strict KEX (Terrapin mitigation): once negotiated, every
18862+
* SSH_MSG_NEWKEYS resets the outgoing sequence number so the next
18863+
* outbound packet starts at zero under the new keys. This has to
18864+
* happen here, before SendPendingChannelWindowAdjust() below can
18865+
* bundle a packet, or that packet goes out at the old sequence
18866+
* number while the peer MACs it at zero. */
18867+
if (ret == WS_SUCCESS && ssh->strictKexEnabled) {
18868+
ssh->seq = 0;
18869+
}
1872018870
}
1872118871

1872218872
if (ret == WS_SUCCESS) {

‎src/ssh.c‎

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3465,6 +3465,56 @@ int wolfSSH_CheckAlgoName(const char* name)
34653465
}
34663466

34673467

3468+
int wolfSSH_CTX_SetStrictKex(WOLFSSH_CTX* ctx, byte enable)
3469+
{
3470+
int ret = WS_BAD_ARGUMENT;
3471+
3472+
if (ctx) {
3473+
ctx->sendStrictKex = enable ? 1 : 0;
3474+
ret = WS_SUCCESS;
3475+
}
3476+
3477+
return ret;
3478+
}
3479+
3480+
3481+
int wolfSSH_CTX_GetStrictKex(WOLFSSH_CTX* ctx)
3482+
{
3483+
int ret = WS_BAD_ARGUMENT;
3484+
3485+
if (ctx) {
3486+
ret = ctx->sendStrictKex ? 1 : 0;
3487+
}
3488+
3489+
return ret;
3490+
}
3491+
3492+
3493+
int wolfSSH_SetStrictKex(WOLFSSH* ssh, byte enable)
3494+
{
3495+
int ret = WS_SSH_NULL_E;
3496+
3497+
if (ssh) {
3498+
ssh->sendStrictKex = enable ? 1 : 0;
3499+
ret = WS_SUCCESS;
3500+
}
3501+
3502+
return ret;
3503+
}
3504+
3505+
3506+
int wolfSSH_GetStrictKex(WOLFSSH* ssh)
3507+
{
3508+
int ret = WS_SSH_NULL_E;
3509+
3510+
if (ssh) {
3511+
ret = ssh->sendStrictKex ? 1 : 0;
3512+
}
3513+
3514+
return ret;
3515+
}
3516+
3517+
34683518
const char* wolfSSH_QueryKex(word32* idx)
34693519
{
34703520
return NameByIndexType(TYPE_KEX, idx);
@@ -5658,15 +5708,15 @@ size_t wolfSSH_GetText(WOLFSSH *ssh, WS_Text id, char *str, size_t strSz)
56585708
break;
56595709
#endif /* !WOLFSSH_NO_DH */
56605710

5661-
case ID_EXTINFO_S:
5711+
case ID_EXT_INFO_S:
56625712
#if defined(__CCRX__)
56635713
ret = WSNPRINTF0(str, strSz, "Server extensions KEX");
56645714
#else
56655715
ret = WSNPRINTF(str, strSz, "Server extensions KEX");
56665716
#endif
56675717
break;
56685718

5669-
case ID_EXTINFO_C:
5719+
case ID_EXT_INFO_C:
56705720
#if defined(__CCRX__)
56715721
ret = WSNPRINTF0(str, strSz, "Client extensions KEX");
56725722
#else

0 commit comments

Comments
 (0)