Commit Graph

374 Commits (c71202ffdb92202cc53bd520ee45f438d2edded4)

Author SHA1 Message Date
John Safranek c71202ffdb Guard remote forwards and disallowed msg ids
A client answers tcpip-forward and cancel-tcpip-forward with a failure,
RFC 4254 section 7.1, before the request body is parsed. DoPacket()
disconnects on a refused id this build implements and on any refused id
of 80 or higher, the range RFC 4252 section 6 names; a refused id below
80 is answered UNIMPLEMENTED, RFC 4253 section 11.4.

- MsgIdKnown() carries DoPacket()'s dispatch ids, build guards included
- answer a refused id off the dispatch, so one cannot reach a handler
  if MsgIdKnown() falls behind
- skip the disconnect once the session is over, RFC 4253 section 11.1
  forbids sending after one
- cover ids 53, 79, 200 and a channel open before user auth on one
  keyed-server helper
- cover a client refusing both request names, with a reply asked for and
  without, and a server still succeeding

Issue: #1047 (3), #1047 (7), F-10576, F-10581, F-12574
2026-09-01 10:51:48 -05:00
John Safranek 9cedb5a1e7 Keep queued replies with the folded forward
A port-0 reply naming a port another registration stands for merges the
two, since one bind gets one registration. The requests still queued
against the entry that goes named that same bind, so FwdReplyRebind()
hands them to the survivor. Left naming nothing, a cancel among them
settles no forward and the merged one keeps matching opens.

- FwdReplyRebind() repoints queued slots before the stale entry is
  unlinked and FwdReplyVoid() clears them
- Test folds a port-0 reply onto a forward with a cancel already
  queued, and checks the cancel takes the survivor down
2026-08-31 20:35:56 -05:00
John Safranek a88404cb88 Drop a forward the peer refused twice
A cancel refusal leaves the forward standing, since the peer keeps a
listener it would not drop. There is no listener to keep when the setup
was refused too, so FwdRemoteSettle() unwinds a registration nothing
establishes and nothing is still owed an answer on, rather than leaving
it unconfirmed and unmatchable until the session ends.

- A cancel refusal unlinks an unconfirmed forward with no setup queued
- Test refuses a want-reply setup and the cancel behind it, and checks
  the registration is gone
2026-08-31 20:35:56 -05:00
John Safranek f6126898e0 Test a reply landing before the request commits
The mid-send window closed to the highwater callback when the commit
moved ahead of it, and the tests written for that window went with it:
they now answer a settled slot and reach the ordinary path. The IO send
callback is where a peer's answer can still land while the sender owns
its slot, so drive the reply from there.

- FwdReplyFromSendCb() answers the request from inside the flush, with
  a short-write and a fail-next mode for a send that loses the rest of
  the request after the answer is in.
- Tests cover a parked success, a parked refusal, a parked port-0 reply,
  a failed send giving the answered slot back, and an answer settling an
  earlier request while a cancel naming the same bind is mid-send.
- The highwater tests stay, for the committed path they now cover; their
  comments said they held an uncommitted slot.
2026-08-31 20:35:56 -05:00
John Safranek 8c920a8767 Commit forward state before the post-send callback
The forward a "tcpip-forward" or "cancel-tcpip-forward" establishes was
committed after SendGlobalRequestFwd() returned, which is after the
post-send highwater callback had run. A request that callback sends goes
out behind this one but committed ahead of it, so the earlier request
had the last word and the client ended up on the opposite side of the
forward from the peer. A first setup whose callback cancels it left the
forward registered with no listener on the peer, and a cancel whose
callback re-establishes the forward unlinked it, refusing every open for
a listener the peer holds.

- Split the post-send highwater check off wolfSSH_SendPacket() as
  SendPacketFlush(), for a sender with state to commit first.
- SendGlobalRequestFwd() takes the pending forward and settles it inside
  the send window, then runs the check.
- Commit order is send order now, so the last request sent governs,
  whichever call made it.
- FwdPendingCommit() still re-resolves the entry: the IO send callback
  can reenter mid-flush, which no ordering fixes.
- Tests cover a reentrant cancel of a first setup, a reentrant setup
  during a cancel, and an inbound forwarded-tcpip open pumped from the
  callback.
2026-08-31 20:35:56 -05:00
John Safranek cabbc53f8f Keep a rekey out of the mem-IO test harnesses
Filling the reply queue sends enough to cross a lowered highwater mark.
The rekey that fires sends a KEXINIT the 256-byte mem buffer cannot hold.

- Disable the highwater; a bigger buffer only moves the cliff.
- Both harnesses, since the buffer is the same size on each.
2026-08-31 20:35:56 -05:00
John Safranek 69bfbccbf9 Add a match setting for remote forwards
The forwarded-tcpip check is a behaviour change on a shipped API, so an
application that trips over it needs a way out that isn't abandoning
wolfSSH_FwdRemoteSetup(). wolfSSH_SetFwdRemoteMatch() relaxes the check
for the session.

- STRICT is the default and keeps the bind-plus-port rule.
- PORT compares the port alone, for a peer that rewrites the bind
  address it echoes back, which STRICT refuses every open from.
- OFF accepts any open, as wolfSSH did before the check existed.
- Tests cover each setting, and a refused one leaving the default in
  place.
2026-08-31 20:35:56 -05:00
John Safranek 3267d87bf5 Cap the pending-reply queue
Only the peer gives a slot back, by answering, so a peer that never
answers a want-reply global request lets the queue grow for the life of
the session, and the queue scans run once per forward on every inbound
forwarded-tcpip open.

- WOLFSSH_MAX_FWD_REPLIES bounds the queue.
- Refuse before the request is framed: one whose slot was never queued
  would mispair every later reply.
