diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e27ac8cd7e3b73bde3ab4ab48a43bb91f12f105d..2e1c39e79103c1ce69a10d0a99146fcba5d33e20 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -53,7 +53,7 @@ jobs: needs: smoke-tests - container: ghcr.io/pi-hole/ftl-build:v1.27-${{ matrix.arch }} + container: ghcr.io/pi-hole/ftl-build:v1.28-${{ matrix.arch }} strategy: fail-fast: false diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 32dff361a54cf32e929b3f212cd83de20c1091e1..a7e24f889a59d05ed5ba0dbeee7bd290a2c19025 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -282,9 +282,9 @@ set(THREADS_PREFER_PTHREAD_FLAG TRUE) find_package(Threads REQUIRED) # for DNSSEC we need the nettle (+ hogweed) crypto and the gmp math libraries -find_library(LIBHOGWEED NAMES libhogweed${CMAKE_STATIC_LIBRARY_SUFFIX} hogweed) +find_library(LIBHOGWEED NAMES libhogweed${CMAKE_STATIC_LIBRARY_SUFFIX} hogweed HINTS /usr/local/lib64) find_library(LIBGMP NAMES libgmp${CMAKE_STATIC_LIBRARY_SUFFIX} gmp) -find_library(LIBNETTLE NAMES libnettle${CMAKE_STATIC_LIBRARY_SUFFIX} nettle) +find_library(LIBNETTLE NAMES libnettle${CMAKE_STATIC_LIBRARY_SUFFIX} nettle HINTS /usr/local/lib64) find_library(LIBIDN NAMES libidn${CMAKE_STATIC_LIBRARY_SUFFIX} idn) target_link_libraries(pihole-FTL rt Threads::Threads ${LIBHOGWEED} ${LIBGMP} ${LIBNETTLE} ${LIBIDN}) diff --git a/src/FTL.h b/src/FTL.h index f9e5a0ed62fed2976a5a0f92ca64b2aa7e11c64e..7c4151b98044fb01a41e6cf51d302053013c3794 100644 --- a/src/FTL.h +++ b/src/FTL.h @@ -110,12 +110,6 @@ // How many authenticated API clients are allowed simultaneously? [.] #define API_MAX_CLIENTS 16 -// How many challenges are valid simultaneously? [.] -#define API_MAX_CHALLENGES 8 - -// How long are challenges considered valid? [seconds] -#define API_CHALLENGE_TIMEOUT 10 - // After how many seconds do we check again if a client can be identified by other means? // (e.g., interface, MAC address, hostname) // Default: 60 (after one minutee) diff --git a/src/api/auth.c b/src/api/auth.c index 2316847f554de8a83fa183b0419bdf53a262a48d..305bc2e56cb2a2f963508613ed7ffbb58d9a68e5 100644 --- a/src/api/auth.c +++ b/src/api/auth.c @@ -70,13 +70,6 @@ static struct { char sid[SID_SIZE]; } auth_data[API_MAX_CLIENTS] = {{false, {false, false}, 0, 0, {0}, {0}, {0}}}; -#define CHALLENGE_SIZE (2*SHA256_DIGEST_SIZE) -static struct { - char challenge[CHALLENGE_SIZE + 1]; - char response[CHALLENGE_SIZE + 1]; - time_t valid_until; -} challenges[API_MAX_CHALLENGES] = {{{0}, {0}, 0}}; - // Can we validate this client? // Returns -1 if not authenticated or expired // Returns >= 0 for any valid authentication @@ -88,8 +81,8 @@ int check_client_auth(struct ftl_conn *api) strcmp(api->request->remote_addr, LOCALHOSTv6) == 0)) return API_AUTH_LOCALHOST; - // Check if there is a password hash - if(strlen(config.webserver.api.pwhash.v.s) == 0u) + // When the pwhash is unset, authentication is disabled + if(config.webserver.api.pwhash.v.s[0] == '\0') return API_AUTH_EMPTYPASS; // Does the client provide a session cookie? @@ -213,29 +206,6 @@ int check_client_auth(struct ftl_conn *api) return user_id; } -// Check received response -static bool check_response(const char *response, const time_t now) -{ - // Loop over all responses and try to validate response - for(unsigned int i = 0; i < API_MAX_CHALLENGES; i++) - { - // Skip expired entries - if(challenges[i].valid_until < now) - continue; - - if(strcasecmp(challenges[i].response, response) == 0) - { - // This challange-response has been used - // Invalidate to prevent replay attacks - challenges[i].valid_until = 0; - return true; - } - } - - // If transmitted challenge wasn't found -> this is an invalid auth request - return false; -} - static int get_all_sessions(struct ftl_conn *api, cJSON *json) { const time_t now = time(NULL); @@ -328,7 +298,6 @@ static int send_api_auth_status(struct ftl_conn *api, const int user_id, const t log_debug(DEBUG_API, "API Auth status: OK (localhost does not need auth)"); cJSON *json = JSON_NEW_OBJECT(); - JSON_ADD_NULL_TO_OBJECT(json, "challenge"); get_session_object(api, json, user_id, now); JSON_SEND_OBJECT(json); } @@ -338,7 +307,6 @@ static int send_api_auth_status(struct ftl_conn *api, const int user_id, const t log_debug(DEBUG_API, "API Auth status: OK (empty password)"); cJSON *json = JSON_NEW_OBJECT(); - JSON_ADD_NULL_TO_OBJECT(json, "challenge"); get_session_object(api, json, user_id, now); JSON_SEND_OBJECT(json); } @@ -356,7 +324,6 @@ static int send_api_auth_status(struct ftl_conn *api, const int user_id, const t } cJSON *json = JSON_NEW_OBJECT(); - JSON_ADD_NULL_TO_OBJECT(json, "challenge"); get_session_object(api, json, user_id, now); JSON_SEND_OBJECT(json); } @@ -369,7 +336,6 @@ static int send_api_auth_status(struct ftl_conn *api, const int user_id, const t strncpy(pi_hole_extra_headers, FTL_DELETE_COOKIE, sizeof(pi_hole_extra_headers)); cJSON *json = JSON_NEW_OBJECT(); - JSON_ADD_NULL_TO_OBJECT(json, "challenge"); get_session_object(api, json, user_id, now); JSON_SEND_OBJECT_CODE(json, 410); // 410 Gone } @@ -379,47 +345,11 @@ static int send_api_auth_status(struct ftl_conn *api, const int user_id, const t strncpy(pi_hole_extra_headers, FTL_DELETE_COOKIE, sizeof(pi_hole_extra_headers)); cJSON *json = JSON_NEW_OBJECT(); - JSON_ADD_NULL_TO_OBJECT(json, "challenge"); get_session_object(api, json, user_id, now); JSON_SEND_OBJECT_CODE(json, 401); // 401 Unauthorized } } -static void generateChallenge(const unsigned int idx, const time_t now) -{ - uint8_t raw_challenge[SHA256_DIGEST_SIZE]; - if(getrandom(raw_challenge, sizeof(raw_challenge), 0) < 0) - { - log_err("getrandom() failed in generateChallenge()"); - return; - } - sha256_raw_to_hex(raw_challenge, challenges[idx].challenge); - challenges[idx].valid_until = now + API_CHALLENGE_TIMEOUT; -} - -static void generateResponse(const unsigned int idx) -{ - uint8_t raw_response[SHA256_DIGEST_SIZE]; - struct sha256_ctx ctx; - sha256_init(&ctx); - - // Add challenge in hex representation - sha256_update(&ctx, - sizeof(challenges[idx].challenge)-1, - (uint8_t*)challenges[idx].challenge); - - // Add separator - sha256_update(&ctx, 1, (uint8_t*)":"); - - // Get and add password hash from setupVars.conf - sha256_update(&ctx, - strlen(config.webserver.api.pwhash.v.s), - (uint8_t*)config.webserver.api.pwhash.v.s); - - sha256_digest(&ctx, SHA256_DIGEST_SIZE, raw_response); - sha256_raw_to_hex(raw_response, challenges[idx].response); -} - static void generateSID(char *sid) { uint8_t raw_sid[SID_SIZE]; @@ -433,17 +363,15 @@ static void generateSID(char *sid) } // api/auth -// GET: Check authentication and obtain a challenge +// GET: Check authentication // POST: Login // DELETE: Logout int api_auth(struct ftl_conn *api) { // Check HTTP method + char *password = NULL; const time_t now = time(NULL); - - const bool empty_password = strlen(config.webserver.api.pwhash.v.s) == 0u; - - int user_id = API_AUTH_UNAUTHORIZED; + const bool empty_password = config.webserver.api.pwhash.v.s[0] == '\0'; if(api->item != NULL && strlen(api->item) > 0) { @@ -451,17 +379,14 @@ int api_auth(struct ftl_conn *api) return 0; } - bool reponse_set = false; - char response[CHALLENGE_SIZE+1u] = { 0 }; - // Did the client authenticate before and we can validate this? - user_id = check_client_auth(api); + int user_id = check_client_auth(api); // If this is a valid session, we can exit early at this point if(user_id != API_AUTH_UNAUTHORIZED) return send_api_auth_status(api, user_id, now); - // Login attempt, extract response + // Login attempt, check password if(api->method == HTTP_POST) { // Try to extract response from payload @@ -479,11 +404,11 @@ int api_auth(struct ftl_conn *api) api->payload.json_error); } - // Check if response is available - cJSON *json_response; - if((json_response = cJSON_GetObjectItemCaseSensitive(api->payload.json, "response")) == NULL) + // Check if password is available + cJSON *json_password; + if((json_password = cJSON_GetObjectItemCaseSensitive(api->payload.json, "password")) == NULL) { - const char *message = "No response found in JSON payload"; + const char *message = "No password found in JSON payload"; log_debug(DEBUG_API, "API auth error: %s", message); return send_json_error(api, 400, "bad_request", @@ -491,10 +416,10 @@ int api_auth(struct ftl_conn *api) NULL); } - // Check response length - if(strlen(json_response->valuestring) != CHALLENGE_SIZE) + // Check password type + if(!cJSON_IsString(json_password)) { - const char *message = "Invalid response length"; + const char *message = "Field password has to be of type 'string'"; log_debug(DEBUG_API, "API auth error: %s", message); return send_json_error(api, 400, "bad_request", @@ -502,10 +427,8 @@ int api_auth(struct ftl_conn *api) NULL); } - // Accept challenge - strncpy(response, json_response->valuestring, CHALLENGE_SIZE); - // response is already null-terminated - reponse_set = true; + // password is already null-terminated + password = json_password->valuestring; } // Logout attempt @@ -515,151 +438,114 @@ int api_auth(struct ftl_conn *api) return send_api_auth_status(api, user_id, now); } - // Login attempt and/or auth check - if(reponse_set || empty_password) + // If this is not a login attempt, we can exit early at this point + if(password == NULL && !empty_password) + return send_api_auth_status(api, user_id, now); + + // else: Login attempt + // - Client tries to authenticate using a password, or + // - There no password on this machine + if(empty_password ? true : verify_password(password, config.webserver.api.pwhash.v.s)) { - // - Client tries to authenticate using a challenge response, or - // - There no password on this machine - const bool response_correct = check_response(response, now); - if(response_correct || empty_password) + // Accepted + + // Zero-out password in memory to avoid leaking it when it is + // freed at the end of the current API request + if(password != NULL) + memset(password, 0, strlen(password)); + + // Check possible 2FA token + if(strlen(config.webserver.api.totp_secret.v.s) > 0) { - // Accepted + // Get 2FA token from payload + cJSON *json_totp; + if((json_totp = cJSON_GetObjectItemCaseSensitive(api->payload.json, "totp")) == NULL) + { + const char *message = "No 2FA token found in JSON payload"; + log_debug(DEBUG_API, "API auth error: %s", message); + return send_json_error(api, 400, + "bad_request", + message, + NULL); + } - // Check possible 2FA token - if(strlen(config.webserver.api.totp_secret.v.s) > 0) + if(!verifyTOTP(json_totp->valueint)) { - // Get 2FA token from payload - cJSON *json_twofa; - if((json_twofa = cJSON_GetObjectItemCaseSensitive(api->payload.json, "totp")) == NULL) - { - const char *message = "No 2FA token found in JSON payload"; - log_debug(DEBUG_API, "API auth error: %s", message); - return send_json_error(api, 400, - "bad_request", - message, - NULL); - } + // 2FA token is invalid + return send_json_error(api, 401, + "unauthorized", + "Invalid 2FA token", + NULL); + } + } - if(!verifyTOTP(json_twofa->valueint)) - { - // 2FA token is invalid - return send_json_error(api, 401, - "unauthorized", - "Invalid 2FA token", - NULL); - } + // Find unused authentication slot + for(unsigned int i = 0; i < API_MAX_CLIENTS; i++) + { + // Expired slow, mark as unused + if(auth_data[i].used && + auth_data[i].valid_until < now) + { + log_debug(DEBUG_API, "API: Session of client %u (%s) expired, freeing...", + i, auth_data[i].remote_addr); + delete_session(i); } - // Find unused authentication slot - for(unsigned int i = 0; i < API_MAX_CLIENTS; i++) + // Found unused authentication slot (might have been freed before) + if(!auth_data[i].used) { - // Expired slow, mark as unused - if(auth_data[i].used && - auth_data[i].valid_until < now) + // Mark as used + auth_data[i].used = true; + // Set validitiy to now + timeout + auth_data[i].login_at = now; + auth_data[i].valid_until = now + config.webserver.sessionTimeout.v.ui; + // Set remote address + strncpy(auth_data[i].remote_addr, api->request->remote_addr, sizeof(auth_data[i].remote_addr)); + auth_data[i].remote_addr[sizeof(auth_data[i].remote_addr)-1] = '\0'; + // Store user-agent (if available) + const char *user_agent = mg_get_header(api->conn, "user-agent"); + if(user_agent != NULL) { - log_debug(DEBUG_API, "API: Session of client %u (%s) expired, freeing...", - i, auth_data[i].remote_addr); - delete_session(i); + strncpy(auth_data[i].user_agent, user_agent, sizeof(auth_data[i].user_agent)); + auth_data[i].user_agent[sizeof(auth_data[i].user_agent)-1] = '\0'; } - - // Found unused authentication slot (might have been freed before) - if(!auth_data[i].used) + else { - // Mark as used - auth_data[i].used = true; - // Set validitiy to now + timeout - auth_data[i].login_at = now; - auth_data[i].valid_until = now + config.webserver.sessionTimeout.v.ui; - // Set remote address - strncpy(auth_data[i].remote_addr, api->request->remote_addr, sizeof(auth_data[i].remote_addr)); - auth_data[i].remote_addr[sizeof(auth_data[i].remote_addr)-1] = '\0'; - // Store user-agent (if available) - const char *user_agent = mg_get_header(api->conn, "user-agent"); - if(user_agent != NULL) - { - strncpy(auth_data[i].user_agent, user_agent, sizeof(auth_data[i].user_agent)); - auth_data[i].user_agent[sizeof(auth_data[i].user_agent)-1] = '\0'; - } - else - { - auth_data[i].user_agent[0] = '\0'; - } - - auth_data[i].tls.login = api->request->is_ssl; - auth_data[i].tls.mixed = false; - - // Generate new SID - generateSID(auth_data[i].sid); - - user_id = i; - break; + auth_data[i].user_agent[0] = '\0'; } - } - // Debug logging - if(config.debug.api.v.b && user_id > API_AUTH_UNAUTHORIZED) - { - char timestr[128]; - get_timestr(timestr, auth_data[user_id].valid_until, false, false); - log_debug(DEBUG_API, "API: Registered new user: user_id %i valid_until: %s remote_addr %s (accepted due to %s)", - user_id, timestr, auth_data[user_id].remote_addr, - response_correct ? "correct response" : "empty password"); - } - if(user_id == API_AUTH_UNAUTHORIZED) - { - log_warn("No free API seats available, not authenticating client"); + auth_data[i].tls.login = api->request->is_ssl; + auth_data[i].tls.mixed = false; + + // Generate new SID + generateSID(auth_data[i].sid); + + user_id = i; + break; } } - else + + // Debug logging + if(config.debug.api.v.b && user_id > API_AUTH_UNAUTHORIZED) { - log_debug(DEBUG_API, "API: Response incorrect. Response=%s, FTL=%s", response, config.webserver.api.pwhash.v.s); + char timestr[128]; + get_timestr(timestr, auth_data[user_id].valid_until, false, false); + log_debug(DEBUG_API, "API: Registered new user: user_id %i valid_until: %s remote_addr %s (accepted due to %s)", + user_id, timestr, auth_data[user_id].remote_addr, + empty_password ? "empty password" : "correct response"); + } + if(user_id == API_AUTH_UNAUTHORIZED) + { + log_warn("No free API seats available, not authenticating client"); } - - // Free allocated memory - return send_api_auth_status(api, user_id, now); } else { - // Client wants to get a challenge - // Generate a challenge - unsigned int i; - - // Get an empty/expired slot - for(i = 0; i < API_MAX_CHALLENGES; i++) - if(challenges[i].valid_until < now) - break; - - // If there are no empty/expired slots, then find the oldest challenge - // and replace it - if(i == API_MAX_CHALLENGES) - { - unsigned int minidx = 0; - time_t minval = now; - for(i = 0; i < API_MAX_CHALLENGES; i++) - { - if(challenges[i].valid_until < minval) - { - minval = challenges[i].valid_until; - minidx = i; - } - } - i = minidx; - } - - // Generate and store new challenge - generateChallenge(i, now); - - // Compute and store expected response for this challenge (SHA-256) - generateResponse(i); - - log_debug(DEBUG_API, "API: Sending challenge=%s", challenges[i].challenge); - - // Return to user - cJSON *json = JSON_NEW_OBJECT(); - JSON_REF_STR_IN_OBJECT(json, "challenge", challenges[i].challenge); - get_session_object(api, json, -1, now); - JSON_SEND_OBJECT(json); + log_debug(DEBUG_API, "API: Password incorrect: '%s'", password); } + + // Free allocated memory + return send_api_auth_status(api, user_id, now); } int api_auth_sessions(struct ftl_conn *api) diff --git a/src/api/config.c b/src/api/config.c index 14965e7242951b06e2d711d7b3ac0537d32a06d1..3990320667d509355a7d4aab7d62190d95de65d1 100644 --- a/src/api/config.c +++ b/src/api/config.c @@ -286,6 +286,15 @@ static const char *getJSONvalue(struct conf_item *conf_item, cJSON *elem, struct // Get password hash as allocated string (an empty string is hashed to an empty string) char *pwhash = strlen(elem->valuestring) > 0 ? create_password(elem->valuestring) : strdup(""); + // Verify that the password hash is valid + const bool verfied = verify_password(elem->valuestring, pwhash); + + if(!verfied) + { + free(pwhash); + return "Failed to create password hash (verification failed), password remains unchanged"; + } + // Get pointer to pwhash instead conf_item--; @@ -707,7 +716,8 @@ static int api_config_patch(struct ftl_conn *api) struct conf_item *conf_item = get_conf_item(&config, i); // Skip processing if value didn't change compared to current value - if(compare_config_item(conf_item->t, &new_item->v, &conf_item->v)) + if(compare_config_item(conf_item->t, &new_item->v, &conf_item->v) && + conf_item->t != CONF_PASSWORD) { log_debug(DEBUG_CONFIG, "Config item %s: Unchanged", conf_item->k); continue; diff --git a/src/api/docs/content/specs/auth.yaml b/src/api/docs/content/specs/auth.yaml index 20df66afa314ee86af26df4a1022152ac3c25465..61fb31fac26a11ab463b8453f1171af230fb4495 100644 --- a/src/api/docs/content/specs/auth.yaml +++ b/src/api/docs/content/specs/auth.yaml @@ -3,16 +3,12 @@ components: paths: auth: get: - summary: Request challenge for login + summary: Check if authentication is required tags: - Authentication operationId: "get_auth" description: | - Request challenge for digest-access challenge-response authentication. - The API may chose to reply with a valid session if no authentation is needed for this server. - - Note that challenges are only valid for a very short time, use them to immediately compute a response. There is no need to get a challenge before users have finished entering their passwords. responses: '200': description: OK @@ -32,18 +28,18 @@ components: dns_failure: $ref: 'auth.yaml#/components/examples/dns_failure' post: - summary: Submit response for login + summary: Submit password for login tags: - Authentication operationId: "add_auth" description: | - Submit computed response + Login with a password. The password is not stored in the session, and neither when to generating the session token. requestBody: description: Callback payload content: 'application/json': schema: - $ref: 'auth.yaml#/components/schemas/response' + $ref: 'auth.yaml#/components/schemas/password' responses: '200': description: OK @@ -69,10 +65,10 @@ components: examples: no_payload: $ref: 'auth.yaml#/components/examples/errors/no_payload' - no_response: - $ref: 'auth.yaml#/components/examples/errors/no_response' - response_inval: - $ref: 'auth.yaml#/components/examples/errors/response_inval' + no_password: + $ref: 'auth.yaml#/components/examples/errors/no_password' + password_inval: + $ref: 'auth.yaml#/components/examples/errors/password_inval' '401': description: Unauthorized content: @@ -194,7 +190,7 @@ components: missing_session_id: $ref: 'auth.yaml#/components/examples/errors/missing_session_id' session_id_oob: - $ref: 'auth.yaml#/components/examples/errors/no_response' + $ref: 'auth.yaml#/components/examples/errors/no_password' session_not_in_use: $ref: 'auth.yaml#/components/examples/errors/session_not_in_use' '401': @@ -210,13 +206,8 @@ components: session: type: object required: - - challenge - session properties: - challenge: - type: string - description: Challenge to be used for computing response - nullable: true session: type: object description: Session object @@ -243,13 +234,13 @@ components: type: boolean description: Whether the DNS server is up and running. False only in failed state - response: + password: type: object - description: Response to be sent to the API + description: Password to be sent to the API properties: - response: + password: type: string - description: Response to previous challenge + description: Password to be used for login example: abcdef sessions_list: type: object @@ -359,7 +350,6 @@ components: login_okay: summary: Login successful value: - challenge: null session: valid: true totp: false @@ -369,7 +359,6 @@ components: no_login_required: summary: No login required for this client value: - challenge: null session: valid: true totp: false @@ -379,7 +368,6 @@ components: login_required: summary: Login required value: - challenge: "a2926b025bcc8618c632f81cd6cf7c37ee051c08aab74b565fd5126350fcd056" session: valid: false totp: false @@ -389,7 +377,6 @@ components: login_failed: summary: Login failed value: - challenge: null session: valid: false totp: false @@ -399,7 +386,6 @@ components: dns_failure: summary: DNS server failure value: - challenge: "a2926b025bcc8618c632f81cd6cf7c37ee051c08aab74b565fd5126350fcd056" session: valid: false totp: false @@ -414,19 +400,19 @@ components: key: "bad_request" message: "No valid JSON payload found" hint: null - no_response: - summary: Bad request (no response) + no_password: + summary: Bad request (no password) value: error: key: "bad_request" - message: "No response found in JSON payload" + message: "No password found in JSON payload" hint: null - response_inval: - summary: Bad request (invalid response) + password_inval: + summary: Bad request (password is not a string) value: error: key: "bad_request" - message: "Invalid response length" + message: "Field password has to be of type 'string'" hint: null missing_session_id: summary: Bad request (missing session ID) diff --git a/src/config/cli.c b/src/config/cli.c index 471da6d13d149e2cca1d3d252667e4a289abd335..7f6f33528044c6c0402a16f88a4e55121b10e680 100644 --- a/src/config/cli.c +++ b/src/config/cli.c @@ -163,6 +163,16 @@ static bool readStringValue(struct conf_item *conf_item, const char *value, stru // Get password hash as allocated string (an empty string is hashed to an empty string) char *pwhash = strlen(value) > 0 ? create_password(value) : strdup(""); + // Verify that the password hash is valid + const bool verfied = verify_password(value, pwhash); + + if(!verfied) + { + log_err("Failed to create password hash (verification failed), password remains unchanged"); + free(pwhash); + return false; + } + // Free old password hash if it was allocated if(conf_item->t == CONF_STRING_ALLOCATED) free(conf_item->v.s); @@ -388,7 +398,7 @@ int set_config_from_CLI(const char *key, const char *value) // Also check if this is the password config item change as this // actually changed pwhash behind the scenes if(!compare_config_item(conf_item->t, &new_item->v, &conf_item->v) || - new_item == &newconf.webserver.api.password) + conf_item->t == CONF_PASSWORD) { // Config item changed diff --git a/src/config/password.c b/src/config/password.c index 1f71a75f816c1435957afa852da64028056b137b..de476c258f803132a6bd5db716a896b7b132d59a 100644 --- a/src/config/password.c +++ b/src/config/password.c @@ -9,12 +9,33 @@ * Please see LICENSE file for your rights under this license. */ #include "FTL.h" +#include "log.h" +#include "config/config.h" #include "password.h" +// genrandom() +#include <sys/random.h> + +// Randomness generator +#include "webserver/x509.h" + +// writeFTLtoml() +#include "config/toml_writer.h" // crypto library #include <nettle/sha2.h> #include <nettle/base64.h> #include <nettle/version.h> +#include <nettle/balloon.h> + +// Salt length for balloon hashing +// The purpose of including salts is to modify the function used to hash each +// user's password so that each stored password hash will have to be attacked +// individually. The only security requirement is that they are unique per user, +// there is no benefit in them being unpredictable or difficult to guess. +// +// As a prime example, Linux uses 64 bits in the shadow password system. In +// 2023, using 128 bits should be sufficient for the foreseeable future. +#define SALT_LEN 16 // 16 bytes = 128 bits // Convert RAW data into hex representation // Two hexadecimal digits are generated for each input byte. @@ -54,7 +75,281 @@ static char * __attribute__((malloc)) double_sha256_password(const char *passwor return strdup(response); } +static char * __attribute__((malloc)) base64_encode(const uint8_t *data, const size_t length) +{ + // Base64 encoding requires 4 bytes for every 3 bytes of input, plus + // additional bytes for padding. The output buffer must be large enough + // to hold the encoded data. + char *encoded = calloc(BASE64_ENCODE_LENGTH(length) + BASE64_ENCODE_FINAL_LENGTH, sizeof(char)); + + // Encode the data + size_t out_len; + struct base64_encode_ctx ctx; + base64_encode_init(&ctx); + out_len = base64_encode_update(&ctx, encoded, length, data); + out_len += base64_encode_final(&ctx, encoded + out_len); + + return encoded; +} + +static uint8_t * __attribute__((malloc)) base64_decode(const char *data, size_t *length) +{ + // Base64 decoding requires 3 bytes for every 4 bytes of input, plus + // additional bytes for padding. The output buffer must be large enough + // to hold the decoded data. + uint8_t *decoded = calloc(BASE64_DECODE_LENGTH(strlen(data)), sizeof(uint8_t)); + + // Decode the data + struct base64_decode_ctx ctx; + base64_decode_init(&ctx); + base64_decode_update(&ctx, length, decoded, strlen(data), data); + base64_decode_final(&ctx); + + return decoded; +} + +// Balloon hashing is a key derivation function presenting proven memory-hard +// password-hashing and modern design. It was created by Dan Boneh, Henry +// Corrigan-Gibbs (both at Stanford University) and Stuart Schechter (Microsoft +// Research) in 2016. It is a recommended function in NIST password +// guidelines. (see https://pages.nist.gov/800-63-3/sp800-63b.html#memsecretver) +// (introduction taken from https://en.wikipedia.org/wiki/Balloon_hashing) +// +// If phc_string is true, the output will be formatted as a PHC string +// Otherwise, the output will be the raw password hash +static char * __attribute__((malloc)) balloon_password(const char *password, + const uint8_t salt[SALT_LEN], + const bool phc_string) +{ + struct timespec start, end; + // Record starting time + if(config.debug.api.v.b) + clock_gettime(CLOCK_MONOTONIC, &start); + + // The space parameter s_cost determines how many blocks of working + // space the algorithm will require during its computation. It is + // common to set s_cost to a high value in order to increase the cost of + // hardware accelerators built by the adversary. + // The algorithm will need (s_cost + 1) * digest_size + // -> 32KB for s_cost = 1024 and algo = SHA256 + const size_t s_cost = 1024; + + // The time parameter t_cost determines the number of rounds of + // computation that the algorithm will perform. This can be used to + // further increase the cost of computation without raising the memory + // requirement. + const size_t t_cost = 32; + + // Scratch buffer scratch is a user allocated working space required by + // the algorithm. To determine the required size of the scratch buffer + // use the utility function balloon_itch. Output of BALLOON algorithm + // will be written into the output buffer dst that has to be at least + // digest_size bytes long. + uint8_t *scratch = calloc(balloon_itch(SHA256_DIGEST_SIZE, s_cost), sizeof(uint8_t)); + + // Compute hash of given password password salted with salt and write + // the result into the output buffer dst + balloon_sha256(s_cost, t_cost, + strlen(password), (const uint8_t *)password, + SALT_LEN, salt, scratch, scratch); + + if(config.debug.api.v.b) + { + // Record ending time + clock_gettime(CLOCK_MONOTONIC, &end); + + // Compute elapsed time + double elapsed = (end.tv_sec - start.tv_sec) + (end.tv_nsec - start.tv_nsec) / 1000000000.0; + log_debug(DEBUG_API, "Balloon hashing took %.1f milliseconds", 1e3*elapsed); + } + + if(!phc_string) + { + // Return raw hash + return (char*)scratch; + } + + char *salt_base64 = base64_encode(salt, SALT_LEN); + char *scratch_base64 = base64_encode(scratch, SHA256_DIGEST_SIZE); + + // Build PHC string-like output (output string is 101 bytes long (measured)) + char *output = calloc(128, sizeof(char)); + int size = snprintf(output, 128, "$BALLOON-SHA256$v=1$s=%zu,t=%zu$%s$%s", + s_cost, + t_cost, + salt_base64, + scratch_base64); + + if(size < 0) + { + // Error + log_err("Error while generating PHC string: %s", strerror(errno)); + goto clean_and_exit; + } + +clean_and_exit: + free(scratch); + free(salt_base64); + free(scratch_base64); + + return output; +} + +// Parse a PHC string and return the parameters and hash +// Returns true on success, false on error +static bool parse_PHC_string(const char *phc, size_t *s_cost, size_t *t_cost, uint8_t **salt, uint8_t **hash) +{ + int version = 0; + char algorithm[64] = { 0 }; + char salt_base64[64] = { 0 }; + char hash_base64[64] = { 0 }; + + // Parse PHC string + int size = sscanf(phc, "$%63[^$]$v=%d$s=%zu,t=%zu$%63[^$]$%63[^$]$", + algorithm, + &version, + s_cost, + t_cost, + salt_base64, + hash_base64); + + // Add null-terminators + algorithm[sizeof(algorithm) - 1] = '\0'; + salt_base64[sizeof(salt_base64) - 1] = '\0'; + hash_base64[sizeof(hash_base64) - 1] = '\0'; + + // Debug output + log_debug(DEBUG_API, "Parsed PHC string: '%s'", phc); + log_debug(DEBUG_API, " -> Algorithm: '%s'", algorithm); + log_debug(DEBUG_API, " -> Version: %d", version); + log_debug(DEBUG_API, " -> s_cost: %zu", *s_cost); + log_debug(DEBUG_API, " -> t_cost: %zu", *t_cost); + log_debug(DEBUG_API, " -> Salt: '%s'", salt_base64); + log_debug(DEBUG_API, " -> Hash: '%s'", hash_base64); + + // Check parsing result + if(size != 6) + { + // Error + log_err("Error while parsing PHC string: Found %d instead of 6 elements in definition", size); + return false; + } + + // Check PHC string version and algorithm + if(version != 1) + { + // Error + log_err("Unsupported PHC string version: %d", version); + return false; + } + + if(strcmp(algorithm, "BALLOON-SHA256") != 0) + { + // Error + log_err("Unsupported PHC string algorithm: %s", algorithm); + return false; + } + + // Decode salt and hash + size_t salt_len = 0; + *salt = base64_decode(salt_base64, &salt_len); + if(salt == NULL) + { + // Error + log_err("Error while decoding salt: %s", strerror(errno)); + return false; + } + if(salt_len != SALT_LEN) + { + // Error + log_err("Invalid decoded salt length: %zu, should be %d", + salt_len, SALT_LEN); + return false; + } + + size_t hash_len = 0; + *hash = base64_decode(hash_base64, &hash_len); + if(hash == NULL) + { + // Error + log_err("Error while decoding hash: %s", strerror(errno)); + return false; + } + if(hash_len != SHA256_DIGEST_SIZE) + { + // Error + log_err("Invalid decoded hash length: %zu, should be %d", + hash_len, SHA256_DIGEST_SIZE); + return false; + } + + return true; +} + char * __attribute__((malloc)) create_password(const char *password) { - return double_sha256_password(password); + // Generate a 128 bit random salt + // genrandom() returns cryptographically secure random data + uint8_t salt[SALT_LEN] = { 0 }; + if(getrandom(salt, sizeof(salt), 0) < 0) + { + log_err("getrandom() failed in create_password()"); + return NULL; + } + + // Generate balloon PHC-encoded password hash + return balloon_password(password, salt, true); +} + +bool verify_password(const char *password, const char* pwhash) +{ + // No password supplied + if(password == NULL || password[0] == '\0') + return false; + + // No password set + if(pwhash == NULL || pwhash[0] == '\0') + return true; + + if(pwhash[0] == '$') + { + // Parse PHC string + size_t s_cost = 0; + size_t t_cost = 0; + uint8_t *salt = NULL; + uint8_t *config_hash = NULL; + if(!parse_PHC_string(pwhash, &s_cost, &t_cost, &salt, &config_hash)) + return false; + if(salt == NULL || config_hash == NULL) + return false; + char *supplied = balloon_password(password, salt, false); + const bool result = memcmp(config_hash, supplied, SHA256_DIGEST_SIZE) == 0; + free(supplied); + return result; + } + else + { + // Legacy password + char *supplied = double_sha256_password(password); + const bool result = strcmp(pwhash, supplied) == 0; + free(supplied); + + // Upgrade double-hased password to BALLOON hash + if(result) + { + char *new_hash = balloon_password(password, NULL, true); + if(new_hash != NULL) + { + log_info("Upgrading password from SHA256^2 to BALLOON-SHA256"); + if(config.webserver.api.pwhash.t == CONF_STRING_ALLOCATED) + free(config.webserver.api.pwhash.v.s); + config.webserver.api.pwhash.v.s = new_hash; + config.webserver.api.pwhash.t = CONF_STRING_ALLOCATED; + writeFTLtoml(true); + free(new_hash); + } + } + + return result; + } } \ No newline at end of file diff --git a/src/config/password.h b/src/config/password.h index 7828ea95ab4373f7c544e7790e918f9837615c53..6cacc7581bddf2ed7149f71a1eb6b5868ce726a7 100644 --- a/src/config/password.h +++ b/src/config/password.h @@ -16,5 +16,6 @@ void sha256_raw_to_hex(uint8_t *data, char *buffer); char *create_password(const char *password) __attribute__((malloc)); +bool verify_password(const char *password, const char *pwhash); #endif //PASSWORD_H diff --git a/src/lua/ftl_lua.c b/src/lua/ftl_lua.c index f8c556b662cda88e27cebb10cd3dfda5d9a357b2..21e503e87984f302b6623e5f917b63202103bcea 100644 --- a/src/lua/ftl_lua.c +++ b/src/lua/ftl_lua.c @@ -212,7 +212,7 @@ static int pihole_needLogin(lua_State *L) { // Check if password is set const bool has_password = config.webserver.api.pwhash.v.s != NULL && - strlen(config.webserver.api.pwhash.v.s) > 0; + config.webserver.api.pwhash.v.s[0] != '\0'; // Check if address is loopback const bool is_loopback = strcmp(remote_addr, LOCALHOSTv4) == 0 || diff --git a/src/webserver/x509.c b/src/webserver/x509.c index 114c0d2fd7cab4e80b49df4581aafb66fa00a47c..ef998569f21a61ed7bb2bb8f5ac1f692566cb2e5 100644 --- a/src/webserver/x509.c +++ b/src/webserver/x509.c @@ -3,13 +3,13 @@ * Network-wide ad blocking via your own hardware. * * FTL Engine -* X.509 certificate routines +* X.509 certificate and randomness generator routines * * This file is copyright under the latest version of the EUPL. * Please see LICENSE file for your rights under this license. */ -#include "../FTL.h" -#include "../log.h" +#include "FTL.h" +#include "log.h" #include "x509.h" #include <mbedtls/rsa.h> #include <mbedtls/x509.h> @@ -105,6 +105,7 @@ bool generate_certificate(const char* certfile, bool rsa) return false; } + // Generate key if(rsa) { // Generate RSA key @@ -126,7 +127,6 @@ bool generate_certificate(const char* certfile, bool rsa) } } - // Create string with random digits for unique serial number // RFC 2459: The serial number is an integer assigned by the CA to each // certificate. It MUST be unique for each certificate issued by a given diff --git a/src/webserver/x509.h b/src/webserver/x509.h index 42b1cfc765b3cef45617ed96871fd34569c738da..3a7837487543f0da92aaea1553af12ca14b326a7 100644 --- a/src/webserver/x509.h +++ b/src/webserver/x509.h @@ -3,13 +3,16 @@ * Network-wide ad blocking via your own hardware. * * FTL Engine -* X.509 certificate routines +* X.509 certificate and randomness generator prototypes * * This file is copyright under the latest version of the EUPL. * Please see LICENSE file for your rights under this license. */ #ifndef X509_H #define X509_H +#include <mbedtls/entropy.h> +#include <mbedtls/ctr_drbg.h> + bool generate_certificate(const char* certfile, bool rsa); #endif // X509_H diff --git a/test/api/checkAPI.py b/test/api/checkAPI.py index bc6286640fb9b5513239c96a0eaa40ee4b0a1c89..89d966a23710ca485339fd9a9a79b46996ba4c9b 100644 --- a/test/api/checkAPI.py +++ b/test/api/checkAPI.py @@ -20,7 +20,7 @@ if __name__ == "__main__": exit(1) # Get endpoints from FTL - ftl = FTLAPI("http://127.0.0.1:8080") + ftl = FTLAPI("http://127.0.0.1:8080", "ABC") ftl.get_endpoints() errs = [0, 0, 0] diff --git a/test/api/json/add_password.json b/test/api/json/add_password.json index 2a1557d9f913f727e9c326f670c6019466c642f8..075c70e7307c60f308e0ba29ab782a99cd2032e1 100644 --- a/test/api/json/add_password.json +++ b/test/api/json/add_password.json @@ -2,7 +2,7 @@ "config": { "webserver": { "api": { - "pwhash": "183c1b634da0078fcf5b0af84bdcbb3e817708c3f22b329be84165f4bad1ae48" + "password": "ABC" } } } diff --git a/test/api/libs/FTLAPI.py b/test/api/libs/FTLAPI.py index 4a325a4acbc4b7b5c749c885578f68ad5d648eaf..e23dcd85fff3e3f5efc3ed81da5b9f486c384213 100644 --- a/test/api/libs/FTLAPI.py +++ b/test/api/libs/FTLAPI.py @@ -18,15 +18,6 @@ from hashlib import sha256 url = "http://pi.hole/api/auth" -""" -challenge = requests.get(url).json()["challenge"].encode('ascii') -response = sha256(challenge + b":" + pwhash).hexdigest().encode("ascii") -session = requests.post(url, data = {"response": response}).json() - -valid = session["session"]["valid"] # True / False -sid = session["session"]["sid"] # SID string if succesful, null otherwise -""" - class AuthenticationMethods(Enum): RANDOM = 0 HEADER = 1 @@ -38,7 +29,7 @@ class FTLAPI(): auth_method = "?" - def __init__(self, api_url: str): + def __init__(self, api_url: str, password: str = None): self.api_url = api_url self.endpoints = { "get": [], @@ -52,60 +43,30 @@ class FTLAPI(): self.verbose = False # Login to FTL API - self.login() + self.login(password) if self.session is None or 'valid' not in self.session or not self.session['valid']: raise Exception("Could not login to FTL API") def login(self, password: str = None): - # Get challenge from FTL + # Check if we even need to login response = self.GET("/api/auth") # Check if we are already logged in or authentication is not # required if response is None: raise Exception("No response from FTL API") - if 'session' in response and response['session']['valid']: - if 'session' not in response: - raise Exception("FTL returned invalid challenge item") + if 'session' not in response: + raise Exception("FTL returned invalid challenge item") + if 'session' in response and response['session']['valid'] == True: self.session = response["session"] + print(response) + if password is not None: + raise Exception("Password provided but API does not require authentication") return - pwhash = None - if password is None: - # Try to obtain the password hash from pihole.toml - try: - with open("/etc/pihole/pihole.toml", "r") as f: - # Iterate over all lines - for line in f: - # Find the line with the password hash - if line.startswith(" pwhash = "): - # Remove quotes and whitespace - line = line.split("=")[1].split("\"") - if len(line) > 2: - pwhash = line[1].strip() - break - except Exception as e: - # Could not read pihole.toml, throw an error - raise Exception("Could not read pihole.toml: " + str(e)) - if pwhash is None: - # The password hash was not found in pihole.toml, throw an error - raise Exception("No password hash found in pihole.toml") - - else: - # Generate password hash - pwhash = sha256(password.encode("ascii")).hexdigest() - pwhash = sha256(pwhash.encode("ascii")).hexdigest() - print("Using password hash: " + pwhash) - - if len(pwhash) != 64: - raise Exception("Invalid length of password hash") - - # Get the challenge from FTL - challenge = response["challenge"].encode("ascii") - response = sha256(challenge + b":" + pwhash.encode("ascii")).hexdigest() - response = self.POST("/api/auth", {"response": response}) + response = self.POST("/api/auth", {"password": password}) if 'session' not in response: - raise Exception("FTL returned invalid challenge item") + raise Exception("FTL returned invalid response item") self.session = response["session"] diff --git a/test/test_suite.bats b/test/test_suite.bats index 19aa207a6ce7c0de6965dd4c60f0c52bcf54976d..8a5f5982f1421905f61d9170567d04f746a875bc 100644 --- a/test/test_suite.bats +++ b/test/test_suite.bats @@ -1242,37 +1242,25 @@ @test "API authorization (without password): No login required" { run bash -c 'curl -s 127.0.0.1:8080/api/auth' printf "%s\n" "${lines[@]}" - [[ ${lines[0]} == '{"challenge":null,"session":{"valid":true,"totp":false,"sid":null,"validity":-1},"dns":true,"took":'*'}' ]] + [[ ${lines[0]} == '{"session":{"valid":true,"totp":false,"sid":null,"validity":-1},"dns":true,"took":'*'}' ]] } -@test "API authorization (with password): FTL challenges us" { +@test "API authorization: Setting password" { # Password: ABC - run bash -c 'curl -X PATCH http://127.0.0.1:8080/api/config -d "@test/api/json/add_password.json"' - run bash -c 'curl -s 127.0.0.1:8080/api/auth | jq ".challenge | length"' + run bash -c 'curl -s -X PATCH http://127.0.0.1:8080/api/config/webserver/api/password -d "{\"config\":{\"webserver\":{\"api\":{\"password\":\"ABC\"}}}}"' printf "%s\n" "${lines[@]}" - [[ ${lines[0]} == "64" ]] + [[ ${lines[0]} == "{\"config\":{\"webserver\":{\"api\":{\"password\":\"********\"}}},\"took\":"*"}" ]] } -@test "API authorization (with password): Incorrect response is rejected" { - run bash -c 'curl -s -X POST 127.0.0.1:8080/api/auth -d "{\"response\":\"0123456789012345678901234567890123456789012345678901234567890123\"}" | jq .session.valid' +@test "API authorization (with password): Incorrect password is rejected if password auth is enabled" { + # Password: ABC + run bash -c 'curl -s -X POST 127.0.0.1:8080/api/auth -d "{\"password\":\"XXX\"}" | jq .session.valid' printf "%s\n" "${lines[@]}" [[ ${lines[0]} == "false" ]] } @test "API authorization (with password): Correct password is accepted" { - computeResponse() { - local pwhash challenge response - pwhash="${1}" - challenge="${2}" - response=$(echo -n "${challenge}:${pwhash}" | sha256sum | sed 's/\s.*$//') - echo "${response}" - } - pwhash="183c1b634da0078fcf5b0af84bdcbb3e817708c3f22b329be84165f4bad1ae48" - challenge="$(curl -s -X GET 127.0.0.1:8080/api/auth | jq --raw-output .challenge)" - printf "Challenge: %s\n" "${challenge}" - response="$(computeResponse "$pwhash" "$challenge")" - printf "Response: %s\n" "${response}" - session="$(curl -s -X POST 127.0.0.1:8080/api/auth -d "{\"response\":\"$response\"}")" + session="$(curl -s -X POST 127.0.0.1:8080/api/auth -d "{\"password\":\"ABC\"}")" printf "Session: %s\n" "${session}" run jq .session.valid <<< "${session}" printf "%s\n" "${lines[@]}"