Skip to content

Add strict key exchange, the Terrapin mitigation - #1271

Open
ejohnstown wants to merge 6 commits into
wolfSSL:masterfrom
ejohnstown:terrapin
Open

ejohnstown wants to merge 6 commits into
wolfSSL:masterfrom
ejohnstown:terrapin

Conversation

@ejohnstown

@ejohnstown ejohnstown commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

wolfSSH negotiates strict key exchange (draft-miller-sshm-strict-kex), the mitigation for the Terrapin attack, CVE-2023-48795, and enables it when the peer asks for it too.

  • advertise both marker spellings on the initial KEXINIT, zero the sequence numbers at every NEWKEYS, and disconnect on any non-KEX message before the peer's NEWKEYS
  • add wolfSSH_CTX_SetStrictKex() and wolfSSH_SetStrictKex() opt-outs, frozen once the handshake starts; covered in regress.c, api.c, and README.md
  • add scripts/openssh-interop.test, running OpenSSH 9.6+ ssh and sshd against the echoserver and example client under make check

Copilot AI lite review requested due to automatic review settings September 22, 2026 17:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

Review effort: Lite
Findings: 5 Medium severity · 1 Low severity

Open (6)
What changed in this PR

Adds support for strict key exchange (draft-miller-sshm-strict-kex) to mitigate the Terrapin attack (CVE-2023-48795), including negotiation markers, initial-KEX message gating, and sequence-number resets at NEWKEYS, plus test coverage and OpenSSH interop checks.

Changes:

  • Introduces strict-KEX negotiation (both marker spellings), initial-KEX allowlisting, and seqnr reset on every NEWKEYS.
  • Adds public APIs to opt out per-CTX and per-session, and documents behavior in README.
  • Expands regression/API tests and adds an OpenSSH 9.6+ interoperability script used by make check.
File Description
wolfssh/​ssh.h Documents and exposes new Strict KEX public APIs.
wolfssh/​internal.h Adds internal flags/IDs for strict-KEX negotiation and session state.
src/​ssh.c Implements new Strict KEX public API setters/getters; updates text IDs.
src/​internal.c Implements strict-KEX handshake freezing, initial-KEX allowlist, negotiation parsing, and seqnr resets at NEWKEYS.
tests/​regress.c Adds extensive strict-KEX/Terrapin regression harness and assertions.
tests/​api.c Adds API-level tests for new Strict KEX controls and defaults.
scripts/​openssh-interop.test Adds OpenSSH client/sshd interop verification for strict-KEX negotiation and continuity.
scripts/​include.am Adds the new interop script to distributed test scripts.
README.md Documents strict key exchange behavior, defaults, and opt-out APIs.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/openssh-interop.test
Comment thread src/internal.c Outdated
Comment thread src/ssh.c Outdated
Comment thread tests/regress.c
Comment thread tests/regress.c
Comment thread wolfssh/internal.h Outdated

@wolfSSL-Fenrir-bot wolfSSL-Fenrir-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fenrir Automated Review — PR #1271

Scan targets checked: wolfssh-src, wolfssh-bugs

Findings: 1
1 finding(s) posted as inline comments (see file-level comments below)

This review was generated automatically by Fenrir. Reported findings require changes before merge.

Comment thread src/internal.c Outdated

@wolfSSL-Fenrir-bot wolfSSL-Fenrir-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fenrir Automated Review — PR #1271

Scan targets checked: wolfssh-src, wolfssh-bugs

Fenrir result: Approved ✅

No new issues found in the changed files.

Advisory only — this automated result does not count as a GitHub approval.

@wolfSSL-Fenrir-bot
wolfSSL-Fenrir-bot dismissed their stale review September 22, 2026 20:36

Fenrir's latest completed scan found no issues; clearing the prior automated change request.

@ejohnstown ejohnstown assigned wolfSSL-Bot and unassigned ejohnstown Sep 23, 2026

@wolfSSL-Fenrir-bot wolfSSL-Fenrir-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fenrir Automated Review — PR #1271

Scan targets checked: wolfssh-src, wolfssh-bugs