- Return WS_RESOURCE_E, not the WS_MEMORY_E that nothing failing to
  allocate would have yielded.
- Test fills the queue, then covers the refusal, that a refused setup
  registers nothing, and a slot coming back on an answer.
2026-08-31 20:35:56 -05:00
John Safranek 4ec4825d86 Let the reply-queue scans see a slot still in its send window
A slot named its forward only once its request committed, so the scans
could not see a request mid-send. A cancel the peer confirms while a
fresh setup for the same bind is still going out then found nothing
standing for the forward and unlinked it, leaving the peer with a
listener the client refuses every open for.

- Name a slot by its bind until it commits.
- FwdReplyNames() answers for both FwdReplyHasSetup() and
  FwdReplyNewest().
- The bind is borrowed from the caller and dropped at commit, the
  lifetime WOLFSSH_FWD_PENDING already assumes for it.
- A pointer to the entry would not do: the send runs callbacks that can
  free and remake it, which is why the entry is re-resolved at commit.
- Test drives a confirmed cancel against a re-setup still in its send
  window.
2026-08-31 20:35:56 -05:00
John Safranek 8d3f20bfde Fix interrupted and failed sends in SendPacket
A signal was turned into a fatal error, and a refused send left its
packet counted in the output buffer. Both fixes are in
wolfSSH_SendPacket(), so they cover every sender.

- WS_CBIO_ERR_ISR fell through to WS_SOCKET_ERROR_E. Nothing went out
  and the session is unharmed, so retry, as ReceiveData() already does.
- Callers that discard a packet on error, like the KEX and userauth
  sends, were throwing away framed output the peer never refused.
- On WS_CBIO_ERR_GENERAL the buffer was shrunk with the packet still
  counted in plainSz, so SendChannelData() flushed nothing and called it
  a success. Clear it with the packet it described.
- Tests pin the retry from a forwarding sender and a plain global
  request, and drive a channel send through a would-block and a refused
  flush.
2026-08-31 20:35:56 -05:00
John Safranek e396a0a4b6 Match forwarded-tcpip to registered forwards
RFC 4254 7.2 says a forwarded-tcpip open answers a forward the client
asked for, so refuse an open naming anything else. Enforcement starts at
the first wolfSSH_FwdRemoteSetup(), leaving a client that frames
tcpip-forward itself unaffected.

- Register each setup per session. A wildcard bind matches on port alone.
- Port 0 now requires want-reply, since only the reply names the port.
- Repeat setups of one bind share a registration, so one cancel undoes
  it.
- A cancel stops matching as it goes out, but a want-reply cancel stays
  registered until the peer answers: a refusal leaves the listener up.
- Replies carry no request id, so a per-session queue pairs them in send
  order. A want-reply wolfSSH_global_request() takes a slot as well.
- Registration is split around the send: allocate first, link once the
  request reached the wire.
- A request resolves its registration on commit, since sending runs
  application callbacks that can reenter the library.
- Tests cover matching, cancel, overlapping requests, send-order
  pairing, port 0, registration around the send, and reentrancy from a
  callback.
- They drive a client session, so they sit outside the server-only block
  in regress.c and run in a --disable-server build. The harness struct,
  its teardown, the channel-open-failure helpers and the forwarding
  callback moved out with them, shared with the server-side tests.

Contracts for wolfSSH_FwdRemoteSetup(), wolfSSH_FwdRemoteCancel() and
wolfSSH_global_request() are in wolfssh/ssh.h.

Issue: ZD-22195
2026-08-31 20:35:56 -05:00
John Safranek 6c83a2bfc9 Mark the forwarding packet builders maybe-unused
BuildDirectTcpipExtra() and BuildGlobalRequestFwdPacket() have callers in
several conditional blocks, and a build with none of them left the two
functions unused, which -Werror turns into a build failure.
ReadUint32() next to them already carries the attribute for the same
reason.

- --disable-server builds regress.c again.
2026-08-31 20:35:56 -05:00
John Safranek 2bfdbac2b3 Carry the EOF status through SFTP and SCP
wolfSSH_SFTP_buffer_send() and ScpStreamSend() keep driving the worker when the
peer half-closes. Both return any negative status, so a WS_EOF would have
aborted a transfer that is still perfectly able to finish: the peer closed its
sending direction, not ours.

- wolfSSH_SFTP_buffer_read() reports every negative peek but a rekey instead of
  spending a receive on it. A drained channel at EOF, a dead session and a
  channel that is gone all mean no more data can arrive, and the poll only
  overwrites the latched cause with WS_WANT_READ or blocks on a peer that has
  hung up.
- A rekey is not a drained channel: peek reports it before it looks at the
  buffer at all, so that one still needs the poll.
- DoScpRequest() reads its own EOF case the same way as the rest.
2026-08-31 11:53:29 -05:00
John Safranek 74098863ad Reject teardown on an unconfirmed channel
DoChannelClose() and wolfSSH_ChannelExit() answer only a channel whose open the
peer has confirmed. peerChannel is 0 until then and both senders resolve by peer
id, so a teardown aimed at an unconfirmed channel landed on whichever channel
held peer id 0 -- normally the live session -- latching its eofTxd and killing
its send direction.

- The close reply still retires the channel it names; there is simply nothing
  to say to a peer that has not answered the open.
- wolfSSH_ChannelExit() reports WS_CHANNEL_NOT_CONF, the same as the two
  send-EOF calls.
2026-08-31 11:53:29 -05:00
John Safranek fe936c8b8c Keep the channel until the peer's close
wolfSSH_ChannelExit() leaves the channel on the list once it has sent the EOF
and the close, so the application's pointer stays valid until the peer answers
and wolfSSH_worker() reports WS_CHANNEL_CLOSED. Removing it locally freed the
pointer under the caller and left the peer's close matching nothing.

