From 8b8ae75befb1ae41157ab71a21ed1d94b8af715e Mon Sep 17 00:00:00 2001 From: John Safranek Date: Tue, 27 Oct 2026 11:29:53 -0700 Subject: [PATCH 1/3] wolfsshd: honor AuthorizedKeysFile none "AuthorizedKeysFile none" now turns off the authorized keys file, as in OpenSSH, instead of naming ~/none, a file the user controls. The value stays "none" through Match composition, and SearchForPubKey refuses it before resolving any path. - match "none" case-insensitively, as OpenSSH does - OpenSSH certificates still check against TrustedUserCAKeys - X.509 certificates do not fall back to CA-only under "none" - a "none" node passes the wolfSSH_TrustedSystemCAKeys startup gates - add sshd_authkeys_none_test.sh: key in ~/none, X.509 CA-only and system CA gate cases, each set globally and in a Match block Issue: F-15558 --- README.md | 4 +- apps/wolfsshd/auth.c | 17 +- apps/wolfsshd/configuration.c | 35 ++- apps/wolfsshd/configuration.h | 2 + apps/wolfsshd/test/run_all_sshd_tests.sh | 2 + apps/wolfsshd/test/sshd_authkeys_none_test.sh | 275 ++++++++++++++++++ apps/wolfsshd/test/test_configuration.c | 182 ++++++++++++ apps/wolfsshd/wolfsshd.c | 16 +- 8 files changed, 524 insertions(+), 9 deletions(-) create mode 100755 apps/wolfsshd/test/sshd_authkeys_none_test.sh diff --git a/README.md b/README.md index ac0c024e9..15fcdf8fa 100644 --- a/README.md +++ b/README.md @@ -609,7 +609,9 @@ rejected inside a `Match` block): `AuthorizedUPNDomains` or a per-user `AuthorizedKeysFile`; note `AuthorizedUPNDomains` constrains only the certificate's UPN realm, not which trusted CA issued it, so use it only when the OS trust store holds - solely your organization's CA. + solely your organization's CA. A config node with `AuthorizedKeysFile none` + also satisfies either check, since X.509 certificate login is refused + outright for the accounts it covers. Note that the pre-existing `HostKey` and `HostCertificate` directives are now also rejected when they appear after a `Match` block (matching OpenSSH); they diff --git a/apps/wolfsshd/auth.c b/apps/wolfsshd/auth.c index eadb0a027..647e3db6d 100644 --- a/apps/wolfsshd/auth.c +++ b/apps/wolfsshd/auth.c @@ -1604,6 +1604,16 @@ WOLFSSHD_STATIC int SearchForPubKey(const char* path, char authKeysPath[MAX_PATH_SZ]; int rc = 0; + /* AuthorizedKeysFile none: no file is searched, so no key matches. The + * config's authKeysFileNone flag is authoritative; the setter stores + * "none" whenever it sets that flag, so this string check sees the same + * state and keeps a relative "none" from ever resolving to ~/none. */ + if (wolfSSHD_ConfigIsNoneKeyword(authKeysFile)) { + wolfSSH_Log(WS_LOG_INFO, + "[SSHD] AuthorizedKeysFile is none, no keys to check"); + return WSSHD_AUTH_FAILURE; + } + WMEMSET(authKeysPath, 0, sizeof(authKeysPath)); rc = ResolveAuthKeysPath(path, authKeysFile, user, authKeysPath); if (rc != WS_SUCCESS) { @@ -2921,9 +2931,12 @@ static int RequestAuthentication(WS_UserAuthData* authData, if (ret == WOLFSSH_USERAUTH_SUCCESS && authData->type == WOLFSSH_USERAUTH_PUBLICKEY) { /* if this is a certificate and no specific authorized keys file has - * been set then rely on CA to have verified the cert */ + * been set then rely on CA to have verified the cert. With + * AuthorizedKeysFile none the certificate goes to the key check + * below and is refused, so none never widens access. */ if (authData->sf.publicKey.isCert && - !wolfSSHD_ConfigGetAuthKeysFileSet(usrConf)) { + !wolfSSHD_ConfigGetAuthKeysFileSet(usrConf) && + !wolfSSHD_ConfigGetAuthKeysFileNone(usrConf)) { /* The cert chain was already verified by wolfSSH against the CA * store loaded once from the global TrustedUserCAKeys. A per-user * Match override of TrustedUserCAKeys is not part of that store and diff --git a/apps/wolfsshd/configuration.c b/apps/wolfsshd/configuration.c index 074c5fda0..1ec8300cb 100644 --- a/apps/wolfsshd/configuration.c +++ b/apps/wolfsshd/configuration.c @@ -118,6 +118,7 @@ struct WOLFSSHD_CONFIG { byte permitRootLogin:2; byte permitEmptyPasswords:1; byte authKeysFileSet:1; /* if not set then no explicit authorized keys */ + byte authKeysFileNone:1; /* AuthorizedKeysFile none: no file is read */ byte strictModes:1; /* enforce file permission/ownership checks */ byte useSystemCA:1; byte useUserCAStore:1; @@ -408,6 +409,7 @@ static WOLFSSHD_CONFIG* wolfSSHD_ConfigCopy(WOLFSSHD_CONFIG* conf) newConf->permitRootLogin = conf->permitRootLogin; newConf->permitEmptyPasswords = conf->permitEmptyPasswords; newConf->authKeysFileSet = conf->authKeysFileSet; + newConf->authKeysFileNone = conf->authKeysFileNone; newConf->strictModes = conf->strictModes; newConf->head = conf->head; newConf->useSystemCA = conf->useSystemCA; @@ -1816,6 +1818,7 @@ static int ConfigComposeFrom(WOLFSSHD_CONFIG* dst, const WOLFSSHD_CONFIG* src) if (take & OPT_BIT(OPT_AUTH_KEYS_FILE)) { ret = ComposeString(&dst->authKeysFile, src->authKeysFile, dst->heap); dst->authKeysFileSet = src->authKeysFileSet; + dst->authKeysFileNone = src->authKeysFileNone; } if (ret == WS_SUCCESS && (take & OPT_BIT(OPT_BANNER))) { ret = ComposeString(&dst->banner, src->banner, dst->heap); @@ -2007,6 +2010,26 @@ int wolfSSHD_ConfigGetAuthKeysFileSet(const WOLFSSHD_CONFIG* conf) return ret; } +/* returns 1 if value is the keyword "none", in any case, as OpenSSH accepts + * for AuthorizedKeysFile and ChrootDirectory, and 0 if not */ +int wolfSSHD_ConfigIsNoneKeyword(const char* value) +{ + return (value != NULL && WSTRLEN(value) == 4 && + WSTRNCASECMP(value, "none", 4) == 0) ? 1 : 0; +} + +/* returns 1 if AuthorizedKeysFile is "none" and 0 if not */ +int wolfSSHD_ConfigGetAuthKeysFileNone(const WOLFSSHD_CONFIG* conf) +{ + int ret = 0; + + if (conf != NULL) { + ret = conf->authKeysFileNone; + } + + return ret; +} + /* returns 1 if StrictModes is enabled and 0 if not. Defaults to enabled (fail * safe) when conf is NULL. */ int wolfSSHD_ConfigGetStrictModes(const WOLFSSHD_CONFIG* conf) @@ -2024,11 +2047,20 @@ int wolfSSHD_ConfigSetAuthKeysFile(WOLFSSHD_CONFIG* conf, const char* file) { int ret = WS_SUCCESS; char* newFile = NULL; + byte none = 0; if (conf == NULL) { ret = WS_BAD_ARGUMENT; } + /* "none" disables the file, as in OpenSSH, rather than naming ~/none. + * The value is kept as "none", not NULL, because NULL selects the + * default ~/.ssh/authorized_keys. */ + if (ret == WS_SUCCESS && wolfSSHD_ConfigIsNoneKeyword(file)) { + none = 1; + file = "none"; + } + /* allocate the replacement string first so a failure leaves the existing * authKeysFile and authKeysFileSet untouched rather than half updated */ if (ret == WS_SUCCESS && file != NULL) { @@ -2045,7 +2077,8 @@ int wolfSSHD_ConfigSetAuthKeysFile(WOLFSSHD_CONFIG* conf, const char* file) * set when a file is explicitly configured so certificate public-key * logins are still checked against it, cleared when removed */ conf->authKeysFile = newFile; - conf->authKeysFileSet = (file != NULL) ? 1 : 0; + conf->authKeysFileSet = (file != NULL && !none) ? 1 : 0; + conf->authKeysFileNone = none; } return ret; diff --git a/apps/wolfsshd/configuration.h b/apps/wolfsshd/configuration.h index 4ac1b5a59..b973d9fef 100644 --- a/apps/wolfsshd/configuration.h +++ b/apps/wolfsshd/configuration.h @@ -94,6 +94,8 @@ int wolfSSHD_ConfigSetUserCAKeysFile(WOLFSSHD_CONFIG* conf, const char* file); word16 wolfSSHD_ConfigGetPort(const WOLFSSHD_CONFIG* conf); char* wolfSSHD_ConfigGetAuthKeysFile(const WOLFSSHD_CONFIG* conf); int wolfSSHD_ConfigGetAuthKeysFileSet(const WOLFSSHD_CONFIG* conf); +int wolfSSHD_ConfigGetAuthKeysFileNone(const WOLFSSHD_CONFIG* conf); +int wolfSSHD_ConfigIsNoneKeyword(const char* value); int wolfSSHD_ConfigSetAuthKeysFile(WOLFSSHD_CONFIG* conf, const char* file); int wolfSSHD_ConfigGetStrictModes(const WOLFSSHD_CONFIG* conf); byte wolfSSHD_ConfigGetPermitEmptyPw(const WOLFSSHD_CONFIG* conf); diff --git a/apps/wolfsshd/test/run_all_sshd_tests.sh b/apps/wolfsshd/test/run_all_sshd_tests.sh index efabb94ec..249eb64c5 100755 --- a/apps/wolfsshd/test/run_all_sshd_tests.sh +++ b/apps/wolfsshd/test/run_all_sshd_tests.sh @@ -35,6 +35,7 @@ extra_test_cases=( "strictmodes_authkeys_negative" "sshd_forcedcmd_test.sh" "sshd_match_overlap_test.sh" + "sshd_authkeys_none_test.sh" "sshd_window_full_test.sh" "sshd_stderr_eof_test.sh" "sshd_stderr_drain_test.sh" @@ -760,6 +761,7 @@ fi local_sshd_tests=( "sshd_forcedcmd_test.sh" "sshd_match_overlap_test.sh" + "sshd_authkeys_none_test.sh" "sshd_window_full_test.sh" "sshd_stderr_eof_test.sh" "sshd_stderr_drain_test.sh" diff --git a/apps/wolfsshd/test/sshd_authkeys_none_test.sh b/apps/wolfsshd/test/sshd_authkeys_none_test.sh new file mode 100755 index 000000000..953980df9 --- /dev/null +++ b/apps/wolfsshd/test/sshd_authkeys_none_test.sh @@ -0,0 +1,275 @@ +#!/bin/bash + +# sshd local test +# +# "AuthorizedKeysFile none" turns off the authorized keys file, as it does in +# OpenSSH. It is not a file name: a relative "none" would resolve to ~/none, +# a file the user controls, and any key placed there would log in. +# +# This test puts an authorized key in the login user's ~/none and checks a +# public key login is refused, both with "none" set globally and with it set +# in a Match block over a global file that does hold the key. Two controls +# come first: the global file alone shows the key and client work, and +# "./none", which names the same file without the keyword, shows the daemon +# can read ~/none, so a refusal below is the keyword's doing. +# +# On an X.509 build with FPKI it then checks "none" does not trip the +# wolfSSH_TrustedSystemCAKeys startup gate, and that "none" refuses a CA-issued +# client certificate, which with no AuthorizedKeysFile at all logs in on the CA +# and its UPN binding alone. Without FPKI the daemon cannot bind a certificate +# to a user, so it refuses a CA-only login with or without "none". +# +# Expects ./authorized_keys_test to exist, as the other tests here do. Create it +# with ./create_authorized_test_file.sh when running this script on its own. +# The X.509 cases expect keys/-cert.der from create_sshd_config.sh. + +if [ -z "$1" ] || [ -z "$2" ]; then + echo "expecting host and port as arguments" + echo "$0 127.0.0.1 22222" + exit 1 +fi + +TESTDIR=`pwd` +USER=`whoami` +. ./wolfssh_options.sh +TEST_PORT="$2" +TEST_HOST="$1" +source ./start_sshd.sh + +# The daemon resolves a relative AuthorizedKeysFile under the passwd entry's +# home directory, which need not be $HOME under sudo. +HOME_DIR=`getent passwd "$USER" | cut -d: -f6` +if [ -z "$HOME_DIR" ] || [ ! -d "$HOME_DIR" ]; then + echo "SKIP: no home directory for $USER" + exit 77 +fi +NONE_FILE="$HOME_DIR/none" +if [ -e "$NONE_FILE" ]; then + echo "SKIP: $NONE_FILE already exists" + exit 77 +fi +# StrictModes refuses a key file under a group or world writable home, which +# would fail the ./none control for a reason that is not wolfsshd's +if [ -n "`find "$HOME_DIR" -maxdepth 0 -perm /022`" ]; then + echo "SKIP: $HOME_DIR is group or world writable" + exit 77 +fi + +cleanup() { + stop_wolfsshd + rm -f "$NONE_FILE" + rm -f sshd_config_test_authkeys_none_* + return 0 +} +trap cleanup EXIT + +if [ ! -f authorized_keys_test ]; then + ./create_authorized_test_file.sh || exit 1 +fi +# 0644 for the StrictModes check, as create_authorized_test_file.sh does +if ! cp authorized_keys_test "$NONE_FILE"; then + echo "SKIP: cannot write $NONE_FILE" + exit 77 +fi +chmod 0644 "$NONE_FILE" + +TEST_CLIENT="$TESTDIR/../../../examples/client/client" +PRIVATE_KEY="$TESTDIR/../../../keys/hansel-key-ecc.der" +PUBLIC_KEY="$TESTDIR/../../../keys/hansel-key-ecc.pub" + +# writes the global section of a config to $1, with $2 as AuthorizedKeysFile +write_config() { + cat < "$1" +Port $TEST_PORT +Protocol 2 +LoginGraceTime 600 +PermitRootLogin yes +PasswordAuthentication no +PermitEmptyPasswords no +UsePrivilegeSeparation no +UseDNS no +HostKey $TESTDIR/../../../keys/server-key.pem +AuthorizedKeysFile $2 +EOF +} + +# starts the daemon with config $1 and runs a key login. Returns 0 when the +# remote command ran. The marker is split so the command sent and the output +# returned differ, see sshd_match_overlap_test.sh. +try_login() { + start_wolfsshd "$1" + if [ -z "$PID" ]; then + echo "FAIL: daemon did not start with $1" + exit 1 + fi + RESULT=$( timeout 10 $TEST_CLIENT -c 'echo authkeys""_none_ran' \ + -u $USER -i $PRIVATE_KEY -j $PUBLIC_KEY -h $TEST_HOST -p $TEST_PORT \ + 2>&1 ) + echo "$RESULT" + stop_wolfsshd + echo "$RESULT" | grep -q authkeys_none_ran +} + +CFG=sshd_config_test_authkeys_none_control +write_config $CFG "$TESTDIR/authorized_keys_test" +if ! try_login $CFG; then + echo "FAIL: control login with the global file failed; setup issue" + exit 1 +fi + +CFG=sshd_config_test_authkeys_none_dotnone +write_config $CFG "./none" +if ! try_login $CFG; then + echo "FAIL: control login through ./none failed; $NONE_FILE is not readable" + exit 1 +fi + +CFG=sshd_config_test_authkeys_none_global +write_config $CFG "none" +if try_login $CFG; then + echo "FAIL: AuthorizedKeysFile none read keys from $NONE_FILE" + exit 1 +fi + +CFG=sshd_config_test_authkeys_none_match +write_config $CFG "$TESTDIR/authorized_keys_test" +cat <> $CFG + +Match User $USER + AuthorizedKeysFile none +EOF +if try_login $CFG; then + echo "FAIL: Match AuthorizedKeysFile none did not disable the key file" + exit 1 +fi + +# starts the daemon with config $1 and returns 0 when it came up +try_start() { + start_wolfsshd "$1" + if [ -z "$PID" ]; then + return 1 + fi + stop_wolfsshd + return 0 +} + +# wolfSSH_TrustedSystemCAKeys on an FPKI build refuses to start unless every +# config node binds certificates with AuthorizedUPNDomains or a per-user +# AuthorizedKeysFile. A node with "none" takes no certificate login at all, +# so it must not stop the daemon. The shared-file case shows the gate is live. +if wolfssh_has CERTS && wolfssh_has FPKI; then + CFG=sshd_config_test_authkeys_none_sysca_control + write_config $CFG ".ssh/authorized_keys" + echo "wolfSSH_TrustedSystemCAKeys yes" >> $CFG + if ! try_start $CFG; then + echo "system CA cases skipped: daemon cannot load the system CAs" + else + CFG=sshd_config_test_authkeys_none_sysca_shared + write_config $CFG "$TESTDIR/authorized_keys_test" + echo "wolfSSH_TrustedSystemCAKeys yes" >> $CFG + if try_start $CFG; then + echo "FAIL: system CA gate accepted a shared AuthorizedKeysFile" + exit 1 + fi + + CFG=sshd_config_test_authkeys_none_sysca_global + write_config $CFG "none" + echo "wolfSSH_TrustedSystemCAKeys yes" >> $CFG + if ! try_start $CFG; then + echo "FAIL: system CA gate refused AuthorizedKeysFile none" + exit 1 + fi + + CFG=sshd_config_test_authkeys_none_sysca_match + write_config $CFG ".ssh/authorized_keys" + cat <> $CFG +wolfSSH_TrustedSystemCAKeys yes + +Match User $USER + AuthorizedKeysFile none +EOF + if ! try_start $CFG; then + echo "FAIL: system CA gate refused Match AuthorizedKeysFile none" + exit 1 + fi + fi +fi + +# X.509: the daemon setup of sshd_x509_test.sh, which needs a build without +# FPKI profile enforcement since the test certificates meet none. A CA-only +# login needs FPKI for the UPN binding; without it the daemon refuses one. +CERT_KEY="$TESTDIR/../../../keys/$USER-key.der" +CERT="$TESTDIR/../../../keys/$USER-cert.der" +CA_CERT="$TESTDIR/../../../keys/ca-cert-ecc.der" +if ! wolfssh_has CERTS || ! wolfssh_has FPKI || wolfssh_has FPKI_PROFILE; then + echo "X.509 cases skipped: no certificates, no FPKI, or FPKI profiles on" + exit 0 +fi +if [ ! -f "$CERT" ] || [ ! -f "$CERT_KEY" ]; then + echo "X.509 cases skipped: no $CERT, run create_sshd_config.sh" + exit 0 +fi + +# writes an X.509 config to $1 with no AuthorizedKeysFile, so a certificate +# logs in on the CA and UPN alone unless "none" stops it; the realm matches the +# test certificates, as create_sshd_config.sh writes it +write_x509_config() { + cat < "$1" +Port $TEST_PORT +Protocol 2 +LoginGraceTime 600 +PermitRootLogin yes +PasswordAuthentication no +PermitEmptyPasswords no +UsePrivilegeSeparation no +UseDNS no +TrustedUserCAKeys $TESTDIR/../../../keys/ca-cert-ecc.pem +HostKey $TESTDIR/../../../keys/server-key.pem +HostCertificate $TESTDIR/../../../keys/server-cert.pem +AuthorizedUPNDomains example +EOF +} + +# as try_login, with the client offering an X.509 certificate +try_cert_login() { + start_wolfsshd "$1" + if [ -z "$PID" ]; then + echo "FAIL: daemon did not start with $1" + exit 1 + fi + RESULT=$( timeout 10 $TEST_CLIENT -X -c 'echo authkeys""_none_ran' \ + -u $USER -i $CERT_KEY -J $CERT -A $CA_CERT \ + -h $TEST_HOST -p $TEST_PORT 2>&1 ) + echo "$RESULT" + stop_wolfsshd + echo "$RESULT" | grep -q authkeys_none_ran +} + +CFG=sshd_config_test_authkeys_none_x509_control +write_x509_config $CFG +if ! try_cert_login $CFG; then + echo "FAIL: control certificate login on the CA alone failed; setup issue" + exit 1 +fi + +CFG=sshd_config_test_authkeys_none_x509_global +write_x509_config $CFG +echo "AuthorizedKeysFile none" >> $CFG +if try_cert_login $CFG; then + echo "FAIL: AuthorizedKeysFile none let a certificate in on the CA alone" + exit 1 +fi + +CFG=sshd_config_test_authkeys_none_x509_match +write_x509_config $CFG +cat <> $CFG + +Match User $USER + AuthorizedKeysFile none +EOF +if try_cert_login $CFG; then + echo "FAIL: Match AuthorizedKeysFile none let a certificate in on the CA" + exit 1 +fi + +exit 0 diff --git a/apps/wolfsshd/test/test_configuration.c b/apps/wolfsshd/test/test_configuration.c index d8dc67f89..925583d76 100644 --- a/apps/wolfsshd/test/test_configuration.c +++ b/apps/wolfsshd/test/test_configuration.c @@ -1913,10 +1913,142 @@ static int test_ConfigSetAuthKeysFile(void) ret = WS_FATAL_ERROR; } + /* "none" in any case disables the file rather than naming ~/none */ + if (ret == WS_SUCCESS) + ret = wolfSSHD_ConfigSetAuthKeysFile(conf, "NoNe"); + if (ret == WS_SUCCESS) { + if (wolfSSHD_ConfigGetAuthKeysFileNone(conf) != 1 || + wolfSSHD_ConfigGetAuthKeysFile(conf) == NULL || + XSTRCMP(wolfSSHD_ConfigGetAuthKeysFile(conf), "none") != 0 || + wolfSSHD_ConfigGetAuthKeysFileSet(conf) != 0) + ret = WS_FATAL_ERROR; + } + + /* a later file replaces the disabled state */ + if (ret == WS_SUCCESS) + ret = wolfSSHD_ConfigSetAuthKeysFile(conf, "nonexistent"); + if (ret == WS_SUCCESS) { + if (wolfSSHD_ConfigGetAuthKeysFileNone(conf) != 0 || + wolfSSHD_ConfigGetAuthKeysFileSet(conf) == 0) + ret = WS_FATAL_ERROR; + } + wolfSSHD_ConfigFree(conf); return ret; } +/* Checks the AuthorizedKeysFile none state for the user a Match block + * resolves to. RequestAuthentication reads authKeysFileNone on that config to + * keep an X.509 certificate off the CA-only path, so the flag must survive + * wolfSSHD_ConfigCopy (none set globally, inherited by a Match node) and + * ConfigComposeFrom (none set or cleared inside a Match block). Returns 0 when + * user resolves to the expected none/set flags and file string. */ +static int CheckAuthKeysNone(WOLFSSHD_CONFIG* head, const char* user, + int none, int set, const char* file) +{ + int ret = WS_SUCCESS; + WOLFSSHD_CONFIG* usrConf; + + usrConf = wolfSSHD_GetUserConf(head, user, NULL, 0, NULL, NULL, + NULL, NULL, NULL); + if (usrConf == NULL) { + ret = WS_FATAL_ERROR; + } + else if (wolfSSHD_ConfigGetAuthKeysFileNone(usrConf) != none || + wolfSSHD_ConfigGetAuthKeysFileSet(usrConf) != set || + wolfSSHD_ConfigGetAuthKeysFile(usrConf) == NULL || + XSTRCMP(wolfSSHD_ConfigGetAuthKeysFile(usrConf), file) != 0) { + Log(" %s: none=%d set=%d file=%s, expected %d %d %s\n", user, + wolfSSHD_ConfigGetAuthKeysFileNone(usrConf), + wolfSSHD_ConfigGetAuthKeysFileSet(usrConf), + wolfSSHD_ConfigGetAuthKeysFile(usrConf) == NULL ? "(null)" : + wolfSSHD_ConfigGetAuthKeysFile(usrConf), none, set, file); + ret = WS_FATAL_ERROR; + } + + wolfSSHD_ConfigFree(usrConf); + return ret; +} + +static int test_GetUserConfAuthKeysNone(void) +{ + int ret = WS_SUCCESS; + int i; + WOLFSSHD_CONFIG* head = NULL; + WOLFSSHD_CONFIG* conf; + static const char* globalNone[] = { + "AuthorizedKeysFile NONE", + "Match User testuser", + "PasswordAuthentication no", + NULL + }; + static const char* matchNone[] = { + "AuthorizedKeysFile /etc/keys", + "Match User testuser", + "AuthorizedKeysFile none", + NULL + }; + static const char* matchFile[] = { + "AuthorizedKeysFile none", + "Match User testuser", + "AuthorizedKeysFile .ssh/match_keys", + NULL + }; + + /* none set globally: the Match node inherits it through the copy */ + head = wolfSSHD_ConfigNew(NULL); + if (head == NULL) + ret = WS_MEMORY_E; + conf = head; + for (i = 0; ret == WS_SUCCESS && globalNone[i] != NULL; i++) { + ret = ParseConfigLine(&conf, globalNone[i], + (int)WSTRLEN(globalNone[i]), 0); + } + if (ret == WS_SUCCESS) + ret = CheckAuthKeysNone(head, "testuser", 1, 0, "none"); + if (ret == WS_SUCCESS) + ret = CheckAuthKeysNone(head, "otheruser", 1, 0, "none"); + wolfSSHD_ConfigFree(head); + head = NULL; + + /* none set in a Match block over a global file */ + if (ret == WS_SUCCESS) { + head = wolfSSHD_ConfigNew(NULL); + if (head == NULL) + ret = WS_MEMORY_E; + conf = head; + } + for (i = 0; ret == WS_SUCCESS && matchNone[i] != NULL; i++) { + ret = ParseConfigLine(&conf, matchNone[i], + (int)WSTRLEN(matchNone[i]), 0); + } + if (ret == WS_SUCCESS) + ret = CheckAuthKeysNone(head, "testuser", 1, 0, "none"); + if (ret == WS_SUCCESS) + ret = CheckAuthKeysNone(head, "otheruser", 0, 1, "/etc/keys"); + wolfSSHD_ConfigFree(head); + head = NULL; + + /* a file in a Match block clears an inherited global none */ + if (ret == WS_SUCCESS) { + head = wolfSSHD_ConfigNew(NULL); + if (head == NULL) + ret = WS_MEMORY_E; + conf = head; + } + for (i = 0; ret == WS_SUCCESS && matchFile[i] != NULL; i++) { + ret = ParseConfigLine(&conf, matchFile[i], + (int)WSTRLEN(matchFile[i]), 0); + } + if (ret == WS_SUCCESS) + ret = CheckAuthKeysNone(head, "testuser", 0, 1, ".ssh/match_keys"); + if (ret == WS_SUCCESS) + ret = CheckAuthKeysNone(head, "otheruser", 1, 0, "none"); + wolfSSHD_ConfigFree(head); + + return ret; +} + /* Verifies ConfigFree releases all string fields - most useful under ASan. */ static int test_ConfigFree(void) { @@ -3698,6 +3830,7 @@ static int test_SearchForPubKey(void) char base[] = "/tmp/wolfsshd_pkXXXXXX"; char keysPath[64] = ""; char missPath[64] = ""; + char nonePath[64] = ""; char line[256]; WS_UserAuthData_PublicKey pubKeyCtx; WUID_T uid = getuid(); @@ -3719,6 +3852,7 @@ static int test_SearchForPubKey(void) if (ret == WS_SUCCESS) { snprintf(keysPath, sizeof(keysPath), "%s/authorized_keys", base); snprintf(missPath, sizeof(missPath), "%s/absent_keys", base); + snprintf(nonePath, sizeof(nonePath), "%s/none", base); ret = BuildAuthKeysLine(blobA, blobSz, line, sizeof(line)); } @@ -3852,6 +3986,50 @@ static int test_SearchForPubKey(void) } } + /* AuthorizedKeysFile none searches no file. A key in /none, where a + * relative "none" would resolve, must not match; "./none" names the same + * file and does match, so the file itself is readable. */ + if (ret == WS_SUCCESS) { + f = fopen(nonePath, "w"); + if (f == NULL) { + Log(" fopen of none failed.\n"); + ret = WS_FATAL_ERROR; + } + else { + fputs(line, f); + fputs("\n", f); + fclose(f); + } + } + if (ret == WS_SUCCESS && chmod(nonePath, S_IRUSR | S_IWUSR) != 0) { + Log(" chmod of none failed.\n"); + ret = WS_FATAL_ERROR; + } + if (ret == WS_SUCCESS) { + Log(" Testing scenario: ./none is read as a file."); + pubKeyCtx.publicKey = blobA; + rc = SearchForPubKey(base, "./none", "testuser", &pubKeyCtx, uid, 0); + if (rc == WSSHD_AUTH_SUCCESS) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED (rc=%d).\n", rc); + ret = WS_FATAL_ERROR; + } + } + if (ret == WS_SUCCESS) { + Log(" Testing scenario: none searches no file."); + pubKeyCtx.publicKey = blobA; + rc = SearchForPubKey(base, "none", "testuser", &pubKeyCtx, uid, 0); + if (rc == WSSHD_AUTH_FAILURE) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED (rc=%d).\n", rc); + ret = WS_FATAL_ERROR; + } + } + /* A missing authorized_keys file is an error, not a silent accept. */ if (ret == WS_SUCCESS) { Log(" Testing scenario: missing keys file returns an error."); @@ -3869,6 +4047,9 @@ static int test_SearchForPubKey(void) if (keysPath[0] != '\0') { unlink(keysPath); } + if (nonePath[0] != '\0') { + unlink(nonePath); + } rmdir(base); return ret; @@ -7317,6 +7498,7 @@ const TEST_CASE testCases[] = { TEST_DECL(test_GetUserAuthTypes), TEST_DECL(test_DefaultUserAuthTypesNullArgs), TEST_DECL(test_ConfigSetAuthKeysFile), + TEST_DECL(test_GetUserConfAuthKeysNone), TEST_DECL(test_ResolveAuthKeysPath), TEST_DECL(test_ConfigFree), #ifndef _WIN32 diff --git a/apps/wolfsshd/wolfsshd.c b/apps/wolfsshd/wolfsshd.c index 61977d4df..fddb0a418 100644 --- a/apps/wolfsshd/wolfsshd.c +++ b/apps/wolfsshd/wolfsshd.c @@ -1434,16 +1434,20 @@ static int SetupCTX(WOLFSSHD_CONFIG* conf, WOLFSSH_CTX** ctx, * AuthorizedKeysFile so a subject CN match alone is never sufficient to * log in; the exact certificate match in that file is the real binding. * The curated wolfSSH_TrustedUserCAStore path is not held to this because - * its store is admin-created and the public store names are refused. */ + * its store is admin-created and the public store names are refused. A + * node with AuthorizedKeysFile none passes: RequestAuthentication sends + * its certificates to the key check, which refuses them, so no + * certificate logs in there at all. */ if (ret == WS_SUCCESS && wolfSSHD_ConfigGetSystemCA(conf)) { const WOLFSSHD_CONFIG* cur; word32 node = 0; cur = conf; while (cur != NULL) { - if (!wolfSSHD_ConfigGetAuthKeysFileSet(cur) || - !wolfSSHD_AuthKeysPatternIsPerUser( - wolfSSHD_ConfigGetAuthKeysFile(cur))) { + if (!wolfSSHD_ConfigGetAuthKeysFileNone(cur) && + (!wolfSSHD_ConfigGetAuthKeysFileSet(cur) || + !wolfSSHD_AuthKeysPatternIsPerUser( + wolfSSHD_ConfigGetAuthKeysFile(cur)))) { wolfSSH_Log(WS_LOG_ERROR, "[SSHD] wolfSSH_TrustedSystemCAKeys with CN-only cert " "binding requires a per-user AuthorizedKeysFile"); @@ -1483,7 +1487,8 @@ static int SetupCTX(WOLFSSHD_CONFIG* conf, WOLFSSH_CTX** ctx, * which trusted CA issued it: any CA in the OS store willing to emit a * UPN in an allowed realm still satisfies it. The per-user * AuthorizedKeysFile arm is the only one that adds an exact-certificate - * second factor. */ + * second factor. A node with AuthorizedKeysFile none passes, as in the + * gate above: no certificate logs in there. */ if (ret == WS_SUCCESS && wolfSSHD_ConfigGetSystemCA(conf)) { const WOLFSSHD_CONFIG* cur; const char* domains; @@ -1492,6 +1497,7 @@ static int SetupCTX(WOLFSSHD_CONFIG* conf, WOLFSSH_CTX** ctx, while (cur != NULL) { domains = wolfSSHD_ConfigGetAuthorizedUPNDomains(cur); if ((domains == NULL || *domains == '\0') && + !wolfSSHD_ConfigGetAuthKeysFileNone(cur) && (!wolfSSHD_ConfigGetAuthKeysFileSet(cur) || !wolfSSHD_AuthKeysPatternIsPerUser( wolfSSHD_ConfigGetAuthKeysFile(cur)))) { From 95231741390657cf01dddae74ca1f7b08040ef2d Mon Sep 17 00:00:00 2001 From: John Safranek Date: Tue, 27 Oct 2026 11:31:20 -0700 Subject: [PATCH 2/3] wolfsshd: honor ChrootDirectory none "ChrootDirectory none" now means no chroot, as in OpenSSH, instead of a relative chdir() target. The keyword still counts as set, so a Match block using it exempts a user from a global ChrootDirectory. - match "none" case-insensitively, as OpenSSH does - add sshd_chroot_none_test.sh: global and Match cases against an empty global chroot Issue: F-16811 --- apps/wolfsshd/configuration.c | 6 +- apps/wolfsshd/test/run_all_sshd_tests.sh | 2 + apps/wolfsshd/test/sshd_chroot_none_test.sh | 114 ++++++++++++++++++++ apps/wolfsshd/test/test_configuration.c | 44 ++++++++ 4 files changed, 165 insertions(+), 1 deletion(-) create mode 100755 apps/wolfsshd/test/sshd_chroot_none_test.sh diff --git a/apps/wolfsshd/configuration.c b/apps/wolfsshd/configuration.c index 1ec8300cb..107645305 100644 --- a/apps/wolfsshd/configuration.c +++ b/apps/wolfsshd/configuration.c @@ -1124,8 +1124,12 @@ static int HandleChrootDir(WOLFSSHD_CONFIG* conf, const char* value) FreeString(&conf->chrootDir, conf->heap); conf->chrootDir = NULL; } - ret = CreateString(&conf->chrootDir, value, + /* "none" means no chroot, as in OpenSSH. The keyword is still + * recorded as set, so a Match block clears an inherited path. */ + if (!wolfSSHD_ConfigIsNoneKeyword(value)) { + ret = CreateString(&conf->chrootDir, value, (int)WSTRLEN(value), conf->heap); + } } return ret; diff --git a/apps/wolfsshd/test/run_all_sshd_tests.sh b/apps/wolfsshd/test/run_all_sshd_tests.sh index 249eb64c5..172348e3c 100755 --- a/apps/wolfsshd/test/run_all_sshd_tests.sh +++ b/apps/wolfsshd/test/run_all_sshd_tests.sh @@ -48,6 +48,7 @@ extra_test_cases=( "sshd_login_grace_test.sh" "sshd_privdrop_fail_test.sh" "sshd_chroot_fail_test.sh" + "sshd_chroot_none_test.sh" "sshd_fork_fail_test.sh" "sshd_exit_status_test.sh" "sshd_x509_test.sh" @@ -774,6 +775,7 @@ local_sshd_tests=( "sshd_login_grace_test.sh" "sshd_privdrop_fail_test.sh" "sshd_chroot_fail_test.sh" + "sshd_chroot_none_test.sh" "sshd_fork_fail_test.sh" "sshd_exit_status_test.sh" ) diff --git a/apps/wolfsshd/test/sshd_chroot_none_test.sh b/apps/wolfsshd/test/sshd_chroot_none_test.sh new file mode 100755 index 000000000..4a212e1b0 --- /dev/null +++ b/apps/wolfsshd/test/sshd_chroot_none_test.sh @@ -0,0 +1,114 @@ +#!/bin/bash + +# sshd local test +# +# "ChrootDirectory none" means no chroot, as it does in OpenSSH. The usual use +# is a Match block that exempts a user from a global ChrootDirectory. Taken as +# a path, "none" is a chdir() target that does not exist and the session dies. +# +# The global chroot here is a directory with no shell in it, so a session that +# is chrooted cannot run the command either. The command running shows the +# Match block's "none" took effect. A global "none" is checked the same way. +# +# Expects ./authorized_keys_test to exist, as the other tests here do. Create it +# with ./create_authorized_test_file.sh when running this script on its own. + +if [ -z "$1" ] || [ -z "$2" ]; then + echo "expecting host and port as arguments" + echo "$0 127.0.0.1 22222" + exit 1 +fi + +TESTDIR=`pwd` +USER=`whoami` +TEST_PORT="$2" +TEST_HOST="$1" +source ./start_sshd.sh + +# chroot(2) needs root, and start_sshd.sh gets there with sudo +if [ "`id -u`" -ne 0 ] && ! sudo -n true 2>/dev/null; then + echo "SKIP: chroot test needs root or non-interactive sudo" + exit 77 +fi + +CHROOT_DIR="$TESTDIR/chroot_none_target" + +cleanup() { + stop_wolfsshd + rm -f sshd_config_test_chroot_none_* + rmdir "$CHROOT_DIR" 2>/dev/null + return 0 +} +trap cleanup EXIT + +if [ ! -f authorized_keys_test ]; then + ./create_authorized_test_file.sh || exit 1 +fi +mkdir -p "$CHROOT_DIR" + +TEST_CLIENT="$TESTDIR/../../../examples/client/client" +PRIVATE_KEY="$TESTDIR/../../../keys/hansel-key-ecc.der" +PUBLIC_KEY="$TESTDIR/../../../keys/hansel-key-ecc.pub" + +# writes the global section of a config to $1, with $2 as ChrootDirectory +write_config() { + cat < "$1" +Port $TEST_PORT +Protocol 2 +LoginGraceTime 600 +PermitRootLogin yes +PasswordAuthentication no +PermitEmptyPasswords no +UsePrivilegeSeparation no +UseDNS no +HostKey $TESTDIR/../../../keys/server-key.pem +AuthorizedKeysFile $TESTDIR/authorized_keys_test +ChrootDirectory $2 +EOF +} + +# starts the daemon with config $1 and runs a key login. Returns 0 when the +# remote command ran. The marker is split so the command sent and the output +# returned differ, see sshd_match_overlap_test.sh. +try_login() { + start_wolfsshd "$1" + if [ -z "$PID" ]; then + echo "FAIL: daemon did not start with $1" + exit 1 + fi + RESULT=$( timeout 10 $TEST_CLIENT -c 'echo chroot""_none_ran' \ + -u $USER -i $PRIVATE_KEY -j $PUBLIC_KEY -h $TEST_HOST -p $TEST_PORT \ + 2>&1 ) + echo "$RESULT" + stop_wolfsshd + echo "$RESULT" | grep -q chroot_none_ran +} + +# control: the global chroot alone leaves no shell to run the command +CFG=sshd_config_test_chroot_none_control +write_config $CFG "$CHROOT_DIR" +if try_login $CFG; then + echo "FAIL: command ran inside an empty chroot; setup issue" + exit 1 +fi + +CFG=sshd_config_test_chroot_none_global +write_config $CFG "none" +if ! try_login $CFG; then + echo "FAIL: ChrootDirectory none still tried to chroot" + exit 1 +fi + +CFG=sshd_config_test_chroot_none_match +write_config $CFG "$CHROOT_DIR" +cat <> $CFG + +Match User $USER + ChrootDirectory none +EOF +if ! try_login $CFG; then + echo "FAIL: Match ChrootDirectory none did not exempt the user" + exit 1 +fi + +exit 0 diff --git a/apps/wolfsshd/test/test_configuration.c b/apps/wolfsshd/test/test_configuration.c index 925583d76..67047ea9d 100644 --- a/apps/wolfsshd/test/test_configuration.c +++ b/apps/wolfsshd/test/test_configuration.c @@ -773,6 +773,49 @@ static int test_GetUserConfMatchOverride(void) return ret; } +/* "ChrootDirectory none" means no chroot. In a Match block it clears the + * global path for that user rather than becoming the path "none". */ +static int test_GetUserConfChrootNone(void) +{ + int ret = WS_SUCCESS; + WOLFSSHD_CONFIG* head; + WOLFSSHD_CONFIG* conf; + WOLFSSHD_CONFIG* match = NULL; + WOLFSSHD_CONFIG* other = NULL; + + head = wolfSSHD_ConfigNew(NULL); + if (head == NULL) + ret = WS_MEMORY_E; + conf = head; + +#define PCL(s) ParseConfigLine(&conf, s, (int)WSTRLEN(s), 0) + if (ret == WS_SUCCESS) ret = PCL("ChrootDirectory /var/chroot"); + if (ret == WS_SUCCESS) ret = PCL("Match User testuser"); + if (ret == WS_SUCCESS) ret = PCL("ChrootDirectory None"); +#undef PCL + + if (ret == WS_SUCCESS) { + match = wolfSSHD_GetUserConf(head, "testuser", NULL, 0, NULL, NULL, + NULL, NULL, NULL); + if (match == NULL || wolfSSHD_ConfigGetChroot(match) != NULL) + ret = WS_FATAL_ERROR; + } + + /* other users keep the global chroot */ + if (ret == WS_SUCCESS) { + other = wolfSSHD_GetUserConf(head, "otheruser", NULL, 0, NULL, NULL, + NULL, NULL, NULL); + if (other == NULL || wolfSSHD_ConfigGetChroot(other) == NULL || + XSTRCMP(wolfSSHD_ConfigGetChroot(other), "/var/chroot") != 0) + ret = WS_FATAL_ERROR; + } + + wolfSSHD_ConfigFree(other); + wolfSSHD_ConfigFree(match); + wolfSSHD_ConfigFree(head); + return ret; +} + /* Only the User and Group Match selectors are implemented. A Match block keyed * on any other selector (Address, Host, LocalAddress, LocalPort, RDomain) would * otherwise be accepted but never apply, a fail-open misconfiguration. Verify @@ -7479,6 +7522,7 @@ const TEST_CASE testCases[] = { TEST_DECL(test_AuthKeysPatternIsPerUser), TEST_DECL(test_ConfigCopy), TEST_DECL(test_GetUserConfMatchOverride), + TEST_DECL(test_GetUserConfChrootNone), TEST_DECL(test_MatchUnsupportedSelector), TEST_DECL(test_GetUserConfMatchSubstring), TEST_DECL(test_GetUserConfMatchSubstringGroup), From a5e63cfee5f386a821c6f1e25eee92409999e9cc Mon Sep 17 00:00:00 2001 From: John Safranek Date: Tue, 27 Oct 2026 11:34:26 -0700 Subject: [PATCH 3/3] wolfsshd: refuse Match Group on Windows Windows wolfsshd does not look up a user's groups, so a Match Group block could never apply and its restrictions were skipped. The config load now rejects a Group selector there, as it does other unsupported Match selectors. - WOLFSSHD_NO_MATCH_GROUP gates the rejection, set for _WIN32 - add sshd_match_group_win_test.ps1 to the Windows check workflow Issue: F-15559 --- .github/workflows/windows-check.yml | 6 + apps/wolfsshd/auth.c | 4 +- apps/wolfsshd/configuration.c | 13 +- apps/wolfsshd/configuration.h | 7 + .../test/sshd_match_group_win_test.ps1 | 145 ++++++++++++++++++ apps/wolfsshd/test/test_configuration.c | 35 ++++- 6 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 apps/wolfsshd/test/sshd_match_group_win_test.ps1 diff --git a/.github/workflows/windows-check.yml b/.github/workflows/windows-check.yml index 3289749a6..f2851b6a9 100644 --- a/.github/workflows/windows-check.yml +++ b/.github/workflows/windows-check.yml @@ -94,6 +94,12 @@ jobs: timeout-minutes: 2 run: .\sshd_dash_d_test.ps1 -SshdExe "$env:SSHD_PATH" + - name: Test wolfsshd refuses Match Group on Windows + working-directory: ${{ github.workspace }}\wolfssh\apps\wolfsshd\test + shell: pwsh + timeout-minutes: 2 + run: .\sshd_match_group_win_test.ps1 -SshdExe "$env:SSHD_PATH" + # Build and run the self-contained unit tests with the MSVC AddressSanitizer. # This is the only job that executes wolfSSH tests under a sanitizer on # Windows, where the USE_WINDOWS_API console code (e.g. wolfSSH_DoOSC) is diff --git a/apps/wolfsshd/auth.c b/apps/wolfsshd/auth.c index 647e3db6d..79891ae10 100644 --- a/apps/wolfsshd/auth.c +++ b/apps/wolfsshd/auth.c @@ -3718,8 +3718,8 @@ WOLFSSHD_CONFIG* wolfSSHD_AuthGetUserConf(const WOLFSSHD_AUTH* auth, if (auth != NULL) { if (usr != NULL) { #ifdef _WIN32 - /* LogonUserEx(): group lookup is not implemented on Windows, so - * Match Group directives do not apply here */ + /* group lookup is not implemented on Windows; config load + * refuses Match Group, so no block here depends on groups */ #else struct passwd* p_passwd; diff --git a/apps/wolfsshd/configuration.c b/apps/wolfsshd/configuration.c index 107645305..cc6e381c0 100644 --- a/apps/wolfsshd/configuration.c +++ b/apps/wolfsshd/configuration.c @@ -1226,7 +1226,8 @@ static int ParseMatchCriteria(WOLFSSHD_CONFIG* config, const char* value) * is not User or Group (Address, Host, LocalAddress, LocalPort, RDomain, ...), * and a Match with no selector at all (bare "Match", "Match all"), is rejected. * This also catches mixed forms such as "User alice Address 10.0.0.0/8" where - * the unsupported selector would otherwise be silently dropped. */ + * the unsupported selector would otherwise be silently dropped. Group is + * rejected too under WOLFSSHD_NO_MATCH_GROUP. */ static int CheckMatchSelectors(const char* value) { int ret = WS_SUCCESS; @@ -1252,7 +1253,11 @@ static int CheckMatchSelectors(const char* value) } else if (len == (int)(sizeof("Group") - 1) && WSTRNCMP(value + i, "Group", sizeof("Group") - 1) == 0) { + #ifdef WOLFSSHD_NO_MATCH_GROUP + ret = WS_FATAL_ERROR; + #else found = 1; + #endif } else { ret = WS_FATAL_ERROR; @@ -1294,9 +1299,15 @@ static int HandleMatch(WOLFSSHD_CONFIG** conf, const char* value, int valueSz) if (ret == WS_SUCCESS) { ret = CheckMatchSelectors(value); if (ret != WS_SUCCESS) { + #ifdef WOLFSSHD_NO_MATCH_GROUP + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] Unsupported Match selector, only User is handled on " + "this platform"); + #else wolfSSH_Log(WS_LOG_ERROR, "[SSHD] Unsupported Match selector, only User and Group are " "handled"); + #endif } } diff --git a/apps/wolfsshd/configuration.h b/apps/wolfsshd/configuration.h index b973d9fef..c0c4b937d 100644 --- a/apps/wolfsshd/configuration.h +++ b/apps/wolfsshd/configuration.h @@ -40,6 +40,13 @@ typedef struct WOLFSSHD_CONFIG WOLFSSHD_CONFIG; #define WOLFSSHD_WIN_STORE_CONFIG #endif +/* Windows wolfsshd does not look up a user's groups, so a Match Group block + * could never apply there. Such a config is refused rather than run without + * the block's restrictions. Defined here so the unit tests see it too. */ +#if defined(_WIN32) && !defined(WOLFSSHD_NO_MATCH_GROUP) + #define WOLFSSHD_NO_MATCH_GROUP +#endif + #include "auth.h" /* 0 so that privilege separation is default on after struct memset'd on init */ diff --git a/apps/wolfsshd/test/sshd_match_group_win_test.ps1 b/apps/wolfsshd/test/sshd_match_group_win_test.ps1 new file mode 100644 index 000000000..afda5b785 --- /dev/null +++ b/apps/wolfsshd/test/sshd_match_group_win_test.ps1 @@ -0,0 +1,145 @@ +#!/usr/bin/env pwsh +# +# Windows regression test: wolfsshd refuses a config with Match Group. +# +# Windows wolfsshd does not look up a user's groups, so a Match Group block +# could never apply and its restrictions would be skipped. The config load +# refuses it instead. This starts wolfsshd -D with a Match Group block and +# checks the daemon exits without listening and logs the selector refusal, so +# an exit for any other reason does not pass. A Match User control with the +# same settings must come up, both before and after the refused cases, so a +# daemon that cannot start at all, or a port left busy, does not pass. +# +# Usage: +# pwsh sshd_match_group_win_test.ps1 -SshdExe [-Port N] +# (SshdExe also accepts the SSHD_PATH environment variable.) + +param( + [string]$SshdExe = $env:SSHD_PATH, + [int]$Port = 22337 +) + +$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_match_group_win" +$authFile = Join-Path $scriptDir "authorized_keys_test_match_group_win" +$logFile = Join-Path $scriptDir "log_match_group_win.txt" + +# Logged by HandleMatch() in configuration.c under WOLFSSHD_NO_MATCH_GROUP. +# Errors reach the -E log without -d. +$refusal = "Unsupported Match selector, only User is handled on this platform" + +if (-not $SshdExe -or -not (Test-Path $SshdExe)) { + Write-Host "ERROR: wolfsshd.exe not found (pass -SshdExe or set SSHD_PATH)" + exit 1 +} + +"" | Out-File -FilePath $authFile -Encoding ASCII + +# Writes the config with $selector as the Match criteria. +function Write-Config([string]$selector) { + @" +Port $Port +Protocol 2 +PasswordAuthentication yes +UseDNS no +HostKey $keyPath +AuthorizedKeysFile $authFile + +Match $selector + ForceCommand internal-sftp +"@ | Out-File -FilePath $confFile -Encoding ASCII +} + +# Starts wolfsshd -D on the current config, logging to $logFile. Returns +# $true once it listens on $Port, $false when it exits first or never listens +# within 10 seconds. The exit code is not used: the Windows main() does not +# return StartSSHD's result. +function Test-Listens { + # -E appends, so start each run with an empty log + Remove-Item -Path $logFile -Force -ErrorAction SilentlyContinue + $sshd = Start-Process -FilePath $SshdExe ` + -ArgumentList "-D", "-f", "`"$confFile`"", "-p", "$Port", ` + "-E", "`"$logFile`"" ` + -NoNewWindow -PassThru + try { + for ($i = 0; $i -lt 20; $i++) { + if ($sshd.HasExited) { + return $false + } + try { + $probe = New-Object System.Net.Sockets.TcpClient + $probe.Connect("127.0.0.1", $Port) + $probe.Close() + return $true + } + catch { + Start-Sleep -Milliseconds 500 + } + } + return $false + } + finally { + if (-not $sshd.HasExited) { + Stop-Process -Id $sshd.Id -Force -ErrorAction SilentlyContinue + $sshd.WaitForExit(5000) | Out-Null + } + } +} + +# True when the daemon did not listen and its log names the selector refusal. +function Test-Refused { + if (Test-Listens) { + return $false + } + if (-not (Test-Path $logFile)) { + Write-Host "no log written to $logFile" + return $false + } + if (-not (Select-String -Path $logFile -SimpleMatch -Quiet ` + -Pattern $refusal)) { + Write-Host "log does not show the selector refusal:" + Get-Content -Path $logFile | Write-Host + return $false + } + return $true +} + +try { + Write-Config "User nosuchuser" + if (-not (Test-Listens)) { + throw "control: wolfsshd did not start with a Match User block" + } + + Write-Config "Group Administrators" + if (-not (Test-Refused)) { + throw "wolfsshd did not refuse a Match Group block it cannot apply" + } + + Write-Config "User nosuchuser Group Administrators" + if (-not (Test-Refused)) { + throw "wolfsshd did not refuse a Match User/Group block it cannot apply" + } + + Write-Config "User nosuchuser" + if (-not (Test-Listens)) { + throw "control: wolfsshd did not start again with a Match User block" + } + + Write-Host "PASS: Match Group refused, Match User accepted" + $exitCode = 0 +} +catch { + Write-Host "FAIL: $_" + $exitCode = 1 +} +finally { + Remove-Item -Path $confFile, $authFile, $logFile -Force ` + -ErrorAction SilentlyContinue +} + +exit $exitCode diff --git a/apps/wolfsshd/test/test_configuration.c b/apps/wolfsshd/test/test_configuration.c index 67047ea9d..b4eb15d08 100644 --- a/apps/wolfsshd/test/test_configuration.c +++ b/apps/wolfsshd/test/test_configuration.c @@ -819,7 +819,14 @@ static int test_GetUserConfChrootNone(void) /* Only the User and Group Match selectors are implemented. A Match block keyed * on any other selector (Address, Host, LocalAddress, LocalPort, RDomain) would * otherwise be accepted but never apply, a fail-open misconfiguration. Verify - * that User/Group are accepted while the unsupported selectors are rejected. */ + * that User/Group are accepted while the unsupported selectors are rejected. + * Under WOLFSSHD_NO_MATCH_GROUP (always on Windows) a block that names Group + * could never apply either, so it must be rejected too. */ +#ifdef WOLFSSHD_NO_MATCH_GROUP + #define MATCH_GROUP_FAILS 1 +#else + #define MATCH_GROUP_FAILS 0 +#endif static int test_MatchUnsupportedSelector(void) { int ret = WS_SUCCESS; @@ -830,11 +837,11 @@ static int test_MatchUnsupportedSelector(void) static CONFIG_LINE_VECTOR vectors[] = { /* supported selectors */ {"Match User", "Match User testuser", 0}, - {"Match Group", "Match Group testgroup", 0}, + {"Match Group", "Match Group testgroup", MATCH_GROUP_FAILS}, /* combined supported selectors must be accepted, in either order */ - {"Match User+Group", "Match User alice Group dev", 0}, - {"Match Group+User", "Match Group dev User alice", 0}, + {"Match User+Group", "Match User alice Group dev", MATCH_GROUP_FAILS}, + {"Match Group+User", "Match Group dev User alice", MATCH_GROUP_FAILS}, /* unsupported selectors must be rejected, not silently ignored */ {"Match Address", "Match Address 10.0.0.0/8", 1}, @@ -889,6 +896,7 @@ static int test_MatchUnsupportedSelector(void) return ret; } +#ifndef WOLFSSHD_NO_MATCH_GROUP /* Resolves 'usr' against 'head' and compares the resolved ForceCommand with * 'expect', where NULL expects no forced command. wolfSSHD_GetUserConf composes * a config the caller owns, so this frees it. Returns WS_SUCCESS on a match. */ @@ -917,6 +925,7 @@ static int CheckForcedCmd(WOLFSSHD_CONFIG* head, const char* usr, wolfSSHD_ConfigFree(match); return ret; } +#endif /* !WOLFSSHD_NO_MATCH_GROUP */ /* Same as CheckForcedCmd for the resolved PasswordAuthentication setting. */ static int CheckPwAuth(WOLFSSHD_CONFIG* head, const char* usr, @@ -939,6 +948,7 @@ static int CheckPwAuth(WOLFSSHD_CONFIG* head, const char* usr, return ret; } +#ifndef WOLFSSHD_NO_MATCH_GROUP /* A combined 'Match User X Group Y' directive is a conjunction: it applies only * to a user who satisfies BOTH selectors, matching OpenSSH semantics. This * locks in that wolfSSHD_GetUserConf does not apply such a block to a user @@ -1017,7 +1027,9 @@ static int test_GetUserConfMatchGroupAnd(void) wolfSSHD_ConfigFree(head); return ret; } +#endif /* !WOLFSSHD_NO_MATCH_GROUP */ +#ifndef WOLFSSHD_NO_MATCH_GROUP /* A 'Match Group' directive must match on any of the user's groups, primary or * supplementary, the way OpenSSH evaluates it. The matcher receives the full * group set as a list, so a group named only in a secondary slot still selects @@ -1086,6 +1098,7 @@ static int test_GetUserConfMatchSecondaryGroup(void) wolfSSHD_ConfigFree(head); return ret; } +#endif /* !WOLFSSHD_NO_MATCH_GROUP */ /* A Match directive whose user name contains the substring "Group" (e.g. * "GroupAdmin") must not yield a spurious group applies-to value. The parser is @@ -1134,6 +1147,7 @@ static int test_GetUserConfMatchSubstring(void) return ret; } +#ifndef WOLFSSHD_NO_MATCH_GROUP /* Symmetric counterpart to test_GetUserConfMatchSubstring: a Match directive * whose group name contains the substring "User" (e.g. "UserStaff") must not * yield a spurious user applies-to value. "Match Group UserStaff" sets only the @@ -1179,6 +1193,7 @@ static int test_GetUserConfMatchSubstringGroup(void) wolfSSHD_ConfigFree(head); return ret; } +#endif /* !WOLFSSHD_NO_MATCH_GROUP */ /* A principal named exactly like the opposite keyword must be handled by token * position, not by the embedded keyword. "Match User Group" names a user whose @@ -1397,6 +1412,7 @@ static int test_IncludeRecursionBound(void) return ret; } +#ifndef WOLFSSHD_NO_MATCH_GROUP /* Each Match block is built from the global config, not from the Match block * before it. A user selected by a later block must not pick up settings that * an earlier, non-matching block changed. */ @@ -1504,8 +1520,10 @@ static int test_GetUserConfMatchNoInherit(void) wolfSSHD_ConfigFree(head); return ret; } +#endif /* !WOLFSSHD_NO_MATCH_GROUP */ +#ifndef WOLFSSHD_NO_MATCH_GROUP /* Builds "global, Match User alice, Match Group nopw" or the reverse order, * depending on 'userFirst'. Only the group block sets PasswordAuthentication; * only the user block sets AuthorizedKeysFile. Returns the head on success. */ @@ -1791,6 +1809,7 @@ static int test_GetUserConfMatchOverlapCompose(void) return (fail != WS_SUCCESS) ? fail : ret; } +#endif /* !WOLFSSHD_NO_MATCH_GROUP */ /* A Match block inside an Include'd file must survive a later Match block in @@ -2120,8 +2139,10 @@ static int test_ConfigFree(void) /* Match User - allocates usrAppliesTo on the copied node */ if (ret == WS_SUCCESS) ret = PCL("Match User alice"); +#ifndef WOLFSSHD_NO_MATCH_GROUP /* Match Group - allocates groupAppliesTo on the next copied node */ if (ret == WS_SUCCESS) ret = PCL("Match Group staff"); +#endif #undef PCL /* Free must not crash and must release every allocation */ @@ -7525,19 +7546,25 @@ const TEST_CASE testCases[] = { TEST_DECL(test_GetUserConfChrootNone), TEST_DECL(test_MatchUnsupportedSelector), TEST_DECL(test_GetUserConfMatchSubstring), +#ifndef WOLFSSHD_NO_MATCH_GROUP TEST_DECL(test_GetUserConfMatchSubstringGroup), +#endif TEST_DECL(test_GetUserConfMatchLiteralKeywordName), TEST_DECL(test_GetUserConfMatchBareKeyword), TEST_DECL(test_GetUserConfMatchRepeatedKeyword), +#ifndef WOLFSSHD_NO_MATCH_GROUP TEST_DECL(test_GetUserConfMatchGroupAnd), TEST_DECL(test_GetUserConfMatchSecondaryGroup), +#endif TEST_DECL(test_CAKeysFileDiffers), TEST_DECL(test_PermitRootLoginModes), TEST_DECL(test_ConfigParseAuthorizedUPNDomains), TEST_DECL(test_MatchUPNToUser), TEST_DECL(test_IncludeRecursionBound), +#ifndef WOLFSSHD_NO_MATCH_GROUP TEST_DECL(test_GetUserConfMatchNoInherit), TEST_DECL(test_GetUserConfMatchOverlapCompose), +#endif TEST_DECL(test_ConfigIncludeMatchChain), TEST_DECL(test_GetUserAuthTypes), TEST_DECL(test_DefaultUserAuthTypesNullArgs),