Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
}
Expand Down Expand Up @@ -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.";
Comment on lines +184 to +196

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall pretty long. No need to mention implementation details in the YANG description, so the last paragraph + (username, client address) part can be dropped. Other parts possibly too.
It should plainly metion something along: "it's an account-lockout control for 3GPP 33.117 compliance, that the brute-force rate limit is max-auth-attempts + auth-timeout, and that enabling lockout without max-auth-attempts still enables one connection to make an unlimited amount of attempts".


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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should also add range "1..max".

default 300;
units "seconds";
description
"How long the account stays locked out of password authentication.";
}

leaf fail-window {
type uint16;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should also add range "1..max".

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 {
Expand Down
7 changes: 7 additions & 0 deletions src/config.h.in
Original file line number Diff line number Diff line change
Expand Up @@ -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_ */
55 changes: 55 additions & 0 deletions src/server_config.c
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
56 changes: 56 additions & 0 deletions src/session_p.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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. */
};

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down
4 changes: 4 additions & 0 deletions src/session_server.c
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
21 changes: 21 additions & 0 deletions src/session_server.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Loading