- DoChannelClose() sends the EOF ahead of the close, per RFC 4254 section 5.3,
  and sends both whatever the flush reports: DoPacket() consumes the peer's
  close either way, so a message skipped over a blocked flush is never sent.
- It retires the channel and names it on a short write too. The debt belongs to
  the output buffer, not the channel, and withholding the close signal would
  leave the caller timing the teardown out.
- The worker flushes that reply, keeps WS_CHANNEL_CLOSED as the return value,
  and leaves WS_WANT_WRITE latched so the caller knows to drain
  wolfSSH_OutputPending() before closing the socket. ssh.h says so.

Issue: F-8839
2026-08-31 11:53:29 -05:00
John Safranek 5911c51ef3 Drop closeTxd on a discarded close
SendChannelClose() latches closeTxd on the same terms as the EOF beside it: on
anything but the send failure that discards the output buffer. It latched
unconditionally, so a closeTxd claiming a close that never left made
wolfSSH_shutdown()'s gate skip the teardown altogether.

- Split from the EOF latch on purpose: that one widens, from success-only to
  bundled, and this one narrows. Read as one change they read wrong.
2026-08-31 11:53:29 -05:00
John Safranek 147b787798 Latch eofTxd on the bundled EOF
SendChannelEof() commits eofTxd once the EOF is in the output buffer, not only
once the flush reports success. A short write leaves the bytes queued and they
go out on the next flush, so the retry an application makes on WS_WANT_WRITE
must not put a second EOF behind the first.

- The exception is the send failure that discards the output buffer, taking
  the EOF with it: latching there would leave the channel refusing every later
  send for an EOF that never went anywhere.
- A reset or a closed peer keeps the bytes, so they still count. The test is
  what the buffer holds, since wolfSSH_SendPacket() reports all three the same
  way.
- Both arms have a test: the discard through FailIoSend, and the reset that
  keeps the bytes queued and latches.

Issue: F-8826
2026-08-31 11:53:29 -05:00
John Safranek 591510257b Add the channel half-close API
wolfSSH_ChannelSendEof() and wolfSSH_stream_send_eof() close an application's
sending direction and leave its receiving direction open, the half-close of
RFC 4254 section 5.3. Reads keep working until the peer sends its own EOF or
closes; data sends on the channel then report WS_EOF, while requests, the exit
status and the teardown messages still go out.

- Both refuse a channel whose open the peer has not confirmed: peerChannel is
  0 until then and the send resolves by peer id, so the EOF would land on
  whichever channel holds peer id 0.
- Both sit behind the disconnect gate and refuse to put a packet between
  KEXINIT and NEWKEYS.
- stream_send_eof() reports WS_REKEYING itself rather than latching it, the way
  stream_peek() does; ssh.h says so, since stream_send() differs.
2026-08-31 11:53:29 -05:00
John Safranek 4817f8e0d2 Drain buffered data before the EOF
wolfSSH_stream_read() and wolfSSH_stream_peek() hand back what the peer sent
before they report its EOF. The eofRxd test now sits behind the buffered-data
test, so a half-close no longer strands whatever arrived with it or just ahead
of it.

- stream_read() takes the head channel id before DoReceive() and keeps waiting
  when the EOF belongs to another channel: the head is still open and still
  has nothing buffered, so reporting it would be indistinguishable. Multi-
  channel callers see that one through the worker or the callback.
- Looping only while the head is unchanged, since DoChannelClose() can free it
  and the buffer pointer lives in it.
- The disconnect contract in ssh.h and internal.h drops the caveat that the
  EOF outranks the drain.
- test_ChannelEofHalfClose() picks up the drain assertions, which the commit
  ahead of this one cannot satisfy.
2026-08-31 11:53:29 -05:00
John Safranek d9c223cbae Stop echoing the peer's channel EOF
DoChannelEof() latches eofRxd and reports WS_EOF. It used to answer with an EOF
of its own, which latched eofTxd, after which the "Cannot send data after EOF"
gates in SendChannelData() and SendChannelExtendedData() refused every later
send: a peer that half-closed could never be replied to. RFC 4254 section 5.3
leaves that reply to the application.

- wolfSSH_worker() carries WS_EOF out with the channel it belongs to, and does
  not mask it during a rekey; the event is raised once, on arrival.
- DoReceiveHandshake() absorbs it in the three accept/connect states that can
  run with a channel already open. A legal EOF is not a handshake failure.
- wolfSSH_shutdown() treats it as the response it was waiting for.
- Tests cover the half-close, the channel id, the rekey case, the callback,
  which had no test anywhere in the tree, and a handshake that survives an EOF.
- test_ConnectSurvivesChannelEof() sits in its own client region, since
  wolfSSH_connect() is not built with NO_WOLFSSH_CLIENT.

Issue: F-1687
2026-08-31 11:53:29 -05:00
John Safranek d70f8e64d3 Hoist the shared unit test doubles
The receive mock and the packet builders sit ahead of every endpoint region
rather than inside the server half. A staged-packet IORecv and a plaintext
packet builder have nothing to do with which endpoint is built, and the tests
that follow reach for them from both halves.

- Marked WS_MAYBE_UNUSED, since either half can be the only user of any one.
- WantWriteIoSend() joins DiscardIoSend() among the send mocks.
- A relocation only: no test body changes, and the moved text is unchanged
  bar the annotation. Read it with --color-moved.
2026-08-31 11:53:29 -05:00
John Safranek c5d283b024 fix: compare RSA blocks instead of parsing
RFC 8332 section 3 advises against verifying RSASSA-PKCS1-v1_5 by
applying the key and parsing the output. wolfSSH_RsaVerify() builds the
expected EMSA-PKCS1-v1_5 block, applies the key with a raw public
operation, and compares whole modulus-sized blocks.

