From 14e248f6dc9f7e25fd615b4aca8421f8fc92bae0 Mon Sep 17 00:00:00 2001 From: Yosuke Shimizu Date: Mon, 15 Jun 2026 14:50:42 +0900 Subject: [PATCH] Enforce LoginGraceTime in wolfsshd on Windows and make the grace flag per-connection --- .github/workflows/windows-check.yml | 35 ++++- apps/wolfsshd/test/run_all_sshd_tests.sh | 4 +- apps/wolfsshd/test/sshd_login_grace_test.ps1 | 129 +++++++++++++++++++ apps/wolfsshd/test/sshd_login_grace_test.sh | 55 ++++++-- apps/wolfsshd/wolfsshd.c | 122 ++++++++++++++---- 5 files changed, 303 insertions(+), 42 deletions(-) create mode 100644 apps/wolfsshd/test/sshd_login_grace_test.ps1 diff --git a/.github/workflows/windows-check.yml b/.github/workflows/windows-check.yml index cf4e955f2..1be0cf96c 100644 --- a/.github/workflows/windows-check.yml +++ b/.github/workflows/windows-check.yml @@ -43,13 +43,12 @@ jobs: working-directory: ${{env.GITHUB_WORKSPACE}}wolfssl run: nuget restore ${{env.WOLFSSL_SOLUTION_FILE_PATH}} - - name: updated user_settings.h for sshd and x509 + - name: Enable wolfSSH options (sshd, sftp, x509) in user_settings.h working-directory: ${{env.GITHUB_WORKSPACE}} - run: cp ${{env.USER_SETTINGS_H_NEW}} ${{env.USER_SETTINGS_H}} - - - name: replace wolfSSL user_settings.h with wolfSSH user_settings.h - working-directory: ${{env.GITHUB_WORKSPACE}} - run: get-content ${{env.USER_SETTINGS_H_NEW}} | %{$_ -replace "if 0","if 1"} + shell: bash + run: | + sed -i 's/#if 0/#if 1/g' ${{env.USER_SETTINGS_H_NEW}} + cp ${{env.USER_SETTINGS_H_NEW}} ${{env.USER_SETTINGS_H}} - name: Build wolfssl library working-directory: ${{env.GITHUB_WORKSPACE}}wolfssl @@ -65,3 +64,27 @@ jobs: # See https://docs.microsoft.com/visualstudio/msbuild/msbuild-command-line-reference run: msbuild /m /p:PlatformToolset=v142 /p:Platform=${{env.BUILD_PLATFORM}} /p:WindowsTargetPlatformVersion=${{env.TARGET_PLATFORM}} /p:Configuration=${{env.WOLFSSH_BUILD_CONFIGURATION}} ${{env.SOLUTION_FILE_PATH}} + - name: Locate wolfsshd.exe and stage wolfssl.dll + working-directory: ${{ github.workspace }}\wolfssh + shell: pwsh + run: | + $sshdExe = Get-ChildItem -Path "${{ github.workspace }}\wolfssh" -Recurse -Filter "wolfsshd.exe" -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -like "*Release*" } | Select-Object -First 1 + if (-not $sshdExe) { + Write-Host "ERROR: wolfsshd.exe not found" + exit 1 + } + Add-Content -Path $env:GITHUB_ENV -Value "SSHD_PATH=$($sshdExe.FullName)" + + $sshdDir = Split-Path -Parent $sshdExe.FullName + $wolfsslDll = Get-ChildItem -Path "${{ github.workspace }}\wolfssl" -Recurse -Filter "wolfssl.dll" -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($wolfsslDll -and -not (Test-Path (Join-Path $sshdDir "wolfssl.dll"))) { + Copy-Item -Path $wolfsslDll.FullName -Destination (Join-Path $sshdDir "wolfssl.dll") -Force + } + + - name: Test LoginGraceTime enforcement on Windows + working-directory: ${{ github.workspace }}\wolfssh\apps\wolfsshd\test + shell: pwsh + timeout-minutes: 2 + run: pwsh -File .\sshd_login_grace_test.ps1 -SshdExe "$env:SSHD_PATH" + diff --git a/apps/wolfsshd/test/run_all_sshd_tests.sh b/apps/wolfsshd/test/run_all_sshd_tests.sh index ddb0d20d3..50c069b8f 100755 --- a/apps/wolfsshd/test/run_all_sshd_tests.sh +++ b/apps/wolfsshd/test/run_all_sshd_tests.sh @@ -132,7 +132,6 @@ else #Github actions needs resolved for these test cases #run_test "error_return.sh" - #run_test "sshd_login_grace_test.sh" # add additional tests here, check on var USING_LOCAL_HOST if can make sshd # server start/restart with changes @@ -147,9 +146,10 @@ else run_test "sshd_forcedcmd_test.sh" run_test "sshd_window_full_test.sh" run_test "sshd_empty_password_test.sh" + run_test "sshd_login_grace_test.sh" else printf "Skipping tests that need to setup local SSHD\n" - SKIPPED=$((SKIPPED+3)) + SKIPPED=$((SKIPPED+4)) fi # these tests run with X509 sshd-config loaded diff --git a/apps/wolfsshd/test/sshd_login_grace_test.ps1 b/apps/wolfsshd/test/sshd_login_grace_test.ps1 new file mode 100644 index 000000000..b1bb872be --- /dev/null +++ b/apps/wolfsshd/test/sshd_login_grace_test.ps1 @@ -0,0 +1,129 @@ +#!/usr/bin/env pwsh +# +# Windows regression test for wolfsshd LoginGraceTime enforcement. +# +# Opens a raw TCP connection that never authenticates and verifies that +# wolfsshd drops it at the login grace deadline. No Windows user account or +# authorized key is required, because the connection is closed before +# authentication ever completes - this exercises the pre-auth grace timer only. +# +# Enforcement is checked behaviorally (the server closes the connection at the +# grace deadline), not by reading the daemon log, so the test does not depend on +# debug logging being compiled into the wolfSSH Windows build. +# +# Usage: +# pwsh sshd_login_grace_test.ps1 -SshdExe [-Port N] [-Grace N] +# (SshdExe also accepts the SSHD_PATH environment variable.) + +param( + [string]$SshdExe = $env:SSHD_PATH, + [int]$Port = 22224, + [int]$Grace = 5 +) + +$ErrorActionPreference = "Stop" +$exitCode = 1 + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = (Resolve-Path (Join-Path $scriptDir "..\..\..")).Path +$keyPath = (Resolve-Path (Join-Path $repoRoot "keys\server-key.pem")).Path +$confFile = Join-Path $scriptDir "sshd_config_test_login_grace" +$authFile = Join-Path $scriptDir "authorized_keys_test" + +if (-not $SshdExe -or -not (Test-Path $SshdExe)) { + Write-Host "ERROR: wolfsshd.exe not found (pass -SshdExe or set SSHD_PATH)" + exit 1 +} + +@" +Port $Port +Protocol 2 +LoginGraceTime $Grace +PermitRootLogin yes +PasswordAuthentication yes +PermitEmptyPasswords no +UseDNS no +HostKey $keyPath +AuthorizedKeysFile $authFile +"@ | Out-File -FilePath $confFile -Encoding ASCII + +"" | Out-File -FilePath $authFile -Encoding ASCII + +# Run wolfsshd in the foreground (-D selects the non-service path on Windows). +$sshd = Start-Process -FilePath $SshdExe ` + -ArgumentList "-D", "-f", "`"$confFile`"", "-p", "$Port" ` + -NoNewWindow -PassThru + +try { + # Wait for the listener to accept connections. + $up = $false + for ($i = 0; $i -lt 20; $i++) { + try { + $probe = New-Object System.Net.Sockets.TcpClient + $probe.Connect("127.0.0.1", $Port) + $probe.Close() + $up = $true + break + } + catch { + Start-Sleep -Milliseconds 500 + } + } + if (-not $up) { + # throw rather than exit so the finally block still stops the daemon + throw "wolfsshd did not start listening on port $Port" + } + + # Open a raw TCP connection and never authenticate. The server sends its + # banner, waits for ours, and must close the connection once the login grace + # time expires. Block on Read (with a timeout well past the grace time) and + # measure when the server closes the connection. + $stall = New-Object System.Net.Sockets.TcpClient + $stall.Connect("127.0.0.1", $Port) + $stream = $stall.GetStream() + $stream.ReadTimeout = ($Grace + 5) * 1000 + + $buf = New-Object byte[] 4096 + $dropped = $false + $sw = [System.Diagnostics.Stopwatch]::StartNew() + try { + while ($true) { + $n = $stream.Read($buf, 0, $buf.Length) + if ($n -le 0) { + $dropped = $true # server closed the connection + break + } + # otherwise the server sent its banner; keep waiting for the drop + } + } + catch [System.IO.IOException] { + # Read timed out: the connection was still open past the grace time. + $dropped = $false + } + $elapsed = [math]::Round($sw.Elapsed.TotalSeconds, 1) + $stall.Close() + + Write-Host "connection closed=$dropped after ${elapsed}s (grace=$Grace)" + + if ($dropped -and ($elapsed -ge ($Grace - 1)) -and ($elapsed -le ($Grace + 4))) { + Write-Host "PASS: unauthenticated connection dropped at login grace deadline" + $exitCode = 0 + } + elseif ($dropped) { + Write-Host "FAIL: connection closed at ${elapsed}s, not near the grace deadline ($Grace s)" + } + else { + Write-Host "FAIL: connection still open past the grace time (not enforced)" + } +} +catch { + Write-Host "ERROR: $_" + $exitCode = 1 +} +finally { + if ($sshd -and -not $sshd.HasExited) { + Stop-Process -Id $sshd.Id -Force -ErrorAction SilentlyContinue + } +} + +exit $exitCode diff --git a/apps/wolfsshd/test/sshd_login_grace_test.sh b/apps/wolfsshd/test/sshd_login_grace_test.sh index 00b950ba9..fd9e45ff2 100755 --- a/apps/wolfsshd/test/sshd_login_grace_test.sh +++ b/apps/wolfsshd/test/sshd_login_grace_test.sh @@ -47,18 +47,53 @@ if [ "$RESULT" != 0 ]; then exit 1 fi -# attempt clearing out stdin from previous echo/grep -read -t 1 -n 1000 discard +popd -# test grace login timeout by stalling on password prompt -timeout --foreground 7 "$TEST_CLIENT" -u "$USER" -h "$TEST_HOST" -p "$TEST_PORT" -t +# Test the grace-time timeout behaviorally: open a raw TCP connection, never +# authenticate, and confirm the server closes it at the grace deadline. This +# asserts the actual behavior rather than scraping the log, matching the Windows +# PowerShell test (and not relying on the daemon log being readable). +GRACE=5 +exec 3<>"/dev/tcp/$TEST_HOST/$TEST_PORT" +if [ "$?" != 0 ]; then + echo "FAIL: could not connect to $TEST_HOST:$TEST_PORT" + exit 1 +fi -popd -cat ./log.txt | grep "Failed login within grace period" -RESULT=$? -if [ "$RESULT" != 0 ]; then - echo "FAIL: Grace period not hit" - cat ./log.txt +# The server sends its banner, waits for ours (which never comes), then closes +# the connection once the grace time expires. Read until the server closes the +# connection (EOF) or the per-read timeout elapses, and measure how long it +# took. Use a large read timeout (GRACE + 8) and decide by elapsed time rather +# than read's exit status, which differs across bash versions (timeout returns +# >128 on modern bash but 1 on the macOS bash 3.2). +START=$SECONDS +while true; do + if IFS= read -r -t $((GRACE + 8)) -n 1024 _ <&3; then + : # received banner/data, keep waiting for the server to close + else + break # server closed (EOF) or the read timeout elapsed + fi +done +ELAPSED=$((SECONDS - START)) +exec 3>&- + +# An exit well before the read timeout means the server closed the connection; +# an exit near GRACE + 8 means it stayed open (not enforced). +if [ "$ELAPSED" -le $((GRACE + 4)) ]; then + DROPPED=1 +else + DROPPED=0 +fi + +echo "connection closed=$DROPPED after ${ELAPSED}s (grace=$GRACE)" + +if [ "$DROPPED" = 1 ] && [ "$ELAPSED" -ge $((GRACE - 1)) ]; then + echo "PASS: unauthenticated connection dropped at login grace deadline" +elif [ "$DROPPED" = 1 ]; then + echo "FAIL: connection closed at ${ELAPSED}s, before the grace deadline ($GRACE s)" + exit 1 +else + echo "FAIL: connection still open past the grace time (not enforced)" exit 1 fi diff --git a/apps/wolfsshd/wolfsshd.c b/apps/wolfsshd/wolfsshd.c index 4389e2774..b4b953a25 100644 --- a/apps/wolfsshd/wolfsshd.c +++ b/apps/wolfsshd/wolfsshd.c @@ -117,6 +117,11 @@ typedef struct WOLFSSHD_CONNECTION { int listenFd; char ip[INET6_ADDRSTRLEN]; byte isThreaded; + /* set when the login grace time expires before authentication completes */ + volatile byte timeOut; +#ifdef _WIN32 + PTP_TIMER loginTimer; /* threadpool timer enforcing login grace time */ +#endif } WOLFSSHD_CONNECTION; #ifdef __unix__ @@ -217,11 +222,11 @@ static void interruptCatch(int in) static void wolfSSHDLoggingCb(enum wolfSSH_LogLevel lvl, const char *const str) { /* always log errors and optionally log other info/debug level messages */ - if (lvl == WS_LOG_ERROR) { - fprintf(logFile, "[PID %d]: %s\n", WGETPID(), str); - } - else if (debugMode) { + if (lvl == WS_LOG_ERROR || debugMode) { fprintf(logFile, "[PID %d]: %s\n", WGETPID(), str); + /* flush so each line is visible immediately, e.g. to a consumer + * reading the log file while the daemon is still running */ + fflush(logFile); } } @@ -1900,26 +1905,68 @@ static int SHELL_Subsystem(WOLFSSHD_CONNECTION* conn, WOLFSSH* ssh, #endif #endif -#ifdef WIN32 -static volatile int timeOut = 0; +#ifdef _WIN32 +/* Cancels and frees the login grace time threadpool timer for a connection. */ +static void CancelLoginTimer(WOLFSSHD_CONNECTION* conn) +{ + if (conn != NULL && conn->loginTimer != NULL) { + /* setting the due time to NULL cancels any pending timer */ + SetThreadpoolTimer(conn->loginTimer, NULL, 0, 0); + WaitForThreadpoolTimerCallbacks(conn->loginTimer, TRUE); + CloseThreadpoolTimer(conn->loginTimer); + conn->loginTimer = NULL; + } +} + +/* threadpool callback marking a connection as timed out at the grace deadline */ +static VOID CALLBACK GraceTimeoutCb(PTP_CALLBACK_INSTANCE instance, PVOID ctx, + PTP_TIMER timer) +{ + WOLFSSHD_CONNECTION* conn = (WOLFSSHD_CONNECTION*)ctx; + + if (conn != NULL) { + conn->timeOut = 1; + } + (void)instance; + (void)timer; +} #else -static __thread int timeOut = 0; -#endif +/* Set by the SIGALRM handler when the login grace time expires. A signal + * handler may only access a volatile sig_atomic_t object, so the handler sets + * just this flag and the accept loop observes it. */ +static volatile sig_atomic_t loginGraceTimedOut = 0; + static void alarmCatch(int signum) { - timeOut = 1; + loginGraceTimedOut = 1; (void)signum; } +#endif + +/* Returns non-zero once the login grace time has expired for this connection. + * On Windows the threadpool callback records it on the connection; on POSIX the + * SIGALRM handler sets a sig_atomic_t flag that the accept loop reads here. */ +static int LoginGraceExpired(const WOLFSSHD_CONNECTION* conn) +{ +#ifdef _WIN32 + return conn->timeOut; +#else + (void)conn; + return (int)loginGraceTimedOut; +#endif +} static int UserAuthResult(byte result, WS_UserAuthData* authData, void* userAuthResultCtx) { (void)authData; - (void)userAuthResultCtx; if (result == WOLFSSH_USERAUTH_SUCCESS) { - #ifndef WIN32 - /* @TODO alarm catch on windows */ + /* authentication finished in time, cancel the login grace timer */ + #ifdef _WIN32 + CancelLoginTimer((WOLFSSHD_CONNECTION*)userAuthResultCtx); + #else + (void)userAuthResultCtx; alarm(0); #endif } @@ -1960,24 +2007,39 @@ static void* HandleConnection(void* arg) wolfSSH_set_fd(ssh, conn->fd); wolfSSH_SetUserAuthCtx(ssh, conn->auth); + /* let UserAuthResult reach this connection to cancel the grace timer */ + wolfSSH_SetUserAuthResultCtx(ssh, conn); - /* set alarm for login grace time */ + /* arm the login grace timer */ graceTime = wolfSSHD_AuthGetGraceTime(conn->auth); if (graceTime > 0) { - #ifdef WIN32 - /* LoginGraceTime enforcement is not yet implemented on Windows. - * @TODO implement via CreateWaitableTimer or similar. */ - wolfSSH_Log(WS_LOG_WARN, "[SSHD] LoginGraceTime is set but " - "not enforced on this platform"); + #ifdef _WIN32 + FILETIME due; + ULARGE_INTEGER rel; + + /* negative relative time, in 100 ns units */ + rel.QuadPart = (ULONGLONG)(-((LONGLONG)graceTime * 10000000LL)); + due.dwLowDateTime = rel.LowPart; + due.dwHighDateTime = rel.HighPart; + + conn->loginTimer = CreateThreadpoolTimer(GraceTimeoutCb, conn, NULL); + if (conn->loginTimer == NULL) { + wolfSSH_Log(WS_LOG_WARN, "[SSHD] Unable to create login grace " + "timer, LoginGraceTime not enforced"); + } + else { + SetThreadpoolTimer(conn->loginTimer, &due, 0, 0); + } #else signal(SIGALRM, alarmCatch); + loginGraceTimedOut = 0; /* clear any stale state before arming */ alarm((unsigned int)graceTime); #endif } ret = wolfSSH_accept(ssh); error = wolfSSH_get_error(ssh); - while (timeOut == 0 && (ret != WS_SUCCESS + while (LoginGraceExpired(conn) == 0 && (ret != WS_SUCCESS && ret != WS_SCP_INIT && ret != WS_SFTP_COMPLETE) && (error == WS_WANT_READ || error == WS_WANT_WRITE)) { @@ -1995,16 +2057,24 @@ static void* HandleConnection(void* arg) error = WS_FATAL_ERROR; } - wolfSSH_Log(WS_LOG_ERROR, - "[SSHD] grace time = %ld timeout = %d", graceTime, timeOut); + #ifndef _WIN32 + /* record a SIGALRM grace timeout on the connection so the logging and + * the grace-period check below see it */ + if (loginGraceTimedOut) { + conn->timeOut = 1; + } + #endif + wolfSSH_Log(WS_LOG_INFO, + "[SSHD] grace time = %ld timeout = %d", graceTime, + conn->timeOut); if (graceTime > 0) { - if (timeOut) { + if (conn->timeOut) { wolfSSH_Log(WS_LOG_ERROR, "[SSHD] Failed login within grace period"); } - #ifdef WIN32 - /* @TODO SetTimer(NULL, NULL, graceTime, alarmCatch); */ + #ifdef _WIN32 + CancelLoginTimer(conn); /* cancel any pending timer */ #else alarm(0); /* cancel any alarm */ #endif @@ -2690,6 +2760,10 @@ static int StartSSHD(int argc, char** argv) conn->auth = auth; conn->listenFd = (int)listenFd; conn->isThreaded = isDaemon; + conn->timeOut = 0; +#ifdef _WIN32 + conn->loginTimer = NULL; +#endif /* wait for a connection */ if (PendingConnection(listenFd)) {