From 112f1d12938764e169219826efc1fbbeb22acd3a Mon Sep 17 00:00:00 2001 From: Niklas Moser Date: Mon, 7 Sep 2026 12:06:16 +0200 Subject: [PATCH] server FEATURE hash a clear-text NETCONF user password before storing it iana-crypt-hash, and RFC 9645 with it, define "$0$" as a value the server is expected to replace with a hash before storing. It is stored verbatim, leaving the clear text in the datastore and in every export of it. The ietf-netconf-server subscription is DONE_ONLY, which sysrepo runs only after the commit, so add subscriptions with SR_SUBSCR_UPDATE that rewrite every created or modified "$0$" hashed-password under listen/ and call-home/ into a "$6$" digest. Running, startup and candidate are all covered, any of them can be written directly and startup is copied into running on the next start, when no subscription exists yet. Values that arrive already hashed are left alone, as are older datastores still holding a "$0$" one. Needs nc_server_config_hash_password() from libnetconf2. Co-Authored-By: Claude Opus 5 (1M context) --- src/main.c | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/src/main.c b/src/main.c index 3f6893ce..fb2e063b 100644 --- a/src/main.c +++ b/src/main.c @@ -1228,6 +1228,114 @@ np2srv_libnetconf2_config_cb(sr_session_ctx_t *session, uint32_t UNUSED(sub_id), #ifdef NC_ENABLED_SSH_TLS +/** + * @brief Replace every clear-text "$0$" password among the changes by its hash. + * + * ietf-ssh-server types a user password as iana-crypt-hash:crypt-hash, where the "$0$" prefix means + * the value is clear text and the server is expected to store a hash of it instead (RFC 9645, + * iana-crypt-hash). Without this the clear text is stored verbatim and ends up in the datastore and + * in every export of it. Runs in the "update" event, so the hash is what gets committed. + * + * @param[in] session Implicit callback session, its data are edited. + * @param[in] xpath XPath selecting the hashed-password nodes to check. + * @return SR_ERR_OK on success, sysrepo error value otherwise. + */ +static int +np2srv_hash_passwords(sr_session_ctx_t *session, const char *xpath) +{ + int r, rc = SR_ERR_OK; + sr_change_iter_t *iter = NULL; + sr_change_oper_t op; + const struct lyd_node *node; + const char *value; + char *path = NULL, *hashed_pw = NULL; + + rc = sr_get_changes_iter(session, xpath, &iter); + if (rc != SR_ERR_OK) { + sr_session_set_error(session, NULL, rc, "Getting changes iter failed (%s).", sr_strerror(rc)); + goto cleanup; + } + + while ((r = sr_get_change_tree_next(session, iter, &op, &node, NULL, NULL, NULL)) == SR_ERR_OK) { + if ((op != SR_OP_CREATED) && (op != SR_OP_MODIFIED)) { + continue; + } + + value = lyd_get_value(node); + if (!value) { + continue; + } + + if (nc_server_config_hash_password(value, &hashed_pw)) { + rc = SR_ERR_INTERNAL; + sr_session_set_error(session, NULL, rc, "Hashing the password of a NETCONF user failed."); + goto cleanup; + } + if (!hashed_pw) { + /* already a hash, nothing to do */ + continue; + } + + path = lyd_path(node, LYD_PATH_STD, NULL, 0); + if (!path) { + rc = SR_ERR_NO_MEMORY; + goto cleanup; + } + + /* edit the data being committed so that the clear text is never stored */ + rc = sr_set_item_str(session, path, hashed_pw, NULL, 0); + if (rc != SR_ERR_OK) { + sr_session_set_error(session, NULL, rc, "Storing the hashed password failed (%s).", sr_strerror(rc)); + goto cleanup; + } + + VRB("Hashed the clear-text password of \"%s\".", path); + + free(path); + path = NULL; + free(hashed_pw); + hashed_pw = NULL; + } + if (r != SR_ERR_NOT_FOUND) { + rc = r; + sr_session_set_error(session, NULL, rc, "Getting next change failed (%s).", sr_strerror(r)); + goto cleanup; + } + +cleanup: + free(path); + free(hashed_pw); + sr_free_change_iter(iter); + return rc; +} + +/** + * @brief Callback for hashing clear-text NETCONF user passwords before they are committed. + * + * Separate from ::np2srv_libnetconf2_config_cb(), which is subscribed DONE-only and therefore cannot + * change (nor reject) the data. Subscribed for the running, startup and candidate datastores, any of + * which a client may write directly. + */ +static int +np2srv_password_hash_update_cb(sr_session_ctx_t *session, uint32_t UNUSED(sub_id), const char *UNUSED(module_name), + const char *UNUSED(xpath), sr_event_t event, uint32_t UNUSED(request_id), void *UNUSED(private_data)) +{ + int rc; + + if (event != SR_EV_UPDATE) { + return SR_ERR_OK; + } + + rc = np2srv_hash_passwords(session, "/ietf-netconf-server:netconf-server/listen/endpoints/endpoint/ssh/" + "ssh-server-parameters/client-authentication/users/user/password/hashed-password"); + if (rc != SR_ERR_OK) { + return rc; + } + + return np2srv_hash_passwords(session, "/ietf-netconf-server:netconf-server/call-home/netconf-client/endpoints/" + "endpoint/ssh/ssh-server-parameters/client-authentication/users/user/password/hashed-password"); +} + /** * @brief Callback for providing SSH algorithms operational data. */ @@ -1371,6 +1479,15 @@ server_data_subscribe(void) goto error; \ } +/* subscription that may still edit the data being committed, "done" is of no interest to it */ +#define SR_UPDATE_SUBSCR(mod_name, cb) \ + rc = sr_module_change_subscribe(np2srv.sr_sess, mod_name, NULL, cb, NULL, 0, \ + SR_SUBSCR_UPDATE | SR_SUBSCR_DONE_ONLY, &np2srv.sr_data_sub); \ + if (rc != SR_ERR_OK) { \ + ERR("Subscribing for \"%s\" data updates failed (%s).", mod_name, sr_strerror(rc)); \ + goto error; \ + } + /* subscribe for providing state data */ if (np2srv.sr_data_sub) { EINT; @@ -1465,6 +1582,18 @@ server_data_subscribe(void) /* create keys and certs subscriptions before server configuration, which may already reference them */ SR_CONFIG_SUBSCR("ietf-keystore", NULL, np2srv_libnetconf2_config_cb); SR_CONFIG_SUBSCR("ietf-truststore", NULL, np2srv_libnetconf2_config_cb); +#ifdef NC_ENABLED_SSH_TLS + /* hash clear-text user passwords before they are stored, needs the "update" event which the + * DONE-only subscription below cannot get; every datastore a client may write directly, startup + * is also copied into running on the next start, when no subscription exists yet */ + sr_session_switch_ds(np2srv.sr_sess, SR_DS_STARTUP); + SR_UPDATE_SUBSCR("ietf-netconf-server", np2srv_password_hash_update_cb); + sr_session_switch_ds(np2srv.sr_sess, SR_DS_CANDIDATE); + SR_UPDATE_SUBSCR("ietf-netconf-server", np2srv_password_hash_update_cb); + sr_session_switch_ds(np2srv.sr_sess, SR_DS_RUNNING); + SR_UPDATE_SUBSCR("ietf-netconf-server", np2srv_password_hash_update_cb); +#endif /* NC_ENABLED_SSH_TLS */ + SR_CONFIG_SUBSCR("ietf-netconf-server", NULL, np2srv_libnetconf2_config_cb); SR_CONFIG_SUBSCR("libnetconf2-netconf-server", NULL, np2srv_libnetconf2_config_cb);