- verify through wc_RsaFunction() and a constant-time block compare
- reject a signature not less than the modulus, which the raw operation
  would otherwise reduce into a second encoding of the same signature
- reject a key too small for the digest, and a wc_RsaEncryptSize() error
- carve the four working blocks from one allocation
- add test_RsaVerify_BadPadding() and test_RsaVerify_SigRange()

Offload builds leave the modulus empty, so the range check is skipped
there. That check walks the bytes itself rather than calling memcmp(),
which some libcs compare as signed char, ordering the modulus's high
byte backwards. The padding cases pass against the old code as well;
the range case does not.

Issue: F-10574
2026-08-31 10:13:10 -05:00
John Safranek c88e2e7212 tests: cover a channel-open rejection on a forwarding channel
No regress case registered a channelOpenCb and a fwdCb at the same time,
which is why the v1.5.0 clobber -- fwdCb(LOCAL_SETUP) overwriting the
open callback's rejection -- shipped unnoticed. The fix is on master
already (0317c40f, 616eb681); this is the missing gate.

- TestDirectTcpipOpenCbRejectBeatsFwdCb: both callbacks registered and
  the open callback rejects, so the peer must get a channel-open failure
  and the forwarding hook must not run at all
- TestDirectTcpipFwdCbRejectAfterOpenCbAccept: the other half, where the
  open callback accepts and the fwdCb's rejection has to reach the peer
- TestDirectTcpipFwdCbRejectsChannelId: DoChannelOpen() consults the
  fwdCb twice, and only the setup rejection was covered

All three pin the recipient channel and the reason code, not just the
message id. The call counter is file scope rather than reached through
the callback ctx, so the zero the first test asserts cannot be a ctx that
stopped being delivered; the third test reads 2 on the same counter as
the positive control.
2026-08-30 11:16:05 -05:00
John Safranek 4b887680c1 fix: always send USERAUTH_FAILURE on reject
RFC 4252 section 5.1 has the server answer a request it does not accept
with USERAUTH_FAILURE. A callback returning WOLFSSH_USERAUTH_REJECTED
now always gets that reply, and always ends the session.

NO_FAILURE_ON_REJECTED suppressed the reply. The macro was never set by
configure, named in a header, or documented, and it guarded all four
method handlers alike.

- drop the macro and its four guards
- end the session on a rejected keyboard-interactive setup, the one
  rejection that used to leave the peer retrying to the cap
- state the guarantee on WOLFSSH_USERAUTH_REJECTED in wolfssh/ssh.h
- add test_UserAuthRejectedSendsFailure() over the dispatched methods and
  the keyboard-interactive response, asserting on the wire since the
  handlers return WS_USER_AUTH_E anyway

Issue: F-11672
2026-08-28 21:53:43 -05:00
John Safranek 26ccac3cdd examples, tests: terminate the ES_ERROR messages with a newline
ES_ERROR() passes its arguments straight to fprintf(stderr, ...), so a
message with no trailing newline runs into whatever the shell or the
test harness prints next.

- 7 sites in the echoserver, 6 in tests/auth.c, 8 in the Espressif copy
  of the echoserver, which carries the same macro and takes this kind of
  cross-cutting fix (e91ff50d, ab8058d7)
- the two wrapped format strings already ended with a newline
2026-08-28 18:22:34 -05:00
John Safranek a98fbddb05 fix: clear userauth state on bad service
A USERAUTH_REQUEST naming a service other than "ssh-connection" is
refused before method dispatch, so it drops the per-method state there
too. A following USERAUTH_INFO_RESPONSE is no longer run against a
keyboard-interactive exchange the server has already failed.

- reset ssh->authId and clear ssh->kbSetupPending on the refusal
- charge the abandoned exchange, as the dispatch path does
- leave ssh->kbAuth alone; clients free those buffers by promptCount
- add wolfSSH_TestDoUserAuthInfoResponse() for the tests
- cover the refusal and the stale INFO_RESPONSE in tests/unit.c

Issue: F-10575
2026-08-28 17:55:19 -05:00
John Safranek 89b8055a0a fix: reject service messages while keying
RFC 4253 section 7.1 bars SERVICE_REQUEST and SERVICE_ACCEPT between a
KEXINIT and the matching NEWKEYS. The admission check now consults
ssh->isKeying, which acceptState and connectState do not track. It tests
WOLFSSH_PEER_IS_KEYING, as the KEXINIT gate below it does: a peer that
has not seen our KEXINIT yet may still legally send.

- gate MSGID_SERVICE_REQUEST on isKeying in IsMessageAllowedServer()
- gate MSGID_SERVICE_ACCEPT on isKeying in IsMessageAllowedClient()
- record the refusals as WS_MSGID_NOT_ALLOWED_E, the code DoReceive()
  stores anyway, rather than WS_REKEYING, a soft retry-later status
- add unit and receive-path coverage in tests/regress.c

Issue: F-10569
2026-08-28 17:45:36 -05:00
John Safranek 5fb7be429b fix: validate keyboard-interactive prompts
RFC 4256 section 3.3 forbids an empty prompt, and RFC 4251 section 5
allows only 0 or 1 in a boolean field. Validation runs before the
payload is sized, so the sizing and building passes agree.

- reject a zero-length prompt or one with a NULL buffer
- reject a prompt count with the arrays unset
- reject a prompt over WOLFSSH_MAX_PROMPT_SZ, which the sizing pass
  would otherwise sum into a wrapped payload size
- normalize the outgoing echo byte to 0 or 1
- cover the rejections in tests/api.c, the echo byte in tests/auth.c

Issue: F-10583, F-10584
2026-08-28 17:45:20 -05:00
John Safranek b8b40322b0 Stop answering traffic after a disconnect
DoPacket() skips the whole message dispatch once ssh->disconnected is set, for
every message but a DISCONNECT. The handlers that answer must not -- a close
draws an EOF and a close of ours, a request a success or failure, an open a
confirmation, an unknown message an UNIMPLEMENTED -- and what the rest would
record is of no use to a caller that can no longer send. RFC 4253 section 11.1.

- Inbound data from here on is dropped rather than buffered, so the read path
  hands back only what arrived before the disconnect. ssh.h and internal.h say
  so, beside the calls and beside the flag.
- A DISCONNECT still reaches DoDisconnect(), which sends nothing and is what
  latches WS_DISCONNECT; SendDisconnect() sets the flag too, so ours can be
  the one that raised it.
- The frame advance steps over the whole packet, so the stream stays in step
  with no payload bookkeeping of its own.
- wolfSSH_worker() gates on SendAfterDisconnect() the way wolfSSH_accept() and
  wolfSSH_connect() do. With the dispatch skipped there is no non-success left
  to return, so it would answer a healthy session for as long as the peer kept
  talking and the SFTP and SCP drive loops would keep pumping a dead one. The
  rekey test below the gate needs no disconnect of its own: the gate returns
  first, and a DISCONNECT arriving mid-pass leaves ret fatal.
  wolfSSH_shutdown() drops the channel on a disconnect before its own pump, so
  the gate does not cost it the read it does there.
- tests/regress.c pins all four: no reply goes out, the stream stays in step
  with a disconnect queued behind a skipped close, late channel data is
  dropped rather than queued, and the worker reports the disconnect on the
  pass that takes it and on every pass behind it.
2026-08-28 11:18:41 -05:00
John Safranek 239850775c Gate the handshake drivers on a disconnect
wolfSSH_accept() and wolfSSH_connect() drive the handshake only while the
session is live. Both gate on SendAfterDisconnect() ahead of the pending-send
block, which would otherwise flush a queued disconnect and count it as the
next handshake message; the shutdown paths own that flush.

- The prototype sits ahead of both drivers, since either can be the only one
  built.
- TestDisconnectGatesAccept() and TestDisconnectGatesConnect() cover a local
  disconnect, one from the peer, and a queued short send. One test per
  endpoint, so a single-sided build keeps the coverage that applies to it.
- A received disconnect used to reach the error-state test in wolfSSH_accept()
  and report WS_INVALID_STATE_E; the gate answers WS_FATAL_ERROR first, and
  both tests pin that.
- The contract comments in ssh.h and internal.h drop the ungated note.
2026-08-28 11:18:41 -05:00
Yosuke Shimizu 78c992a5a1 ssh.c: report the byte count from a channel read that defers its credit
- wolfSSH_stream_read() advances inputBuffer->idx before crediting the
  window, and it and _ChannelRead() return the bytes copied with a
  non-success adjust left in ssh->error.
- _ChannelRead() takes the WOLFSSH from channel->ssh, rejects an idx
  past inputBuffer->length, restores the entry ssh->error on a clean
  credit, and retires a stale WS_WANT_WRITE only when its own credit
  went out.
- wolfSSH_SFTP_Close() checks NoticeError() only on a failed send.
- The src/ssh.c block comments, and new wolfssh/ssh.h notes above
  wolfSSH_stream_read(), wolfSSH_ChannelRead() and
  wolfSSH_ChannelIdRead(), state the window-adjust and ssh->error
  contract.
- tests/unit.c adds test_stream_read_deferredWindowAdjust() and
  test_ChannelIdRead_deferredWindowAdjust(): an adjust that defers,
  then fails, then succeeds, plus a read with nothing buffered against
  a seeded WS_WANT_WRITE.
2026-08-27 09:01:27 -07:00
Yosuke Shimizu 83762b7ba6 wolfsftp: buffer the SFTP DATA length across partial reads
- STATE_SEND_READ_FTP_DATA trims state->buffer to UINT32_SZ with
  wolfSSH_SFTP_buffer_set_size(), reads the data string length into
  it with wolfSSH_SFTP_buffer_read(), and decodes it with
  wolfSSH_SFTP_buffer_rewind() and wolfSSH_SFTP_buffer_ato32().
- The outSz bound is applied to the decoded length before
  wolfSSH_SFTP_buffer_create() allocates for it, and that call's
  return is checked.
- ssh->error is set to WS_BUFFER_E when the size or decode helper
  fails, to WS_RECV_OVERFLOW_E when the decoded length exceeds
  outSz, and to WS_MEMORY_E when the allocation fails.
- The szFlat stack array is removed from
  wolfSSH_SFTP_SendReadPacket().
- tests/unit.c gains test_SftpSendReadPacketSplit() and
  test_SftpSendReadPacketOverflow(), both registered in
  wolfSSH_UnitTest(), with the SftpBuildData() and
  SftpClientDriveReadSplit() helpers. The first drives a DATA reply
  split at each of the four points across the length prefix; the
  second drives one whose string length exceeds the caller's buffer
  and checks for WS_RECV_OVERFLOW_E with no retained send read
  state.

Issue: F-8828
2026-08-26 20:44:18 -07:00
Yosuke Shimizu 40f9400f94 internal: send a disconnect when key exchange fails
- DoKexDhInit() sends SSH_MSG_DISCONNECT with KEY_EXCHANGE_FAILED on
  WS_CRYPTO_FAILED and WS_PUBKEY_REJECTED_E, DoKexDhGexGroup() on
  WS_CRYPTO_FAILED and WS_DH_SIZE_E.
