diff --git a/src/api/auth.c b/src/api/auth.c index e0a4698b..aa8fb9af 100644 --- a/src/api/auth.c +++ b/src/api/auth.c @@ -509,7 +509,8 @@ int api_auth(struct ftl_conn *api) // 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)) + const enum password_result result = empty_password ? true : verify_password(password, config.webserver.api.pwhash.v.s, true); + if(result == PASSWORD_CORRECT) { // Accepted @@ -604,6 +605,14 @@ int api_auth(struct ftl_conn *api) log_warn("No free API seats available, not authenticating client"); } } + else if(result == PASSWORD_RATE_LIMITED) + { + // Rate limited + return send_json_error(api, 429, + "too_many_requests", + "Too many requests", + "login rate limiting"); + } else { log_debug(DEBUG_API, "API: Password incorrect: '%s'", password); diff --git a/src/api/config.c b/src/api/config.c index b9daf616..6f3fa21b 100644 --- a/src/api/config.c +++ b/src/api/config.c @@ -287,7 +287,7 @@ static const char *getJSONvalue(struct conf_item *conf_item, cJSON *elem, struct char *pwhash = strlen(elem->valuestring) > 0 ? create_password(elem->valuestring) : strdup(""); // Verify that the password hash is valid - if(!verify_password(elem->valuestring, pwhash)) + if(verify_password(elem->valuestring, pwhash, false) != PASSWORD_CORRECT) { free(pwhash); return "Failed to create password hash (verification failed), password remains unchanged"; diff --git a/src/api/docs/content/specs/auth.yaml b/src/api/docs/content/specs/auth.yaml index f46645fb..2c314181 100644 --- a/src/api/docs/content/specs/auth.yaml +++ b/src/api/docs/content/specs/auth.yaml @@ -79,6 +79,14 @@ components: allOf: - $ref: 'common.yaml#/components/errors/unauthorized' - $ref: 'common.yaml#/components/schemas/took' + '429': + description: Too Many Requests + content: + application/json: + schema: + allOf: + - $ref: 'common.yaml#/components/errors/too_many_requests' + - $ref: 'common.yaml#/components/schemas/took' delete: summary: Delete session tags: diff --git a/src/api/docs/content/specs/common.yaml b/src/api/docs/content/specs/common.yaml index 75129d23..fa82c83c 100644 --- a/src/api/docs/content/specs/common.yaml +++ b/src/api/docs/content/specs/common.yaml @@ -53,6 +53,26 @@ components: nullable: true description: "No additional data available" example: null + too_many_requests: + type: object + description: "Too many requests (rate limiting)" + properties: + error: + type: object + properties: + key: + type: string + description: "Machine-readable error type" + example: "too_many_requests" + message: + type: string + description: "Human-readable error message" + example: "Too many requests" + hint: + type: string + nullable: true + description: "No additional data available" + example: null headers: Location: description: Location of created resource diff --git a/src/args.c b/src/args.c index 3f90d761..877b0bc9 100644 --- a/src/args.c +++ b/src/args.c @@ -58,6 +58,8 @@ #include "tools/dhcp-discover.h" // run_arp_scan() #include "tools/arp-scan.h" +// run_performance_test() +#include "config/password.h" // defined in dnsmasq.c extern void print_dnsmasq_version(const char *yellow, const char *green, const char *bold, const char *normal); @@ -355,6 +357,14 @@ void parse_args(int argc, char* argv[]) exit(run_dhcp_discover()); } + // Password hashing performance test + if(argc > 1 && (strcmp(argv[1], "--perf") == 0 || strcmp(argv[1], "performance") == 0)) + { + // Enable stdout printing + cli_mode = true; + exit(run_performance_test()); + } + // ARP scanning mode if(argc > 1 && strcmp(argv[1], "arp-scan") == 0) { @@ -817,6 +827,8 @@ void parse_args(int argc, char* argv[]) printf("\t interfaces and scan 10x more often\n"); printf("\t%s--totp%s Generate valid TOTP token for 2FA\n", green, normal); printf("\t authentication (if enabled)\n"); + printf("\t%s--perf%s Run performance-tests based on the\n", green, normal); + printf("\t BALLOON password-hashing algorithm\n"); printf("\t%s--%s [OPTIONS]%s Pass OPTIONS to internal dnsmasq resolver\n", green, cyan, normal); printf("\t%s-h%s, %shelp%s Display this help and exit\n\n", green, normal, green, normal); exit(EXIT_SUCCESS); diff --git a/src/config/cli.c b/src/config/cli.c index 5829b449..0b61548b 100644 --- a/src/config/cli.c +++ b/src/config/cli.c @@ -162,7 +162,7 @@ static bool readStringValue(struct conf_item *conf_item, const char *value, stru char *pwhash = strlen(value) > 0 ? create_password(value) : strdup(""); // Verify that the password hash is valid - if(!verify_password(value, pwhash)) + if(verify_password(value, pwhash, false) != PASSWORD_CORRECT) { log_err("Failed to create password hash (verification failed), password remains unchanged"); free(pwhash); diff --git a/src/config/password.c b/src/config/password.c index ebbe3caa..3fb676f3 100644 --- a/src/config/password.c +++ b/src/config/password.c @@ -14,6 +14,8 @@ #include "password.h" // genrandom() with fallback #include "daemon.h" +// sleepms() +#include "timers.h" // Randomness generator #include "webserver/x509.h" @@ -305,16 +307,39 @@ char * __attribute__((malloc)) create_password(const char *password) return balloon_password(password, salt, true); } -bool verify_password(const char *password, const char* pwhash) +char verify_password(const char *password, const char* pwhash, const bool rate_limiting) { // No password supplied if(password == NULL || password[0] == '\0') - return false; + return PASSWORD_INCORRECT; // No password set if(pwhash == NULL || pwhash[0] == '\0') - return true; + return PASSWORD_CORRECT; + // Check if there has already been one login attempt within this second + static time_t last_password_attempt = 0; + static unsigned int num_password_attempts = 0; + if(rate_limiting && + last_password_attempt > 0 && + last_password_attempt == time(NULL)) + { + // Check if we have reached the maximum number of attempts + if(++num_password_attempts > MAX_PASSWORD_ATTEMPTS_PER_SECOND) + { + // Rate limit reached + sleepms(250); + return PASSWORD_RATE_LIMITED; + } + } + else + { + // Reset counter + num_password_attempts = 1; + last_password_attempt = time(NULL); + } + + // Check password hash format if(pwhash[0] == '$') { // Parse PHC string @@ -323,9 +348,9 @@ bool verify_password(const char *password, const char* pwhash) uint8_t *salt = NULL; uint8_t *config_hash = NULL; if(!parse_PHC_string(pwhash, &s_cost, &t_cost, &salt, &config_hash)) - return false; + return PASSWORD_INCORRECT; if(salt == NULL || config_hash == NULL) - return false; + return PASSWORD_INCORRECT; char *supplied = balloon_password(password, salt, false); const bool result = memcmp(config_hash, supplied, SHA256_DIGEST_SIZE) == 0; @@ -336,7 +361,7 @@ bool verify_password(const char *password, const char* pwhash) if(config_hash != NULL) free(config_hash); - return result; + return result ? PASSWORD_CORRECT : PASSWORD_INCORRECT; } else { @@ -361,6 +386,187 @@ bool verify_password(const char *password, const char* pwhash) } } - return result; + return result ? PASSWORD_CORRECT : PASSWORD_INCORRECT; } } + +static double sqroot(double square) +{ + double root = square / 3.0; + if (square <= 0) return 0.0; + for (unsigned int i=0; i<32; i++) + root = (root + square / root) / 2; + return root; +} + +static int performance_test_task(const size_t s_cost, const size_t t_cost, const uint8_t password[], + const size_t pwlen, uint8_t salt[SALT_LEN], + double *elapsed1, double *elapsed2) +{ + struct timespec start, end, end2; + // 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. + const size_t scratch_size = balloon_itch(SHA256_DIGEST_SIZE, s_cost); + uint8_t *scratch = calloc(scratch_size, sizeof(uint8_t)); + if(scratch == NULL) + { + printf("Could not allocate %zu bytes of memory for test!\n", scratch_size); + return -1; + } + + // Record starting time + clock_gettime(CLOCK_MONOTONIC, &start); + + // Compute hash of given password password salted with salt and write + // the result into the output buffer dst + balloon_sha256(s_cost, t_cost, pwlen, password, SALT_LEN, salt, scratch, scratch); + + // Record end time + clock_gettime(CLOCK_MONOTONIC, &end); + + // Compute hash of given password password salted with salt and write + // the result into the output buffer dst + balloon_sha256(s_cost, t_cost, pwlen, password, SALT_LEN, salt, scratch, scratch); + + // Record end time + clock_gettime(CLOCK_MONOTONIC, &end2); + + // Free allocated memory + free(scratch); + + // Compute elapsed time + *elapsed1 = (end.tv_sec - start.tv_sec) + (end.tv_nsec - start.tv_nsec) / 1000000000.0; + *elapsed2 = (end2.tv_sec - end.tv_sec) + (end2.tv_nsec - end.tv_nsec) / 1000000000.0; + char prefix[2] = { 0 }; + double formatted = 0.0; + format_memory_size(prefix, (unsigned long long)scratch_size, &formatted); + const double avg = (*elapsed1 + *elapsed2)/2; + const double stdev = sqroot(((*elapsed1 - avg)*(*elapsed1 - avg) + (*elapsed2 - avg)*(*elapsed2 - avg))/2); + printf("s = %5zu, t = %5zu took %6.1f +/- %4.1f ms (scratch buffer %6.1f%1sB) -> %.0f\n", + s_cost, t_cost, 1e3*avg, 1e3*stdev, formatted, prefix, 1.0*(s_cost*t_cost)/avg); + + // Break if test took longer than two seconds + if(avg > 2) + return 1; + return 0; +} + +// Run performance tests until individual test result gets beyond 3 seconds +int run_performance_test(void) +{ + struct timespec start, end; + // Record starting time + clock_gettime(CLOCK_MONOTONIC, &start); + + // Test password + const uint8_t password[] = "abcdefghijklmnopqrstuvwxyz0123456789!\"ยง$%&/()=?"; + + // 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) + { + printf("Could not generate random salt!\n"); + return EXIT_FAILURE; + } + + printf("Running time-performance test:\n"); + size_t t_t_cost = 16; + const size_t t_s_cost = 512; + cJSON *time_test = cJSON_CreateArray(); + unsigned int i = 0; + while(true) + { + double elapsed1 = 0.0, elapsed2 = 0.0; + const int ret = performance_test_task(t_s_cost, t_t_cost, password, sizeof(password), salt, + &elapsed1, &elapsed2); + + if(ret == -1) + return EXIT_FAILURE; + + if(i > 0) + { + // We do not want to include the first test in the + // average as the first call is slower + cJSON_AddItemToArray(time_test, cJSON_CreateNumber(1.0*(t_s_cost*t_t_cost)/elapsed1)); + cJSON_AddItemToArray(time_test, cJSON_CreateNumber(1.0*(t_s_cost*t_t_cost)/elapsed2)); + } + + if(ret == 1) + break; + + // Double time costs + t_t_cost *= 2; + i++; + } + + printf("\nRunning space-performance test:\n"); + const size_t s_t_cost = 512; + size_t s_s_cost = 8; + cJSON *space_test = cJSON_CreateArray(); + while(true) + { + double elapsed1 = 0.0, elapsed2 = 0.0; + const int ret = performance_test_task(s_s_cost, s_t_cost, password, sizeof(password), salt, + &elapsed1, &elapsed2); + + cJSON_AddItemToArray(space_test, cJSON_CreateNumber(1.0*(s_t_cost*s_s_cost)/elapsed1)); + cJSON_AddItemToArray(space_test, cJSON_CreateNumber(1.0*(s_t_cost*s_s_cost)/elapsed2)); + + if(ret == -1) + return EXIT_FAILURE; + else if(ret == 1) + break; + + // Double space costs + s_s_cost *= 2; + } + + clock_gettime(CLOCK_MONOTONIC, &end); + const double elapsed = (end.tv_sec - start.tv_sec) + (end.tv_nsec - start.tv_nsec) / 1000000000.0; + + // Compute average time and space costs from data in cJSON arrays + cJSON *item = NULL; + double t_avg_sum1 = 0.0; + cJSON_ArrayForEach(item, time_test) + { + t_avg_sum1 += item->valuedouble; + } + t_avg_sum1 /= cJSON_GetArraySize(time_test); + + double s_avg_sum1 = 0.0; + cJSON_ArrayForEach(item, space_test) + { + s_avg_sum1 += item->valuedouble; + } + s_avg_sum1 /= cJSON_GetArraySize(space_test); + + // Get standard deviations + double t_stdev_sum1 = 0.0; + cJSON_ArrayForEach(item, time_test) + { + t_stdev_sum1 += (item->valuedouble - t_avg_sum1)*(item->valuedouble - t_avg_sum1); + } + t_stdev_sum1 = sqroot(t_stdev_sum1/cJSON_GetArraySize(time_test)); + + double s_stdev_sum1 = 0.0; + cJSON_ArrayForEach(item, space_test) + { + s_stdev_sum1 += (item->valuedouble - s_avg_sum1)*(item->valuedouble - s_avg_sum1); + } + s_stdev_sum1 = sqroot(s_stdev_sum1/cJSON_GetArraySize(space_test)); + + // Free allocated memory + cJSON_Delete(time_test); + cJSON_Delete(space_test); + + // Print results + printf("\nAverage time-performance index: %9.0f +/- %.0f (s = %zu)\n", t_avg_sum1, t_stdev_sum1, t_s_cost); + printf("Average space-performance index: %9.0f +/- %.0f (t = %zu)\n", s_avg_sum1, s_stdev_sum1, s_t_cost); + printf("\nTotal test time: %.1f seconds\n\n", elapsed); + + return EXIT_SUCCESS; +} diff --git a/src/config/password.h b/src/config/password.h index 6cacc758..e0a3a5f2 100644 --- a/src/config/password.h +++ b/src/config/password.h @@ -16,6 +16,16 @@ 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); +char verify_password(const char *password, const char *pwhash, const bool rate_limiting); +int run_performance_test(void); + +enum password_result { + PASSWORD_INCORRECT = 0, + PASSWORD_CORRECT = 1, + PASSWORD_RATE_LIMITED = -1 +} __attribute__((packed)); + +// The maximum number of password attempts per second +#define MAX_PASSWORD_ATTEMPTS_PER_SECOND 3 #endif //PASSWORD_H diff --git a/test/api/libs/FTLAPI.py b/test/api/libs/FTLAPI.py index 2b2519dc..a71bcec2 100644 --- a/test/api/libs/FTLAPI.py +++ b/test/api/libs/FTLAPI.py @@ -43,9 +43,10 @@ class FTLAPI(): self.verbose = False # Login to FTL API - 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") + if password is not None: + 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): # Check if we even need to login @@ -65,6 +66,8 @@ class FTLAPI(): return response = self.POST("/api/auth", {"password": password}) + if "error" in response: + raise Exception("FTL returned error: " + json.dumps(response["error"])) if 'session' not in response: raise Exception("FTL returned invalid response item") self.session = response["session"] diff --git a/test/api/test-rate-limit.py b/test/api/test-rate-limit.py new file mode 100644 index 00000000..781a2d73 --- /dev/null +++ b/test/api/test-rate-limit.py @@ -0,0 +1,24 @@ +# Script that sends a number of randomly generated passwords to the +# /api/auth endpoint checking that rate limiting is enforced +import random +import string +from libs.FTLAPI import FTLAPI + +if __name__ == "__main__": + # Create FTLAPI object + ftl = FTLAPI("http://127.0.0.1:8080") + + # Try to login with random passwords + for i in range(0, 100): + pw = "".join(random.choices(string.printable, k=random.randint(1, 64))) + try: + ftl.login(pw) + except Exception as e: + if "too_many_requests" in str(e): + print("Rate-limited on attempt no. " + str(i)) + exit(0) + else: + print("Unexpected error: " + str(e)) + exit(1) + print("Rate-limiting was not enforced") + exit(1) diff --git a/test/run.sh b/test/run.sh index 6f6454ed..e2563e82 100755 --- a/test/run.sh +++ b/test/run.sh @@ -130,6 +130,11 @@ kill "$(pidof pihole-FTL)" # Restore umask umask "$OLDUMASK" +# Run performance tests +if ! su pihole -s /bin/sh -c "/home/pihole/pihole-FTL --perf"; then + echo "pihole-FTL --perf failed to start" +fi + # Remove copied file rm /home/pihole/pihole-FTL