Merge pull request #1646 from pi-hole/new/performance_test

Add performance tests and prevent online-brute forcing
This commit is contained in:
DL6ER 2023-10-08 14:33:01 +02:00 committed by GitHub
commit d75599c7ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 311 additions and 14 deletions

View File

@ -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);

View File

@ -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
const bool verfied = verify_password(elem->valuestring, pwhash);
const bool verfied = verify_password(elem->valuestring, pwhash, false) == PASSWORD_CORRECT;
if(!verfied)
{

View File

@ -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:

View File

@ -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

View File

@ -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);

View File

@ -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
const bool verfied = verify_password(value, pwhash);
const bool verfied = verify_password(value, pwhash, false) == PASSWORD_CORRECT;
if(!verfied)
{

View File

@ -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;
}

View File

@ -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

View File

@ -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"]

View File

@ -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)

View File

@ -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