- DoKexDhReply() sends KEY_EXCHANGE_FAILED on WS_CRYPTO_FAILED and
  HOST_KEY_NOT_VERIFIABLE on WS_PUBKEY_REJECTED_E.
- DuplexEndpoint records the reason code of a plaintext outbound
  disconnect, and InitKexReplyHarnessKex() takes an explicit KEX
  algorithm.
- New mutator modes shorten f and e, write a zero-length e, cut the
  GEX prime below the requested floor and set the GEX generator to 1;
  LocateSinglePacketPayload() finds the payload for all three
  single-packet rewriters.
- The harness KEX algorithm falls back to curve25519-sha256, then
  ecdh-sha2-nistp256, when no plain diffie-hellman-group is built.
- Tests assert the reason code on the wire for each new mode and for
  host key rejection, and assert no disconnect on a successful
  handshake.

Issue: F-8838
2026-08-26 20:43:23 -07:00
John Safranek 78633a1a23 Clear disconnectTxd once the buffer drains
The flag was set in SendDisconnect() and never cleared, so it meant "a
disconnect was sent" rather than "a flush is owed". Once ours had gone
out, the next teardown call still pushed whatever the internal senders
had queued behind it: measured, a CHANNEL_EOF from DoChannelEof() went
on the wire after the disconnect. wolfSSH_SendPacket() now clears it.

Issue: F-8837
2026-08-26 20:15:01 -05:00
John Safranek d05978f00d Report the disconnect with no channel to drop
wolfSSH_shutdown() set ssh->error to WS_DISCONNECT only inside the
channel branch, so a flush that emptied the buffer with the channel
already retired left behind the WS_WANT_WRITE that queued it. echoserver
and sftpclient read that error and burn ten wolfSSH_worker() calls on a
write that is already done. TestShutdownFlushesWithNoChannel asserts it.

Issue: F-8837
2026-08-26 20:15:01 -05:00
John Safranek 173a85b1eb Disconnect outranks a stuck rekey
wolfSSH_stream_peek() and wolfSSH_stream_read() test isKeying before
disconnected, so a peer that rekeys then disconnects wedges both: only
NEWKEYS clears isKeying and none is coming. Callers spin on WS_REKEYING
and never get the buffered data. Both gates and read's copy step now
defer to disconnected. Covered by TestDisconnectOutranksRekey.

Issue: F-8837
2026-08-26 20:15:01 -05:00
John Safranek 18b9249ee7 Make the disconnect tests prove what they claim
The two disconnect tests ran on a session that had never finished user
auth, so IsMessageAllowed() blocked the sends on its own and the "nothing
on the wire" assertions held even with the gates removed. Both now sit
past user auth. With only the shutdown gate reverted the test measures 72
bytes out and both teardown flags set, where before it measured nothing.

- wolfSSH_stream_peek() reports WS_DISCONNECT when the channel is gone,
  the way wolfSSH_stream_read() already did; a missing channel used to
  read as a bad argument on a session that had simply ended
- the drain test covers the no-channel case for both calls

Issue: F-8837
2026-08-26 20:15:01 -05:00
John Safranek 5805256b4c Close the last three post-disconnect sends
The disconnect gate left three ways for traffic to reach a peer that had
already ended the session, and it made the default highwater callback
report a failure for a packet that had gone out fine.

- wolfSSH_shutdown() drops the channel when ssh->disconnected is set, so
  the EOF, exit status and close are skipped along with the wait for a
  close the peer will never send
- wsHighwater() skips the rekey request on a disconnected session, so a
  firing high water mark no longer turns SendDisconnect() and
  SendChannelEof() into failures
- wolfSSH_ChangeTerminalSize() gained the SendAfterDisconnect() gate,
  making the ssh.h contract true for every send declared below it
- regress covers all three, including that shutdown leaves eofTxd and
  closeTxd clear and puts nothing on the wire

A disconnect of our own left queued by a short send still reaches the peer.
SendDisconnect() records disconnectTxd once the packet is bundled, and one
FlushQueuedDisconnect() helper gates the retry in wolfSSH_SendDisconnect()
and wolfSSH_shutdown() on that. Keying it on disconnected alone would push
whatever was queued, since the peer's disconnect sets that flag too and
leaves only unrelated traffic behind.

wolfSSH_shutdown() flushes ahead of the channel-list test, so the peer's
close retiring the last channel does not strand the disconnect, and an
unfinished flush outranks WS_CHANNEL_CLOSED. Its WS_WANT_WRITE stays in
ssh->error as well, since callers gate their shutdown retry on that.

The highwater guard sits in HighwaterCheck(), not in the default callback:
the return that fails the send comes from whatever callback the application
installed, and it propagates out through wolfSSH_SendPacket().

Issue: F-8837
2026-08-26 20:15:01 -05:00
John Safranek ef43586026 Gate the last three senders on a disconnect
The ssh.h comment promised that every send call below it reports
WS_DISCONNECT, but three did not: wolfSSH_TriggerKeyExchange(),
wolfSSH_SendIgnore() and wolfSSH_SendDisconnect().

- All three now take the SendAfterDisconnect() gate, so the sentence in
  ssh.h describes the code rather than the intent.
- TriggerKeyExchange() is the highwater callback's rekey trigger, so this
  also stops a rekey starting on a session the peer has ended.
- SendIgnore() and SendDisconnect() gained the NULL check the gate needs;
  both already reported WS_BAD_ARGUMENT for that from the callee.
- A second disconnect is refused: one ends the session.
- regress.c: the three calls join the send sweep.

Issue: F-8837
2026-08-26 20:15:01 -05:00
John Safranek bac3c8ba37 Report a disconnect from stream_peek
wolfSSH_stream_peek() is how the shell loops decide whether a channel is
drained. It had no disconnect check, so a dead session looked exactly like
a drained one: zero bytes available, nothing to tell them apart.