Fenrir result: Approved ✅

No new issues found in the changed files.

Advisory only — this automated result does not count as a GitHub approval.

@philljj philljj left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good so far, just a few nits and a question.

Comment thread src/internal.c Outdated
static byte HandshakeStrictKex(WOLFSSH* ssh)
{
if (ssh->handshake == NULL) {
return ssh->sendStrictKex;

@philljj philljj Sep 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the idea that if a handshake hasn't been allocated, then use what's set on the ssh? (which would be inherited from the ctx by default)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that was the idea, but HandshakeStrictKex() is gone now. I dropped the per-session setter, so the setting lives only on the CTX. wolfSSH_new() copies it into the session, and nothing writes it after that. SendKexInit() and DoKexInit() both read that one copy, so they can't disagree, and the handshake doesn't need its own copy.

Comment thread src/internal.c Outdated
}

if (!ssh->handshake->strictKexSet) {
ssh->handshake->strictKex = ssh->sendStrictKex ? 1 : 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: a short comment at this if would be nice.

The handshake is inheriting the strictKex setting from ssh here I guess.

This looks to be the only place handshake->strictKex member is assigned.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one's moot now. HandshakeInfo no longer has strictKex or strictKexSet. See the reply above: the session's copy made at wolfSSH_new() is the only one.

Comment thread src/internal.c
if (state == WS_MSG_RECV && ssh->strictKexEnabled &&
!ssh->initialKexDone) {
if (msg != MSGID_DISCONNECT && msg != MSGID_KEXINIT &&
msg != MSGID_NEWKEYS && !MSGIDLIMIT_TRANS_KEX(msg)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think MSGIDLIMIT_TRANS_KEX(msg) allows everything from MSGIDLIMIT_TRANS_KEX_MIN = 30 to MSGIDLIMIT_TRANS_KEX_MAX = 49.

Is this too broad?

Skoll complained about it:

HIGH-1: Strict KEX admits messages from the wrong key-exchange method

  • File: src/internal.c:1053-1056
  • Function: IsMessageAllowed

Description: The new strict-KEX gate exempts every message ID in the 30–49 KEX range, plus SSH_MSG_NEWKEYS, rather than only the messages expected for the negotiated method. The existing server policy does not close this gap: immediately after receiving KEXINIT, handshake->expectMsgId remains MSGID_NONE, and IsMessageAllowedServer() accepts client-side IDs through 34. For example, with ECDH negotiated, GEX_INIT (32) or GEX_REQUEST (34) can pass this strict gate and reach the wrong handler; an early NEWKEYS also passes and is later treated by DoPacket() as completing the initial KEX even when DoNewKeys() fails. Strict KEX requires messages to be specific to the negotiated method and accepted only the expected number of times, as specified in section 3.2 of the strict-KEX draft. This defect is introduced by the PR's blanket KEX-range allowlist.

Code:

if (msg != MSGID_DISCONNECT && msg != MSGID_KEXINIT &&
        msg != MSGID_NEWKEYS && !MSGIDLIMIT_TRANS_KEX(msg)) {

Recommendation: Fix the server-side strict-KEX sequencing and add regression cases for wrong-method KEX messages and premature NEWKEYS before merging.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, there was a gap. The strict KEX allow list takes the whole 30-49 range, and in most places the existing expectMsgId check narrows it to the exact message due next. The server was the exception. After the client's KEXINIT it left expectMsgId unset until the client's first KEX message arrived. In that window, a message from another method (GEX_INIT or GEX_REQUEST under ECDH, say) or an early NEWKEYS got through.

Fixed:

  • After the client's KEXINIT, the server expects KEXDH_INIT, or KEXDH_GEX_REQUEST for DH-GEX. Anything else is refused, and under strict KEX that ends the connection with KEY_EXCHANGE_FAILED. After a wrong first_kex_packet_follows guess, the server discards the guessed packet first and only then sets the expectation, because the guessed packet's ID can be from another method.
  • DoPacket() resets the sequence number and ends the initial KEX only on a NEWKEYS that actually installed the new keys. A NEWKEYS that fails is counted like any other packet.
  • New regress tests: NEWKEYS, KEXDH_REPLY, GEX_INIT and GEX_REQUEST sent ahead of the client's KEXDH_INIT each end the connection under strict KEX, and a NEWKEYS that fails doesn't end the initial KEX.

The expectation is set whether or not strict KEX is on, so this also tightens the non-strict server path

@philljj philljj assigned ejohnstown and unassigned wolfSSL-Bot Sep 25, 2026
The "Unable to copy" message now carries the return code and the
session error with its name. The return alone is usually the generic
WS_FATAL_ERROR, which says nothing about the cause.
When select() reports the socket readable but only part of a packet
has arrived, wolfSSH_worker() returns WS_WANT_READ. sftp_worker() now
goes back to waiting on the socket instead of ending the session,
which cut off SFTP transfers whose packets arrived split across reads.
GetInputLine() reads up to 255 bytes for the version line, so a peer
that sends its first packets in the same segment leaves them buffered
behind it. DoReceive() force-freed the input buffer after each packet,
dropping any of those bytes past the first packet. It now keeps
them and frees the buffer only once it is empty.
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
  the draft's unprefixed name and then the -v00@openssh.com name
  deployed OpenSSH uses; Paramiko lets the last kex-strict-* name decide
  and knows only the v00 one, so it goes last
- DoKexInit() matches either spelling and enables the mitigation when
  both sides asked for it; a marker in a rekey KEXINIT is ignored
- DoKexInit() ends the connection when strict KEX is on and the KEXINIT
  was not the peer's first packet
- 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() opts out per context; wolfSSH_new() copies
  the setting and nothing changes it after, so advertising the marker
  and enforcing it read the same value
- wolfSSH_GetStrictKexNegotiated() reports whether a session has it on
- wolfSSH_SendIgnore() refuses to send while strict KEX is offered and
  the initial KEX is still running, since a strict peer would disconnect
- 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-out in README.md
Runs the OpenSSH client against the echoserver and the wolfSSH example
client against sshd, checking that each pairing negotiates the strict
KEX marker and that a session then runs over the connection.

- skip when ssh is missing or predates OpenSSH 9.6
- fall back from the ECC keys to RSA, then Ed25519, on a build without
  them, and give sshd RSA and Ed25519 host keys beside the ECDSA one
- skip the sshd half alone when sshd or ssh-keygen is missing, or sshd
  will not start unprivileged
- report the sequence number resets OpenSSH logs rather than test them,
  the wording being no contract
- run from make check through dist_noinst_SCRIPTS
After the client's KEXINIT the server left expectMsgId unset until the
client's first KEX message arrived, so any transport ID got through in
that window: a message from another KEX method, or an early NEWKEYS.
Strict KEX allows only the messages the negotiated method expects
(draft-ietf-sshm-strict-kex section 3.2).

- DoKexInit() sets expectMsgId to KEXDH_INIT, or KEXDH_GEX_REQUEST for
  DH-GEX; after a wrong first_kex_packet_follows guess it is set once
  the guessed packet is skipped
- DoPacket() resets peerSeq and ends the initial KEX only on a NEWKEYS
  that installed keys; one that fails is counted like any other packet
- regress: out-of-turn KEX messages ahead of the client's KEXDH_INIT
  end the connection under strict KEX, and a failed NEWKEYS is counted
- regress: the first_kex_packet_follows cross-boundary case expects the
  negotiated KEX's message after the skip
@ejohnstown
ejohnstown requested a review from philljj September 29, 2026 23:18

@wolfSSL-Fenrir-bot wolfSSL-Fenrir-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fenrir Automated Review — PR #1271

Scan targets checked: wolfssh-src, wolfssh-bugs
Coverage: 5 of 7 in-scope changed file(s) opened by the reviewer; not opened: wolfssh/error.h, wolfssh/ssh.h

Fenrir result: Approved ✅

No new issues found in the changed files.

Advisory only — this automated result does not count as a GitHub approval.

Review tier: Lite

@ejohnstown ejohnstown assigned wolfSSL-Bot and unassigned ejohnstown Sep 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants