diff --git a/CMakeLists.txt b/CMakeLists.txt index ae43766a..278bb3f3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -104,6 +104,7 @@ set(MAX_PSPOLL_THREAD_COUNT 6 CACHE STRING "Maximum number of threads that could set(TRANSPORT_HANDSHAKE_TIMEOUT 10 CACHE STRING "SSH key exchange and TLS handshake timeout in seconds") set(MESSAGE_MAX_SIZE 1048576 CACHE STRING "Maximum size of a message in kB") set(TIMEOUT_STEP 100 CACHE STRING "Number of microseconds tasks are repeated until timeout elapses") +set(AUTHLOCK_FILE "" CACHE STRING "File the SSH password authentication lockout tally is mirrored to, empty to keep the tally in memory only. Its directory must exist, it is not created.") set(YANG_MODULE_DIR "${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_DATADIR}/yang/modules/libnetconf2" CACHE STRING "Directory where to copy the YANG modules to") set(CLIENT_SEARCH_DIR "${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_DATADIR}/yang/modules" CACHE STRING "Default NC client YANG module search directory") diff --git a/modules/libnetconf2-netconf-server@2026-04-17.yang b/modules/libnetconf2-netconf-server@2026-09-18.yang similarity index 85% rename from modules/libnetconf2-netconf-server@2026-04-17.yang rename to modules/libnetconf2-netconf-server@2026-09-18.yang index 4b88a94c..146dd112 100644 --- a/modules/libnetconf2-netconf-server@2026-04-17.yang +++ b/modules/libnetconf2-netconf-server@2026-09-18.yang @@ -31,6 +31,10 @@ module libnetconf2-netconf-server { prefix tlss; } + revision "2026-09-18" { + description "Added password authentication lockout configuration."; + } + revision "2026-04-17" { description "Change SSH banner description, reference and string length to reflect its correct purpose."; } @@ -159,6 +163,67 @@ module libnetconf2-netconf-server { description "Represents the maximum amount of seconds an authentication can go on for."; } + + leaf max-auth-attempts { + type uint16; + default 0; + description + "Maximum number of failed authentication attempts allowed within a single SSH session, + after which the session is disconnected. Every rejected credential counts, including + every public key a client offers that the server does not accept, so this must be set + high enough for clients whose SSH agent holds several keys. + + The value 0 means no limit, in which case only auth-timeout bounds a single session."; + } + + container lockout { + presence + "Enables locking an account out of password authentication after repeated failures."; + + description + "Locks an account out of password authentication once it fails too many times in a row. + Failures are counted per (username, client address) pair across connections, so that a + client that cannot reach the account from its own address cannot lock the account out for + anyone else. The tally is shared by every password-based method the server offers, which + are the configured password, keyboard-interactive and PAM. Public key authentication is + not counted and never refused, so a locked out deployment stays reachable. + + The policy configured here is the one of the endpoint the connection arrived on; it is not + re-evaluated when authentication falls through to a referenced endpoint. + + The tally is kept in memory. It additionally survives a server restart if the server was + built with the AUTHLOCK_FILE option or the application set a path with the + nc_server_ssh_set_authlock_path() API call."; + + reference + "3GPP TS 33.117: Catalogue of general security assurance requirements, section 4.2.3.4.3.1"; + + leaf max-fails { + type uint16 { + range "1..max"; + } + default 5; + description + "Number of consecutive failed password authentications that lock the account out."; + } + + leaf lock-time { + type uint16; + default 300; + units "seconds"; + description + "How long the account stays locked out of password authentication."; + } + + leaf fail-window { + type uint16; + default 900; + units "seconds"; + description + "Two consecutive failures further apart than this do not count towards the same tally, + so that occasional typos spread over a long time never lock an account out."; + } + } } grouping ssh-server-banner-grouping { diff --git a/src/config.h.in b/src/config.h.in index 2e982fe7..b48e6d9d 100644 --- a/src/config.h.in +++ b/src/config.h.in @@ -101,4 +101,11 @@ /* Comply with the O-RAN WG11 security spec (client cert Extended Key Usage and Key Usage checks). */ #cmakedefine NC_COMPLY_WITH_ORAN_WG11 +/* + * Default file the SSH password authentication lockout tally is mirrored to. An empty string means + * no default, in which case the tally is kept in memory only unless a path is set with + * ::nc_server_ssh_set_authlock_path(). + */ +#define NC_AUTHLOCK_FILE_DEFAULT "@AUTHLOCK_FILE@" + #endif /* NC_CONFIG_H_ */ diff --git a/src/server_config.c b/src/server_config.c index 66e96982..84db4058 100644 --- a/src/server_config.c +++ b/src/server_config.c @@ -1665,6 +1665,48 @@ config_ssh_auth_timeout(const struct lyd_node *node, enum nc_operation UNUSED(pa return 0; } +static int +config_ssh_max_auth_attempts(const struct lyd_node *node, enum nc_operation UNUSED(parent_op), + struct nc_server_ssh_opts *ssh) +{ + /* default value always present */ + ssh->authlock.session_max_fails = strtoul(lyd_get_value(node), NULL, 10); + return 0; +} + +static int +config_ssh_lockout(const struct lyd_node *node, enum nc_operation parent_op, struct nc_server_ssh_opts *ssh) +{ + enum nc_operation op; + struct lyd_node *n; + + NC_NODE_GET_OP(node, parent_op, &op); + + if (op == NC_OP_DELETE) { + /* the container is gone, so the lockout is off again; max_fails of 0 disables it */ + ssh->authlock.max_fails = 0; + ssh->authlock.lock_time = 0; + ssh->authlock.fail_window = 0; + return 0; + } + + /* default values always present */ + nc_lyd_find_child_optional(node, "max-fails", &n); + if (n) { + ssh->authlock.max_fails = strtoul(lyd_get_value(n), NULL, 10); + } + nc_lyd_find_child_optional(node, "lock-time", &n); + if (n) { + ssh->authlock.lock_time = strtoul(lyd_get_value(n), NULL, 10); + } + nc_lyd_find_child_optional(node, "fail-window", &n); + if (n) { + ssh->authlock.fail_window = strtoul(lyd_get_value(n), NULL, 10); + } + + return 0; +} + static int config_endpt_reference(const struct lyd_node *node, enum nc_operation parent_op, char **endpt_ref) { @@ -1716,6 +1758,18 @@ config_ssh_client_auth(const struct lyd_node *node, enum nc_operation parent_op, NC_CHECK_RET(config_ssh_auth_timeout(n, op, ssh)); } + /* config max auth attempts per session (augment) */ + nc_lyd_find_child_optional(node, "libnetconf2-netconf-server:max-auth-attempts", &n); + if (n) { + NC_CHECK_RET(config_ssh_max_auth_attempts(n, op, ssh)); + } + + /* config password authentication lockout (augment) */ + nc_lyd_find_child_optional(node, "libnetconf2-netconf-server:lockout", &n); + if (n) { + NC_CHECK_RET(config_ssh_lockout(n, op, ssh)); + } + /* config endpoint reference (augment) */ nc_lyd_find_child_optional(node, "libnetconf2-netconf-server:endpoint-reference", &n); if (n) { @@ -5538,6 +5592,7 @@ nc_server_config_ssh_dup(const struct nc_server_ssh_opts *src, struct nc_server_ } (*dst)->auth_timeout = src->auth_timeout; + (*dst)->authlock = src->authlock; cleanup: if (rc) { diff --git a/src/session_p.h b/src/session_p.h index 284cbb54..670e2606 100644 --- a/src/session_p.h +++ b/src/session_p.h @@ -410,6 +410,42 @@ struct nc_hostkey { }; }; +/** + * @brief Number of (username, client address) pairs the password authentication lockout tracks. + * + * A pair is only ever added by a failed password authentication, and one that is currently locked + * out is never evicted, so the bound is on how many distinct pairs may be counted towards a lockout + * at the same time, not on how many may be locked out. + */ +#define NC_AUTHLOCK_MAX_ENTRIES 64 + +/** + * @brief Password authentication lockout policy of an SSH endpoint. + */ +struct nc_authlock_opts { + uint16_t session_max_fails; /**< failed authentication attempts allowed within a single SSH session, + 0 for no limit */ + uint16_t max_fails; /**< consecutive failed password authentications that lock an account out, + 0 if the lockout is disabled */ + uint16_t lock_time; /**< how long an account stays locked out, seconds */ + uint16_t fail_window; /**< failures further apart than this start a new tally, seconds */ +}; + +/** + * @brief Failed password authentication tally of a single (username, client address) pair. + * + * Keyed on the client address as well as the username because the username is entirely + * attacker-controlled: keyed on the username alone, anyone able to reach the server could keep any + * account permanently locked out. + */ +struct nc_authlock_entry { + char *username; /**< account the tally belongs to */ + char *host; /**< client address the tally belongs to, NULL if it was not known */ + uint32_t fails; /**< consecutive failed password authentications */ + time_t last_fail; /**< when the last one was */ + time_t locked_until; /**< no password authentication before this, 0 if not locked out */ +}; + /** * @brief Server options for configuring the SSH transport protocol. */ @@ -428,6 +464,8 @@ struct nc_server_ssh_opts { char *banner; /**< SSH banner message, sent before authentication. */ uint16_t auth_timeout; /**< Authentication timeout. */ + + struct nc_authlock_opts authlock; /**< Password authentication lockout policy. */ }; /** @@ -1162,6 +1200,16 @@ struct nc_session { ATOMIC_T *ch_thread_running; uint16_t ssh_auth_attempts; /**< number of failed SSH authentication attempts */ + + /** + * @brief Password authentication lockout policy of the endpoint the session arrived on. + * + * Copied out of ::nc_server_ssh_opts before the authentication starts, because the + * credential checks are reached from libssh callbacks that are not given the endpoint + * options. Not re-evaluated when authentication falls through to a referenced endpoint. + */ + struct nc_authlock_opts authlock; + void *client_cert; /**< TLS client certificate if used for authentication */ #endif /* NC_ENABLED_SSH_TLS */ } server; @@ -1694,6 +1742,14 @@ void nc_server_ch_thread_names_free(char **names); */ int nc_server_ch_threads_destroy(void); +/** + * @brief Free the password authentication lockout tally. + * + * The state file it mirrors, if one is configured, is kept, so a lockout survives the server being + * restarted. Must not be called before every thread that may authenticate a client has been joined. + */ +void nc_server_ssh_authlock_free(void); + /** * @brief Stop a dispatched Call Home client thread, if such thread was dispatched for the given client. * diff --git a/src/session_server.c b/src/session_server.c index 688e5dc1..802a1a04 100644 --- a/src/session_server.c +++ b/src/session_server.c @@ -1701,6 +1701,10 @@ nc_server_destroy(void) nc_server_config_release(config); #ifdef NC_ENABLED_SSH_TLS + /* free the password authentication lockout tally, the state file it mirrors is kept; only safe + * here, once the Call Home and accept threads that authenticate clients have been joined */ + nc_server_ssh_authlock_free(); + curl_global_cleanup(); nc_tls_backend_destroy_wrap(); ssh_finalize(); diff --git a/src/session_server.h b/src/session_server.h index e9e94b1a..ad1383e3 100644 --- a/src/session_server.h +++ b/src/session_server.h @@ -602,6 +602,27 @@ int nc_server_ssh_kbdint_get_nanswers(const struct nc_session *session, ssh_sess */ int nc_server_ssh_set_pam_conf_filename(const char *filename); +/** + * @brief Set the file the SSH password authentication lockout tally is mirrored to. + * + * The lockout counts consecutive failed password authentications per (username, client address) + * pair and is configured per SSH endpoint, see the `lockout` container in the + * `libnetconf2-netconf-server` YANG module. The tally is always kept in memory; setting a file here + * additionally makes a lockout survive the server being restarted, and lets an operator clear one + * early by removing the file or editing the entry out of it. + * + * The directory of the file has to exist and be writable by the server, it is not created. If the + * file cannot be written, an error is logged once and the tally stays in memory only. + * + * Overrides the path the library was built with (the `AUTHLOCK_FILE` CMake option), which is unset + * by default. Calling this discards the tally currently in memory. + * + * @param[in] path Path of the state file, NULL or an empty string to keep the tally in memory only. + * + * @return 0 on success, 1 on error. + */ +int nc_server_ssh_set_authlock_path(const char *path); + /** * @brief Set the SSH protocol identification string. * diff --git a/src/session_server_ssh.c b/src/session_server_ssh.c index fc97323e..6ce41927 100644 --- a/src/session_server_ssh.c +++ b/src/session_server_ssh.c @@ -21,11 +21,14 @@ #include #include #include +#include +#include #include #include #include #include #include +#include #include #include #include @@ -48,6 +51,541 @@ #include "session_server_ssh_wrapper.h" #include "session_wrapper.h" +/* + * Password authentication lockout. + * + * Consecutive failed password authentications are counted per (username, client address) pair + * across connections, and the pair is refused password authentication for lock_time seconds once + * max_fails is reached. The client address is part of the key because the username is entirely + * attacker-controlled: keyed on the username alone, anyone able to reach the server could keep any + * account permanently locked out by failing to authenticate as it. + * + * The policy is per SSH endpoint and off unless configured, see the lockout container in + * libnetconf2-netconf-server. The tally is mirrored to a state file when one is configured, so that + * restarting the server does not clear a lockout. Public key and certificate authentication are + * deliberately left alone: the lockout is on guessing a password, and leaving key auth open keeps a + * locked-out deployment recoverable. + */ +static struct { + pthread_mutex_t lock; + struct nc_authlock_entry entries[NC_AUTHLOCK_MAX_ENTRIES]; + uint32_t entry_count; + char *path; /**< state file the tally is mirrored to, NULL to keep it in memory only */ + int path_set; /**< whether @p path was resolved already */ + int loaded; /**< whether the state file was read already */ + ino_t loaded_ino; /**< inode of the state file revision the tally came from */ + struct timespec loaded_mtim; /**< mtime of the state file revision the tally came from */ + int store_failed; /**< whether the state file turned out to be unusable, disables persistence */ +} authlock = {.lock = PTHREAD_MUTEX_INITIALIZER}; + +/** + * @brief Drop the whole in-memory tally. Expects the lock to be held. + */ +static void +nc_authlock_clear(void) +{ + uint32_t i; + + for (i = 0; i < authlock.entry_count; ++i) { + free(authlock.entries[i].username); + free(authlock.entries[i].host); + } + authlock.entry_count = 0; +} + +/** + * @brief Get the path of the lockout state file. Expects the lock to be held. + * + * @return Path set by ::nc_server_ssh_set_authlock_path() or the compiled-in default, NULL if + * neither is set or the file turned out to be unusable, in which case the tally is memory-only. + */ +static const char * +nc_authlock_path(void) +{ + if (authlock.store_failed) { + return NULL; + } + + if (!authlock.path_set) { + authlock.path_set = 1; + if (NC_AUTHLOCK_FILE_DEFAULT[0]) { + authlock.path = strdup(NC_AUTHLOCK_FILE_DEFAULT); + if (!authlock.path) { + ERRMEM; + } + } + } + + return authlock.path; +} + +API int +nc_server_ssh_set_authlock_path(const char *path) +{ + char *dup = NULL; + + if (path && path[0]) { + dup = strdup(path); + NC_CHECK_ERRMEM_RET(!dup, 1); + } + + pthread_mutex_lock(&authlock.lock); + + free(authlock.path); + authlock.path = dup; + authlock.path_set = 1; + authlock.store_failed = 0; + + /* the tally in memory is the one of the previous file, re-read it from the new one */ + nc_authlock_clear(); + authlock.loaded = 0; + authlock.loaded_ino = 0; + memset(&authlock.loaded_mtim, 0, sizeof authlock.loaded_mtim); + + pthread_mutex_unlock(&authlock.lock); + return 0; +} + +/** + * @brief Read the lockout state file into the tally. Expects the lock to be held. + * + * @param[in] path State file to read. + */ +static void +nc_authlock_load(const char *path) +{ + char line[512], host[256], *username; + uint32_t fails; + long long locked_until, last_fail; + struct nc_authlock_entry *entry; + struct stat st; + int offset; + FILE *f; + + authlock.loaded = 1; + + f = fopen(path, "r"); + if (!f) { + return; + } + + /* remember which revision of the file this tally is, so that a change made to it from the + * outside is noticed */ + if (!fstat(fileno(f), &st)) { + authlock.loaded_ino = st.st_ino; + authlock.loaded_mtim = st.st_mtim; + } + + /* the username is the rest of the line so that it may contain spaces */ + while ((authlock.entry_count < NC_AUTHLOCK_MAX_ENTRIES) && fgets(line, sizeof line, f)) { + if (sscanf(line, "%" SCNu32 " %lld %lld %255s %n", + &fails, &locked_until, &last_fail, host, &offset) != 4) { + continue; + } + username = line + offset; + username[strcspn(username, "\n")] = '\0'; + if (!username[0]) { + continue; + } + + entry = &authlock.entries[authlock.entry_count]; + memset(entry, 0, sizeof *entry); + + entry->username = strdup(username); + if (!entry->username) { + break; + } + /* "-" is what an entry with no known client address is written as */ + if (strcmp(host, "-")) { + entry->host = strdup(host); + if (!entry->host) { + free(entry->username); + break; + } + } + entry->fails = fails; + entry->locked_until = locked_until; + entry->last_fail = last_fail; + ++authlock.entry_count; + } + + fclose(f); +} + +/** + * @brief Write the tally to the lockout state file. Expects the lock to be held. + * + * @param[in] path State file to write. + */ +static void +nc_authlock_store(const char *path) +{ + char *tmp_path = NULL; + FILE *f = NULL; + struct stat st; + uint32_t i; + int fd; + + if (asprintf(&tmp_path, "%s.tmp", path) == -1) { + return; + } + + /* 0600: the file lists account names */ + fd = open(tmp_path, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd == -1) { + goto fail; + } + f = fdopen(fd, "w"); + if (!f) { + close(fd); + goto fail; + } + + for (i = 0; i < authlock.entry_count; ++i) { + /* only a lockout is worth persisting, a tally that has not reached one yet is not written + * so that a failed authentication does not have to rewrite the file */ + if (!authlock.entries[i].locked_until) { + continue; + } + /* a username with whitespace of its own would break the line format, keep that one in + * memory only */ + if (strpbrk(authlock.entries[i].username, "\r\n")) { + continue; + } + fprintf(f, "%" PRIu32 " %lld %lld %s %s\n", authlock.entries[i].fails, + (long long)authlock.entries[i].locked_until, (long long)authlock.entries[i].last_fail, + authlock.entries[i].host ? authlock.entries[i].host : "-", authlock.entries[i].username); + } + + if (fclose(f)) { + goto fail; + } + if (rename(tmp_path, path)) { + goto fail; + } + + if (!stat(path, &st)) { + authlock.loaded_ino = st.st_ino; + authlock.loaded_mtim = st.st_mtim; + } + + free(tmp_path); + return; + +fail: + /* the path is a deployment setting, so this is a misconfiguration rather than a transient + * error; say so once and keep the tally in memory from here on */ + ERR(NULL, "Failed to store the authentication failure tally in \"%s\" (%s), its directory has to " + "exist and be writable. Lockouts will not survive a restart.", path, strerror(errno)); + authlock.store_failed = 1; + unlink(tmp_path); + free(tmp_path); +} + +/** + * @brief Bring the in-memory tally in line with the state file. Expects the lock to be held. + * + * Reads the file when it has not been read yet, and re-reads it when it has changed since - which is + * how an operator clears a lockout early: remove the file, or edit the account out of it. Writes + * this code made itself are not read back. + */ +static void +nc_authlock_sync(void) +{ + const char *path = nc_authlock_path(); + struct stat st; + + if (!path) { + /* no state file configured, the tally is memory-only */ + return; + } + + if (stat(path, &st)) { + if (errno != ENOENT) { + /* the file may well be there and only be unreadable right now, dropping the tally on + * that would unlock every account */ + return; + } + /* no state file, so nothing is locked out; it was either never written or removed to clear + * the lockouts */ + nc_authlock_clear(); + authlock.loaded = 1; + authlock.loaded_ino = 0; + memset(&authlock.loaded_mtim, 0, sizeof authlock.loaded_mtim); + return; + } + + if (authlock.loaded && (st.st_ino == authlock.loaded_ino) && + (st.st_mtim.tv_sec == authlock.loaded_mtim.tv_sec) && + (st.st_mtim.tv_nsec == authlock.loaded_mtim.tv_nsec)) { + /* the tally is that of the file as it is now */ + return; + } + + nc_authlock_clear(); + nc_authlock_load(path); +} + +/** + * @brief Compare the client address of an entry with the one of a connection. + * + * @param[in] entry_host Client address of the entry, may be NULL. + * @param[in] host Client address of the connection, may be NULL. + * @return Non-zero if they are the same address, 0 otherwise. + */ +static int +nc_authlock_same_host(const char *entry_host, const char *host) +{ + if (!entry_host || !host) { + return !entry_host && !host; + } + + return !strcmp(entry_host, host); +} + +/** + * @brief Find the tally of a (username, client address) pair. Expects the lock to be held. + * + * @param[in] username Account to look for. + * @param[in] host Client address to look for, may be NULL if it is not known. + * @return Its entry, NULL if it has none. + */ +static struct nc_authlock_entry * +nc_authlock_find(const char *username, const char *host) +{ + uint32_t i; + + for (i = 0; i < authlock.entry_count; ++i) { + if (!strcmp(authlock.entries[i].username, username) && + nc_authlock_same_host(authlock.entries[i].host, host)) { + return &authlock.entries[i]; + } + } + + return NULL; +} + +/** + * @brief Find or create the tally of a (username, client address) pair. Expects the lock to be held. + * + * @param[in] username Account to look for. + * @param[in] host Client address to look for, may be NULL if it is not known. + * @param[in] now Current time. + * @return Its entry, NULL if the table is full of lockouts or on allocation failure. + */ +static struct nc_authlock_entry * +nc_authlock_get(const char *username, const char *host, time_t now) +{ + struct nc_authlock_entry *entry; + uint32_t i, slot; + char *name, *addr = NULL; + + entry = nc_authlock_find(username, host); + if (entry) { + return entry; + } + + if (authlock.entry_count < NC_AUTHLOCK_MAX_ENTRIES) { + slot = authlock.entry_count; + } else { + /* full, reuse the entry that failed longest ago among those that are not locked out. An + * entry that is locked out is never evicted, otherwise filling the table would be all it + * takes to lift a lockout. */ + slot = NC_AUTHLOCK_MAX_ENTRIES; + for (i = 0; i < authlock.entry_count; ++i) { + if (authlock.entries[i].locked_until > now) { + continue; + } + if ((slot == NC_AUTHLOCK_MAX_ENTRIES) || + (authlock.entries[i].last_fail < authlock.entries[slot].last_fail)) { + slot = i; + } + } + if (slot == NC_AUTHLOCK_MAX_ENTRIES) { + /* every tracked pair is locked out, so refuse to track another one rather than drop a + * lockout; the pairs that are locked out keep being refused either way */ + WRN(NULL, "Authentication failure tally full of locked out users, not counting the " + "failures of user \"%s\" for now.", username); + return NULL; + } + } + + name = strdup(username); + NC_CHECK_ERRMEM_RET(!name, NULL); + if (host) { + addr = strdup(host); + if (!addr) { + ERRMEM; + free(name); + return NULL; + } + } + + if (slot == authlock.entry_count) { + ++authlock.entry_count; + } else { + free(authlock.entries[slot].username); + free(authlock.entries[slot].host); + } + + memset(&authlock.entries[slot], 0, sizeof authlock.entries[slot]); + authlock.entries[slot].username = name; + authlock.entries[slot].host = addr; + + return &authlock.entries[slot]; +} + +/** + * @brief Get how much longer a client is locked out of password authentication. + * + * @param[in] username Account to check. + * @param[in] host Client address to check, may be NULL if it is not known. + * @return Seconds until it may authenticate again, 0 if it is not locked out. + */ +static time_t +nc_authlock_remaining(const char *username, const char *host) +{ + struct nc_authlock_entry *entry; + time_t now = time(NULL), remaining = 0; + + pthread_mutex_lock(&authlock.lock); + + nc_authlock_sync(); + + entry = nc_authlock_find(username, host); + if (entry && (entry->locked_until > now)) { + remaining = entry->locked_until - now; + } + + pthread_mutex_unlock(&authlock.lock); + + return remaining; +} + +/** + * @brief Record the outcome of a password authentication. + * + * Does nothing unless the endpoint the session arrived on has the lockout configured. + * + * @param[in] session NETCONF session, for the policy, the client address and the log message. + * @param[in] username Account that authenticated. + * @param[in] success Whether it succeeded, which clears the tally. + */ +static void +nc_authlock_record(struct nc_session *session, const char *username, int success) +{ + const struct nc_authlock_opts *opts = &session->opts.server.authlock; + struct nc_authlock_entry *entry; + const char *path, *host = session->host; + time_t now = time(NULL), was_locked_until; + + if (!opts->max_fails || !username) { + /* the lockout is not configured for this endpoint */ + return; + } + + pthread_mutex_lock(&authlock.lock); + + nc_authlock_sync(); + + entry = nc_authlock_find(username, host); + if (success) { + if (!entry || (!entry->fails && !entry->locked_until)) { + /* nothing recorded for this client, no need to rewrite the state file */ + goto cleanup; + } + was_locked_until = entry->locked_until; + entry->fails = 0; + entry->last_fail = 0; + entry->locked_until = 0; + } else { + if (!entry) { + entry = nc_authlock_get(username, host, now); + if (!entry) { + goto cleanup; + } + } + was_locked_until = entry->locked_until; + + if (entry->locked_until && (entry->locked_until <= now)) { + /* the lockout expired, this failure starts a fresh tally rather than immediately + * locking the client out again */ + entry->fails = 0; + entry->locked_until = 0; + } else if (entry->fails && ((now - entry->last_fail) > opts->fail_window)) { + /* the previous failures are too old to count towards this one */ + entry->fails = 0; + } + + ++entry->fails; + entry->last_fail = now; + + if (entry->fails >= opts->max_fails) { + entry->locked_until = now + opts->lock_time; + WRN(session, "User \"%s\" locked out of password authentication for %" PRIu16 " s after " + "%" PRIu32 " consecutive failed attempts.", username, opts->lock_time, entry->fails); + } + } + + /* the state file only holds lockouts, so it is only rewritten when one starts or ends; a tally + * that has not reached a lockout yet is not worth making every failed attempt wait for a write */ + if (entry->locked_until != was_locked_until) { + path = nc_authlock_path(); + if (path) { + nc_authlock_store(path); + } + } + +cleanup: + pthread_mutex_unlock(&authlock.lock); +} + +void +nc_server_ssh_authlock_free(void) +{ + pthread_mutex_lock(&authlock.lock); + + nc_authlock_clear(); + free(authlock.path); + authlock.path = NULL; + authlock.path_set = 0; + authlock.store_failed = 0; + authlock.loaded = 0; + authlock.loaded_ino = 0; + memset(&authlock.loaded_mtim, 0, sizeof authlock.loaded_mtim); + + pthread_mutex_unlock(&authlock.lock); +} + +/** + * @brief Check whether a client is locked out of password authentication and log it if it is. + * + * Always allows the authentication unless the endpoint the session arrived on has the lockout + * configured. + * + * @param[in] session NETCONF session, for the policy, the client address and the log message. + * @param[in] username Account to check. + * @return 0 if it may authenticate, 1 if it is locked out. + */ +static int +nc_authlock_denied(struct nc_session *session, const char *username) +{ + time_t remaining; + + if (!session->opts.server.authlock.max_fails || !username) { + return 0; + } + + remaining = nc_authlock_remaining(username, session->host); + if (!remaining) { + return 0; + } + + WRN(session, "User \"%s\" is locked out of password authentication for another %lld s.", + username, (long long)remaining); + return 1; +} + int nc_ssh_check_local_user_support(struct nc_session *session) { @@ -157,9 +695,27 @@ nc_ssh_auth_success(struct nc_session *session, struct nc_auth_state *auth_state void nc_server_ssh_auth_attempt_failed(struct nc_session *session) { + uint16_t max_fails = session->opts.server.authlock.session_max_fails; + ++session->opts.server.ssh_auth_attempts; - VRB(session, "Failed user \"%s\" authentication attempt (#%d).", + VRB(session, "Failed user \"%s\" authentication attempt (#%" PRIu16 ").", session->username ? session->username : "unknown", session->opts.server.ssh_auth_attempts); + + /* every rejected credential gets here, including every public key the client offers that is not + * accepted, so the cap is off unless the endpoint configures max-auth-attempts */ + if (!max_fails || (session->opts.server.ssh_auth_attempts < max_fails)) { + return; + } + + /* the per-session cap, which bounds what a single connection may try; the accept loops end on a + * session that is no longer connected */ + if (NC_SESSION_STATUS_GET(session) != NC_STATUS_INVALID) { + ERR(session, "Too many failed authentication attempts (%" PRIu16 ") in a single session, disconnecting.", + session->opts.server.ssh_auth_attempts); + NC_SESSION_STATUS_SET(session, NC_STATUS_INVALID); + NC_SESSION_TERM_REASON_SET(session, NC_SESSION_TERM_OTHER); + ssh_disconnect(session->ti.libssh.session); + } } int @@ -171,6 +727,11 @@ nc_server_ssh_auth_password_check(struct nc_session *session, const char *user, assert(!local_users_supported || auth_client); + /* refuse an account that failed password authentication too many times */ + if (nc_authlock_denied(session, user)) { + return 1; + } + /* Get the stored password */ if (local_users_supported) { stored_password = auth_client->password; @@ -199,6 +760,9 @@ nc_server_ssh_auth_password_check(struct nc_session *session, const char *user, free(stored_password); } + /* a password that worked clears the account's tally, one that did not counts against it */ + nc_authlock_record(session, user, rc ? 0 : 1); + return rc; } @@ -450,6 +1014,12 @@ nc_server_ssh_pam_authenticate(struct nc_session *session, const char *username, char *pam_config_name = NULL; int ret; + /* refuse an account that failed password authentication too many times; pam_faillock, where it + * is configured, only sees the PAM methods, this tally is shared with the other ones */ + if (nc_authlock_denied(session, username)) { + return 1; + } + /* get the PAM configuration, PAM must not be called with the lock held */ if (nc_server_ssh_get_pam_conf_filename(&pam_config_name)) { return 1; @@ -474,9 +1044,20 @@ nc_server_ssh_pam_authenticate(struct nc_session *session, const char *username, } else { VRB(session, "PAM error occurred (%s).", pam_strerror(pam_h, ret)); } + + /* only a rejected credential counts towards the lockout; an aborted, unavailable or + * misconfigured PAM stack is not the client getting the password wrong */ + if ((ret == PAM_AUTH_ERR) || (ret == PAM_USER_UNKNOWN) || (ret == PAM_CRED_INSUFFICIENT) || + (ret == PAM_MAXTRIES)) { + nc_authlock_record(session, username, 0); + } goto cleanup; } + /* the credential was accepted, which clears the tally whatever the account management below + * has to say about the account */ + nc_authlock_record(session, username, 1); + /* correct token entered, check other requirements (the time of the day, expired token, ...) */ ret = pam_acct_mgmt(pam_h, 0); if ((ret != PAM_SUCCESS) && (ret != PAM_NEW_AUTHTOK_REQD)) { @@ -501,6 +1082,7 @@ nc_server_ssh_pam_authenticate(struct nc_session *session, const char *username, ERR(NULL, "PAM error occurred (%s).", pam_strerror(pam_h, ret)); } free(pam_config_name); + return ret; } @@ -1186,6 +1768,10 @@ nc_server_ssh_kbdint_verify_passwd(struct nc_session *session, const char *usern const char *answer; int rc; + if (nc_authlock_denied(session, username)) { + return 1; + } + if (n_answers != 1) { ERR(session, "Unexpected amount of answers in system auth. Expected 1, got \"%d\".", n_answers); return 1; @@ -1213,6 +1799,8 @@ nc_server_ssh_kbdint_verify_passwd(struct nc_session *session, const char *usern free(pw); free(received_pw); + nc_authlock_record(session, username, rc ? 0 : 1); + return rc; } @@ -1744,6 +2332,10 @@ nc_accept_ssh_session_auth(struct nc_session *session, struct nc_server_ssh_opts DBG(session, "SSH authentication..."); + /* the credential checks are reached from libssh callbacks that are not given @p opts, so the + * lockout policy of this endpoint has to be on the session before the first one runs */ + session->opts.server.authlock = opts->authlock; + /* authenticate */ if (opts->auth_timeout) { nc_timeouttime_get(&ts_timeout, opts->auth_timeout * 1000); diff --git a/src/session_server_ssh_wrapper.h b/src/session_server_ssh_wrapper.h index 827445d2..79e0c5d7 100644 --- a/src/session_server_ssh_wrapper.h +++ b/src/session_server_ssh_wrapper.h @@ -335,6 +335,10 @@ int nc_server_ssh_compare_password(const char *stored_pw, const char *received_p /** * @brief Increase the failed authentication attempt counter and log the attempt. * + * Disconnects the session once the counter reaches the endpoint's max-auth-attempts, which is + * unlimited unless configured. Every rejected credential is counted, including every public key + * the client offers that the server does not accept. + * * @param[in] session NETCONF session. */ void nc_server_ssh_auth_attempt_failed(struct nc_session *session); diff --git a/tests/test_ssh.c b/tests/test_ssh.c index 3ecf2a25..46999939 100644 --- a/tests/test_ssh.c +++ b/tests/test_ssh.c @@ -22,6 +22,8 @@ #include #include #include +#include +#include #include #include @@ -35,6 +37,7 @@ struct test_ssh_data { const char *privkey_path; int check_protocol_string; int expect_fail; + int bad_password; }; int TEST_PORT = 10050; @@ -69,12 +72,13 @@ __wrap_ssh_get_issue_banner(ssh_session session) static char * auth_password(const char *username, const char *hostname, void *priv) { + const struct test_ssh_data *test_data = priv; + (void) hostname; - (void) priv; /* set the reply to password authentication */ if (!strcmp(username, "test_pw")) { - return strdup("testpw"); + return strdup((test_data && test_data->bad_password) ? "not-testpw" : "testpw"); } else { return NULL; } @@ -119,7 +123,7 @@ client_thread_ssh(void *arg) ret = nc_client_ssh_add_keypair(test_data->pubkey_path, test_data->privkey_path); assert_int_equal(ret, 0); } else { - nc_client_ssh_set_auth_password_clb(auth_password, NULL); + nc_client_ssh_set_auth_password_clb(auth_password, test_data); } /* wait for the server to be ready */ @@ -164,6 +168,63 @@ test_password(void **state) } } +static int setup_ssh(void **state); + +/** @brief Whether ::setup_ssh() configures the password authentication lockout on the endpoint. */ +static int setup_ssh_with_lockout; + +#define TEST_AUTHLOCK_FILE "test_ssh_authlock_failures" + +static int +setup_ssh_lockout(void **state) +{ + int ret; + + /* mirror the tally to a file as well, so that persisting a lockout is exercised too */ + unlink(TEST_AUTHLOCK_FILE); + ret = nc_server_ssh_set_authlock_path(TEST_AUTHLOCK_FILE); + assert_int_equal(ret, 0); + + setup_ssh_with_lockout = 1; + ret = setup_ssh(state); + setup_ssh_with_lockout = 0; + + return ret; +} + +static void +test_password_lockout(void **state) +{ + int ret, i, round; + pthread_t tids[2]; + struct stat st; + struct ln2_test_ctx *test_ctx = *state; + struct test_ssh_data *test_data = test_ctx->test_data; + + test_data->username = "test_pw"; + test_data->expect_fail = 1; + + /* the first round is refused because the password is wrong, the second one because that single + * failure locked the account out - the password the client sends there is the correct one */ + for (round = 0; round < 2; ++round) { + test_data->bad_password = (round == 0); + + ret = pthread_create(&tids[0], NULL, client_thread_ssh, *state); + assert_int_equal(ret, 0); + ret = pthread_create(&tids[1], NULL, ln2_glob_test_server_thread_fail, *state); + assert_int_equal(ret, 0); + + for (i = 0; i < 2; i++) { + pthread_join(tids[i], NULL); + } + + /* the lockout the first round caused has to have reached the state file */ + assert_int_equal(stat(TEST_AUTHLOCK_FILE, &st), 0); + } + + unlink(TEST_AUTHLOCK_FILE); +} + static void test_none(void **state) { @@ -751,6 +812,14 @@ setup_ssh(void **state) "ssh-server-parameters/client-authentication/users/user[name='test_none']/none", NULL, 0, NULL); assert_int_equal(ret, 0); + if (setup_ssh_with_lockout) { + /* one failed password is enough to lock the account out, so that the test does not depend + * on how many times the client retries within a single connection */ + ret = lyd_new_path(tree, test_ctx->ctx, "/ietf-netconf-server:netconf-server/listen/endpoints/endpoint[name='endpt']/ssh/" + "ssh-server-parameters/client-authentication/libnetconf2-netconf-server:lockout/max-fails", "1", 0, NULL); + assert_int_equal(ret, 0); + } + /* add all the default nodes/np containers */ ret = lyd_new_implicit_tree(tree, LYD_IMPLICIT_NO_STATE, NULL); assert_int_equal(ret, 0); @@ -769,6 +838,7 @@ main(void) { const struct CMUnitTest tests[] = { cmocka_unit_test_setup_teardown(test_password, setup_ssh, ln2_glob_test_teardown), + cmocka_unit_test_setup_teardown(test_password_lockout, setup_ssh_lockout, ln2_glob_test_teardown), cmocka_unit_test_setup_teardown(test_none, setup_ssh, ln2_glob_test_teardown), cmocka_unit_test_setup_teardown(test_rsa_pubkey, setup_ssh, ln2_glob_test_teardown), cmocka_unit_test_setup_teardown(test_ec256_pubkey, setup_ssh, ln2_glob_test_teardown),