- Report WS_DISCONNECT once the buffered data runs dry, the same shape
  wolfSSH_stream_read() uses. What is still buffered comes back first.
- ssh.h and internal.h name peek alongside the read call, and no longer
  claim the read side is ungated outright.
- regress.c: peek sees the buffered byte, then sees the disconnect.

Raised from the channel-eof branch, where peek becomes the drain gate for
the wolfsshd and echoserver shell loops.

Issue: F-8837
2026-08-26 20:15:01 -05:00
John Safranek 285e0409cf Block every send after a disconnect
The disconnect flag gated wolfSSH_stream_read() and wolfSSH_stream_send(),
which is the client-side API. wolfsshd and echoserver drive their channels
through the channel-id calls, so the daemon was never gated at all.

- New SendAfterDisconnect() helper, used by the six send entry points:
  stream_send, stream_exit, ChannelIdSend, ChannelIdSendExt,
  extended_data_send and global_request.
- Reads stay open, since data that arrived before the disconnect is still
  the caller's. wolfSSH_stream_read() drains its buffer and reports
  WS_DISCONNECT only once it runs dry.
- wolfSSH_worker() stays ungated; the shutdown paths still pump it.
- ssh.h and internal.h describe the split.
- regress.c: buffered data survives the disconnect, and every send call
  refuses without a byte leaving the session.

Issue: F-8837

Every public send call means every one: the channel-pointer sends
(wolfSSH_ChannelSend, wolfSSH_ChannelSendExt, wolfSSH_ChannelExit), the
forwarding requests and both wolfSSH_ChannelFwdNew* opens carry the gate
too, and none of them had a message-filter backstop.

ChannelCreditWindow() parks its credit rather than sending. The reads that
drain what arrived before the disconnect credit the window for the bytes
taken, and that credit went straight to the transport: each drain put a
CHANNEL_WINDOW_ADJUST on the wire after the session was over, and a failing
send replaced the byte count already copied for the caller.
2026-08-26 20:15:01 -05:00
John Safranek e6b324d14f Make a disconnect end the session
SSH_MSG_DISCONNECT left nothing behind but ssh->error, which
wolfSSH_stream_read() clears on entry. An application looping on the
stream calls lost the code and went back to a connection already over.

- Add WOLFSSH.disconnected, set by DoDisconnect() and SendDisconnect().
- DoDisconnect() sets it before decoding the payload, so a malformed
  message still ends the session. RFC 4253 section 11.1.
- wolfSSH_stream_read() and wolfSSH_stream_send() report WS_DISCONNECT
  from the flag instead of reaching for the transport again.
- Both guards run ahead of the channelList NULL test, so a torn-down
  session reports the disconnect rather than WS_BAD_ARGUMENT.
- ssh.h states that undrained channel data goes with the session;
  internal.h states which calls the flag gates and which it does not.
- regress.c: the receive side, the send side, and both of those again on
  a session with an open channel.

Issue: F-8837

The test channel credits the peer's window too. Left at 0, SendChannelData()
bails with WS_WINDOW_FULL before the wire, and the "nothing went out" checks
would hold with the gate removed.
2026-08-26 20:15:01 -05:00
John Safranek 5303681b91 Fix the shutdown channel teardown
wolfSSH_shutdown() searched for the session channel by the peer's channel
ID while telling ChannelFind() to match the local ID field. Each side
numbers its channels independently, so the search usually found nothing.

- The session channel is the head of the list; take it directly instead
  of searching for what is already in hand.
- Restores the EOF, exit-status and close sends, and the drain that waits
  on the peer's close, all skipped on the NULL result.
- Only bit when the two IDs differ, so the single-channel tests, where
  both sides pick 0, never saw it.
- unit.c: shut down a channel whose peer ID is not its local ID, then
  check that EOF and close went out.

Issue: F-8817
2026-08-26 20:15:01 -05:00
John Safranek 245baf01f0 Document the wolfSSH_SetChannelType contract
The refusals added for names the peer cannot use changed the return
contract of a public API whose block comment still promised only
WS_SUCCESS. There is no dox_comments entry, so that comment is all an
embedder has.

- Spell out each WS_BAD_ARGUMENT case, the keep-the-stored-name rule,
  and that a refused call leaves the selected type alone.
- api.c asserts connectChannelId across the refusals. It is the field
  SendChannelRequest() switches on, so moving the checks back below the
  assignment would otherwise pass.
2026-08-26 12:11:15 -05:00
John Safranek e6bce999c6 Reject a window-change on a channel with no pty
A window-change arriving before any pty-req had nothing to resize, but
the size was stored and the resize callback run anyway. Dropbear refuses
the same request for the same reason.

- Reject it with the existing rej path, so no reply is sent for a
  request RFC 4254 sec 6.7 says takes none, and the session continues.
- unit.c covers the rejection, and now drives a real pty-req, which
  had no coverage on the receive side at all.
2026-08-26 12:11:15 -05:00
John Safranek f956512c5a Clamp terminal dimensions to what a winsize can hold
The four pty-req and window-change dimensions were decoded straight into
the WOLFSSH fields and handed to the resize callback unchecked. The
consumers copy them into the unsigned short fields of a struct winsize
for TIOCSWINSZ, so anything above 65535 wraps, and 0x10000 arrives as a
0x0 terminal.

- Add SetTerminalSize() and route both the pty-req and window-change
  branches through it, so pty-req stops decoding straight into the
  WOLFSSH fields.
