From 5235de847c092d0335aa880a7480ae815a2f1cac Mon Sep 17 00:00:00 2001 From: Yosuke Shimizu Date: Fri, 26 Jun 2026 13:39:30 +0900 Subject: [PATCH] wolfsshd: expand AuthorizedKeysFile %u/%h/%% tokens per user --- apps/wolfsshd/auth.c | 154 +++++++++++++++++++----- apps/wolfsshd/auth.h | 2 + apps/wolfsshd/configuration.h | 4 + apps/wolfsshd/test/test_configuration.c | 118 ++++++++++++++++++ 4 files changed, 251 insertions(+), 27 deletions(-) diff --git a/apps/wolfsshd/auth.c b/apps/wolfsshd/auth.c index 50225637a..3505d32cf 100644 --- a/apps/wolfsshd/auth.c +++ b/apps/wolfsshd/auth.c @@ -107,9 +107,6 @@ struct WOLFSSHD_AUTH { #ifndef MAX_LINE_SZ #define MAX_LINE_SZ 900 #endif -#ifndef MAX_PATH_SZ - #define MAX_PATH_SZ 80 -#endif #if 0 /* this could potentially be useful in a deeply embedded future port */ @@ -464,15 +461,121 @@ static int CheckPasswordUnix(const char* usr, const byte* pw, word32 pwSz, WOLFS static const char authKeysDefault[] = ".ssh/authorized_keys"; -/* Resolve the authorized keys file path for a user. The pattern is the user's - * configured AuthorizedKeysFile (resolved per request from the per-user config) - * and is passed in explicitly rather than read from shared state so concurrent - * authentications (e.g. Windows threaded mode) cannot race on it. A NULL or - * empty pattern falls back to the default authorized_keys location. */ -static int ResolveAuthKeysPath(const char* homeDir, const char* pattern, +/* Expand AuthorizedKeysFile tokens (%% literal, %h home dir, %u user name) + * from pattern into out. Unrecognized tokens fail closed so a per-user pattern + * cannot collapse to one shared path. Returns WS_SUCCESS or a negative error. */ +static int ExpandAuthKeysTokens(const char* pattern, const char* homeDir, + const char* user, char* out, word32 outSz) +{ + int ret = WS_SUCCESS; + word32 outIdx = 0; + word32 i = 0; + word32 patSz; + word32 insSz; + const char* ins; + char lit[2]; + + if (pattern == NULL || out == NULL || outSz == 0) { + ret = WS_BAD_ARGUMENT; + } + + if (ret == WS_SUCCESS) { + patSz = (word32)WSTRLEN(pattern); + lit[1] = '\0'; + + while (ret == WS_SUCCESS && i < patSz) { + ins = NULL; + + if (pattern[i] == '%' && (i + 1) < patSz) { + switch (pattern[i + 1]) { + case '%': + lit[0] = '%'; + ins = lit; + break; + case 'h': + ins = homeDir; + break; + case 'u': + ins = user; + break; + default: + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] Unsupported AuthorizedKeysFile token"); + ret = WS_FATAL_ERROR; + break; + } + /* token recognized but its value is unavailable */ + if (ret == WS_SUCCESS && ins == NULL) { + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] No value for AuthorizedKeysFile token"); + ret = WS_FATAL_ERROR; + } + i += 2; + } + else { + /* literal character (including a trailing lone '%') */ + lit[0] = pattern[i]; + ins = lit; + i += 1; + } + + if (ret == WS_SUCCESS) { + insSz = (word32)WSTRLEN(ins); + /* leave room for the terminating null */ + if (outIdx + insSz >= outSz) { + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] Path for key file larger than max allowed"); + ret = WS_FATAL_ERROR; + } + else { + XMEMCPY(out + outIdx, ins, insSz); + outIdx += insSz; + } + } + } + } + + if (ret == WS_SUCCESS) { + out[outIdx] = '\0'; + } + + return ret; +} + +/* True for an absolute path. POSIX roots only at '/'; Windows also roots at a + * '\' or a drive letter ("X:"). The Windows forms stay guarded so a Unix + * relative pattern beginning with '\' or ":" is not misread. */ +static int IsAbsoluteAuthKeysPath(const char* path) +{ + int ret = 0; + + if (path != NULL) { + if (path[0] == '/') { + ret = 1; + } +#ifdef _WIN32 + else if (path[0] == '\\') { + ret = 1; + } + else if (((path[0] >= 'A' && path[0] <= 'Z') || + (path[0] >= 'a' && path[0] <= 'z')) && path[1] == ':') { + ret = 1; + } +#endif + } + + return ret; +} + +/* Resolve the authorized keys file path for a user. The pattern is passed in + * explicitly so concurrent authentications cannot race on it, and its tokens + * are expanded so each user resolves to a distinct path. */ +WOLFSSHD_STATIC int ResolveAuthKeysPath(const char* homeDir, + const char* pattern, const char* user, char* resolved) { int ret = WS_SUCCESS; + char expanded[MAX_PATH_SZ]; char* idx; int homeDirSz; const char* suffix = authKeysDefault; @@ -483,24 +586,19 @@ static int ResolveAuthKeysPath(const char* homeDir, const char* pattern, if (ret == WS_SUCCESS) { if (pattern != NULL && *pattern != 0) { - /* TODO: token substitutions (e.g. %h) */ - if (*pattern == '/') { - /* Absolute path is used as-is. Error out rather than - * silently truncate when it does not fit, mirroring the - * relative-path branch below. */ - if (WSTRLEN(pattern) >= MAX_PATH_SZ) { - wolfSSH_Log(WS_LOG_ERROR, - "[SSHD] Path for key file larger than max allowed"); - ret = WS_FATAL_ERROR; - } - else { - WSTRNCPY(resolved, pattern, MAX_PATH_SZ - 1); - resolved[MAX_PATH_SZ - 1] = '\0'; - } + ret = ExpandAuthKeysTokens(pattern, homeDir, user, expanded, + (word32)sizeof(expanded)); + if (ret != WS_SUCCESS) { + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] Failed to expand AuthorizedKeysFile pattern"); + } + /* expanded is NUL-terminated and shorter than MAX_PATH_SZ */ + else if (IsAbsoluteAuthKeysPath(expanded)) { + WMEMCPY(resolved, expanded, WSTRLEN(expanded) + 1); return ret; } else { - suffix = pattern; + suffix = expanded; } } } @@ -527,6 +625,7 @@ static int ResolveAuthKeysPath(const char* homeDir, const char* pattern, } static int SearchForPubKey(const char* path, const char* authKeysFile, + const char* user, const WS_UserAuthData_PublicKey* pubKeyCtx) { int ret = WSSHD_AUTH_SUCCESS; @@ -539,7 +638,7 @@ static int SearchForPubKey(const char* path, const char* authKeysFile, int rc = 0; WMEMSET(authKeysPath, 0, sizeof(authKeysPath)); - rc = ResolveAuthKeysPath(path, authKeysFile, authKeysPath); + rc = ResolveAuthKeysPath(path, authKeysFile, user, authKeysPath); if (rc != WS_SUCCESS) { wolfSSH_Log(WS_LOG_ERROR, "[SSHD] Failed to resolve authorized keys" " file path."); @@ -705,7 +804,8 @@ static int CheckPublicKeyUnix(const char* name, } if (ret == WSSHD_AUTH_SUCCESS) { - ret = SearchForPubKey(pwInfo->pw_dir, authorizedKeysFile, pubKeyCtx); + ret = SearchForPubKey(pwInfo->pw_dir, authorizedKeysFile, name, + pubKeyCtx); } } @@ -1049,7 +1149,7 @@ static int CheckPublicKeyWIN(const char* usr, if (ret == WSSHD_AUTH_SUCCESS) { r[rSz-1] = L'\0'; - ret = SearchForPubKey(r, authorizedKeysFile, pubKeyCtx); + ret = SearchForPubKey(r, authorizedKeysFile, usr, pubKeyCtx); if (ret != WSSHD_AUTH_SUCCESS) { wolfSSH_Log(WS_LOG_ERROR, "[SSHD] Failed to find public key for user %s", usr); diff --git a/apps/wolfsshd/auth.h b/apps/wolfsshd/auth.h index 298ea4690..6a2d0c338 100644 --- a/apps/wolfsshd/auth.h +++ b/apps/wolfsshd/auth.h @@ -93,6 +93,8 @@ int CheckPasswordHashUnix(const char* input, char* stored); #endif int CheckAuthKeysLine(char* line, word32 lineSz, const byte* key, word32 keySz); +int ResolveAuthKeysPath(const char* homeDir, const char* pattern, + const char* user, char* resolved); int CAKeysFileDiffers(const char* a, const char* b); int wolfSSHD_GetUserAuthTypes(const WOLFSSHD_CONFIG* usrConf); #endif diff --git a/apps/wolfsshd/configuration.h b/apps/wolfsshd/configuration.h index 8c8a542d8..d52c4d0f9 100644 --- a/apps/wolfsshd/configuration.h +++ b/apps/wolfsshd/configuration.h @@ -39,6 +39,10 @@ typedef struct WOLFSSHD_CONFIG WOLFSSHD_CONFIG; #define WOLFSSHD_PRIV_SANDBOX 1 #define WOLFSSHD_PRIV_OFF 2 +#ifndef MAX_PATH_SZ + #define MAX_PATH_SZ 80 +#endif + WOLFSSHD_CONFIG* wolfSSHD_ConfigNew(void* heap); void wolfSSHD_ConfigFree(WOLFSSHD_CONFIG* conf); int wolfSSHD_ConfigLoad(WOLFSSHD_CONFIG* conf, const char* filename); diff --git a/apps/wolfsshd/test/test_configuration.c b/apps/wolfsshd/test/test_configuration.c index 4f6757284..98dda6f1e 100644 --- a/apps/wolfsshd/test/test_configuration.c +++ b/apps/wolfsshd/test/test_configuration.c @@ -1661,6 +1661,123 @@ static int test_GetUserAuthTypes(void) return ret; } +/* Verify AuthorizedKeysFile token substitution so an absolute pattern with %u + * resolves to a per-user path instead of the same literal string for every + * user. */ +static int test_ResolveAuthKeysPath(void) +{ + int ret = WS_SUCCESS; + int rc; + word32 i; + char resolved[MAX_PATH_SZ]; + char longPat[MAX_PATH_SZ + 16]; + char longHome[MAX_PATH_SZ]; + static const struct { + const char* home; + const char* pattern; + const char* user; + int expectRet; + const char* expect; + } vectors[] = { + /* absolute pattern with %u resolves to a distinct path per user */ + { "/home/alice", "/etc/ssh/keys/%u", "alice", WS_SUCCESS, + "/etc/ssh/keys/alice" }, + { "/home/bob", "/etc/ssh/keys/%u", "bob", WS_SUCCESS, + "/etc/ssh/keys/bob" }, + /* %h expands to the home directory */ + { "/home/alice", "%h/.ssh/authorized_keys", "alice", WS_SUCCESS, + "/home/alice/.ssh/authorized_keys" }, + /* %% is a literal percent */ + { "/home/alice", "/keys/100%%/%u", "alice", WS_SUCCESS, + "/keys/100%/alice" }, + /* relative pattern is taken under the home directory */ + { "/home/alice", "keys/%u", "alice", WS_SUCCESS, + "/home/alice/keys/alice" }, +#ifdef _WIN32 + /* drive-letter and backslash roots are absolute on Windows */ + { "/home/alice", "C:\\keys\\%u", "alice", WS_SUCCESS, + "C:\\keys\\alice" }, + { "/home/alice", "\\keys\\%u", "alice", WS_SUCCESS, + "\\keys\\alice" }, +#else + /* on POSIX they are relative, taken under the home directory */ + { "/home/alice", "C:\\keys\\%u", "alice", WS_SUCCESS, + "/home/alice/C:\\keys\\alice" }, + { "/home/alice", "\\keys\\%u", "alice", WS_SUCCESS, + "/home/alice/\\keys\\alice" }, +#endif + /* NULL pattern falls back to the default location */ + { "/home/alice", NULL, "alice", WS_SUCCESS, + "/home/alice/.ssh/authorized_keys" }, + /* a trailing lone '%' is treated as a literal */ + { "/home/alice", "/etc/keys/%u%", "alice", WS_SUCCESS, + "/etc/keys/alice%" }, + /* unrecognized token fails closed */ + { "/home/alice", "/etc/keys/%q", "alice", WS_FATAL_ERROR, NULL }, + /* recognized token with no available value (NULL user) fails closed */ + { "/home/alice", "/etc/keys/%u", NULL, WS_FATAL_ERROR, NULL }, + }; + + for (i = 0; ret == WS_SUCCESS && i < sizeof(vectors) / sizeof(vectors[0]); + i++) { + Log(" Testing scenario: pattern \"%s\" user \"%s\".", + vectors[i].pattern != NULL ? vectors[i].pattern : "(null)", + vectors[i].user != NULL ? vectors[i].user : "(null)"); + WMEMSET(resolved, 0, sizeof(resolved)); + rc = ResolveAuthKeysPath(vectors[i].home, vectors[i].pattern, + vectors[i].user, resolved); + if (rc != vectors[i].expectRet) { + Log(" FAILED (rc=%d).\n", rc); + ret = WS_FATAL_ERROR; + } + else if (vectors[i].expect != NULL && + WSTRCMP(resolved, vectors[i].expect) != 0) { + Log(" FAILED (got \"%s\").\n", resolved); + ret = WS_FATAL_ERROR; + } + else { + Log(" PASSED.\n"); + } + } + + /* an expansion that exceeds MAX_PATH_SZ fails closed */ + if (ret == WS_SUCCESS) { + Log(" Testing scenario: over-length pattern is rejected."); + WMEMSET(longPat, 'a', sizeof(longPat) - 1); + longPat[0] = '/'; + longPat[sizeof(longPat) - 1] = '\0'; + WMEMSET(resolved, 0, sizeof(resolved)); + rc = ResolveAuthKeysPath("/home/alice", longPat, "alice", resolved); + if (rc == WS_FATAL_ERROR) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED (rc=%d).\n", rc); + ret = WS_FATAL_ERROR; + } + } + + /* a relative pattern under a long home directory fails closed */ + if (ret == WS_SUCCESS) { + Log(" Testing scenario: relative pattern under long home is " + "rejected."); + WMEMSET(longHome, 'a', sizeof(longHome) - 1); + longHome[0] = '/'; + longHome[sizeof(longHome) - 1] = '\0'; + WMEMSET(resolved, 0, sizeof(resolved)); + rc = ResolveAuthKeysPath(longHome, "keys/%u", "alice", resolved); + if (rc == WS_FATAL_ERROR) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED (rc=%d).\n", rc); + ret = WS_FATAL_ERROR; + } + } + + return ret; +} + const TEST_CASE testCases[] = { TEST_DECL(test_ConfigDefaults), TEST_DECL(test_ParseConfigLine), @@ -1678,6 +1795,7 @@ const TEST_CASE testCases[] = { TEST_DECL(test_IncludeRecursionBound), TEST_DECL(test_GetUserAuthTypes), TEST_DECL(test_ConfigSetAuthKeysFile), + TEST_DECL(test_ResolveAuthKeysPath), TEST_DECL(test_ConfigFree), #ifdef WOLFSSL_BASE64_ENCODE TEST_DECL(test_CheckAuthKeysLine),