- Clamp all four to TERMINAL_DIMENSION_MAX. Others truncate at the
  ioctl and accept it, but wolfSSH hands the word32 values to
  termResizeCb first, so an unclamped dimension escapes the library
  rather than being cut down on the way to the ioctl.
- Take a zero dimension as sent. Others do too, and a zero is how a
  peer reports a dimension it has no information about.
- unit.c drives all four dimensions from one table, covering the zero,
  single-zero and wrapping cases, in an error code range no other case
  in the function claims.

F-8833 recommended ignoring a zero dimension. That is declined above:
Others take zeros as sent, and a zero is how a peer reports a dimension
it has no information about. The finding's symptom, a 0x0 terminal, is
also reached by a route it did not identify, a dimension above 65535
wrapping, and the clamp closes that one.

Issue: F-8833
2026-08-26 12:11:15 -05:00
John Safranek fd91af61c3 Frame DoPacket from the validated packet length
DoPacket stepped to the next packet using payloadIdx, which handlers
set to however much they read. The default case reads none of an
unimplemented message's payload, leaving the cursor short by that much.

- Advance inputBuffer.idx by UINT32_SZ + curSz from the packet start,
  the length DoReceive already bounds-checked, so a handler that
  ignores trailing bytes cannot move the next packet's start.
- Snapshot curSz on entry beside the packet start, so the frame is
  computed entirely from entry-time state. Reading it back after the
  handler switch would describe the next packet if a handler ever
  re-entered the receive path.
- Covers DoIgnore, DoDebug, DoUnimplemented, DoChannelSuccess and
  DoChannelFailure, which are all short on a padded payload.
- Clamp to the buffer length on the WS_BUFFER_E path.
- unit.c pins the cursor across an unimplemented message, driving
  DoPacket through a new wolfSSH_TestDoPacket() hook. The ShrinkBuffer()
  noted below zeroes the cursor, so DoReceive() cannot be in the path.

Note the short cursor is not currently observable: DoReceive calls
ShrinkBuffer() with forcedFree, which drops the rest of the buffer
after every packet. This is hardening, not a live desync.

Issue: F-8825
2026-08-26 12:11:15 -05:00
John Safranek 4d9f6a9721 Report a channel name the peer cannot use
wolfSSH_SetChannelType() discarded an exec or subsystem name it could
not use and still returned WS_SUCCESS. SendChannelRequest() then omits
the name field entirely, which the peer rejects as malformed, dropping
the connection. Both an oversized name and an empty one reach it; the
empty case is reachable from the command line as "wolfssh -c ''".

- Return WS_BAD_ARGUMENT for a name at or above WOLFSSH_MAX_CHN_NAMESZ,
  matching how the function already reports a bad type or side.
- Return WS_BAD_ARGUMENT when no name is given and none was stored by
  an earlier call, and when a size arrives with no name behind it.
- Keep returning WS_SUCCESS when an earlier call stored a name, which
  is what the SFTP and SCP retry loops depend on.
- Return before setting connectChannelId so a rejected call leaves
  no state behind, as the server-side exec rejection does.
- Keep the stored name intact when a later call is refused.
- api.c asserts each refusal, and the largest name still admitted.
2026-08-26 12:11:15 -05:00
John Safranek 050dee0fbf Bound channel maxPacketSz below the wire limit
MAX_PACKET_SZ caps the whole SSH binary packet, but the channel
maxPacketSz it was compared against counts only channel payload. A
peer honoring the advertised 35000 overruns the receiver's own check.

- Derive MAX_CHANNEL_PACKET_SZ in internal.h: MAX_PACKET_SZ less the
  transport framing, the CHANNEL_EXTENDED_DATA header, the worst-case
  padding BundlePacket() picks, and MAX_HMAC_SZ. 34899 by default.
- Name that overhead twice, once for the compiler and once as a
  literal for the preprocessor, which reads the wolfCrypt enum
  constants in the first form as zero. The #error guarding
  DEFAULT_MAX_PACKET_SZ uses the second rather than its own copy.
- MAX_CHANNEL_PACKET_SZ is derived rather than a tunable, so it is
  not overridable; an override defeated the bound it enforces.
- wolfSSH_CTX_SetWindowPacketSize() bounds maxPacketSz against that
  instead of MAX_PACKET_SZ; DEFAULT_MAX_PACKET_SZ is unaffected.
- api.c tests the new edge and that MAX_PACKET_SZ is now rejected.

Issue: F-8835
2026-08-26 12:11:15 -05:00
Yosuke Shimizu 709352ba8b internal: sign Ed25519 user-auth requests through the agent
- BuildUserAuthRequestEd25519() signs with wolfSSH_AGENT_SignRequest()
  when the agent is enabled, writing the returned signature blob
  length-prefixed into the reserved payload and advancing idx past it.
  The capacity handed to the agent is the room the prepare phase set
  aside: two lengths plus the signature and public key type sizes.
- The buffer the local signing path fills is allocated in that path
  rather than at the top of the function; sig starts NULL and the
  small-stack free at the tail already null-checks it.
- PrepareUserAuthRequestEd25519() notes that the agent holds the
  private key and loads none locally.
- tests/regress.c gains TestAgentEd25519UserAuthEmitsSignature(),
  TestAgentEd25519UserAuthPropagatesAgentError() and
  TestAgentEd25519UserAuthRejectsOversizeSignature(), which drive
  SendUserAuthRequest() over a mock agent and parse the emitted
  USERAUTH_REQUEST down to its signature field.
- InitAgentEd25519Ctx() takes the signature size the mock agent
  answers with, so a caller can hand back a blob past the capacity.
- ParsePayloadLen() and BuildExtInfoSigAlgs() move to the shared test
  helper section so the new tests and the existing callers share them.

Issue: F-11660
2026-08-26 08:50:47 -07:00