From f70ce379de6261733dc71279c76fc76a210285e1 Mon Sep 17 00:00:00 2001 From: Daniel Roberson Date: Sun, 13 Sep 2026 07:44:32 -0700 Subject: initial commit --- .clang-format | 12 ++ LICENSE | 21 +++ Makefile | 83 ++++++++++ README.md | 290 +++++++++++++++++++++++++++++++++ bloom.c | 439 ++++++++++++++++++++++++++++++++++++++++++++++++++ bloom.h | 76 +++++++++ mmh3.c | 105 ++++++++++++ mmh3.h | 11 ++ pam-configs/pam_bloom | 7 + pam_bloom.c | 246 ++++++++++++++++++++++++++++ pam_bloom_create.c | 236 +++++++++++++++++++++++++++ rockyou.txt.bloom | Bin 0 -> 25780030 bytes test_mmh3.c | 173 ++++++++++++++++++++ 13 files changed, 1699 insertions(+) create mode 100644 .clang-format create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 bloom.c create mode 100644 bloom.h create mode 100644 mmh3.c create mode 100644 mmh3.h create mode 100644 pam-configs/pam_bloom create mode 100644 pam_bloom.c create mode 100644 pam_bloom_create.c create mode 100644 rockyou.txt.bloom create mode 100644 test_mmh3.c diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..99ce97f --- /dev/null +++ b/.clang-format @@ -0,0 +1,12 @@ +BasedOnStyle: LLVM + +IndentWidth: 4 +TabWidth: 4 +UseTab: Never + +ColumnLimit: 100 + +BreakBeforeBraces: Attach + +BinPackArguments: true +BinPackParameters: true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8d71747 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Daniel Roberson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..35fe1e1 --- /dev/null +++ b/Makefile @@ -0,0 +1,83 @@ +CC ?= cc + +CFLAGS ?= -O2 +CFLAGS += -Wall -Wextra -Wpedantic +CFLAGS += -fPIC +CFLAGS += -fstack-protector-strong +CFLAGS += -D_FORTIFY_SOURCE=2 + +LDFLAGS += -Wl,-z,relro,-z,now + +PAM_LDLIBS = -lpam -lm +CREATE_LDLIBS = -lm + +PAM_TARGET = pam_bloom.so +CREATE_TARGET = pam_bloom_create + +PREFIX ?= /usr +BINDIR ?= $(PREFIX)/bin + +MULTIARCH := $(shell dpkg-architecture -qDEB_HOST_MULTIARCH 2>/dev/null) + +ifeq ($(MULTIARCH),) +PAMDIR ?= $(PREFIX)/lib/security +else +PAMDIR ?= $(PREFIX)/lib/$(MULTIARCH)/security +endif + +FILTERDIR ?= /etc/security/pam_bloom/filters.d +DESTDIR ?= + +COMMON_OBJS = bloom.o mmh3.o +PAM_OBJS = pam_bloom.o $(COMMON_OBJS) +CREATE_OBJS = pam_bloom_create.o $(COMMON_OBJS) + +FILTERS = rockyou.txt.bloom + +PAM_CONFIG_DIR ?= /usr/share/pam-configs +PAM_CONFIG = pam-configs/pam_bloom + +.PHONY: all clean install uninstall + +all: $(PAM_TARGET) $(CREATE_TARGET) + +$(PAM_TARGET): $(PAM_OBJS) + $(CC) $(LDFLAGS) -shared -o $@ $(PAM_OBJS) $(PAM_LDLIBS) + +$(CREATE_TARGET): $(CREATE_OBJS) + $(CC) $(LDFLAGS) -o $@ $(CREATE_OBJS) $(CREATE_LDLIBS) + +pam_bloom.o: pam_bloom.c bloom.h + $(CC) $(CFLAGS) -c -o $@ $< + +pam_bloom_create.o: pam_bloom_create.c bloom.h + $(CC) $(CFLAGS) -c -o $@ $< + +bloom.o: bloom.c bloom.h mmh3.h + $(CC) $(CFLAGS) -c -o $@ $< + +mmh3.o: mmh3.c mmh3.h + $(CC) $(CFLAGS) -c -o $@ $< + +install: all + install -d "$(DESTDIR)$(BINDIR)" + install -m 0755 $(CREATE_TARGET) "$(DESTDIR)$(BINDIR)/$(CREATE_TARGET)" + + install -d "$(DESTDIR)$(PAMDIR)" + install -m 0755 $(PAM_TARGET) "$(DESTDIR)$(PAMDIR)/$(PAM_TARGET)" + + install -d -m 0755 "$(DESTDIR)$(FILTERDIR)" + install -m 0644 $(FILTERS) "$(DESTDIR)$(FILTERDIR)/" + + install -d "$(DESTDIR)$(PAM_CONFIG_DIR)" + install -m 0644 $(PAM_CONFIG) \ + "$(DESTDIR)$(PAM_CONFIG_DIR)/pam_bloom" + +uninstall: + rm -f "$(DESTDIR)$(BINDIR)/$(CREATE_TARGET)" + rm -f "$(DESTDIR)$(PAMDIR)/$(PAM_TARGET)" + rm -f "$(DESTDIR)$(PAM_CONFIG_DIR)/pam_bloom" + +clean: + rm -f $(PAM_TARGET) $(CREATE_TARGET) \ + $(PAM_OBJS) pam_bloom_create.o diff --git a/README.md b/README.md new file mode 100644 index 0000000..898b5fa --- /dev/null +++ b/README.md @@ -0,0 +1,290 @@ +# pam_bloom + +`pam_bloom` is a Linux PAM password module that rejects passwords +found in one or more Bloom filters. + +It can be used to prevent users from choosing passwords found in known +password lists such as RockYou, organization-specific password lists, +or other administrator-provided dictionaries. + +The package includes: + +* `pam_bloom.so` — PAM module +* `pam_bloom_create` — utility for creating Bloom filters from wordlists + + +WARNING: This software modifies PAM configuration. There is always +risk involved with re-configuring the system's authentication system +such as locking legitimate users out. Administrators are responsible +for testing their PAM configuration before deployment. + +## Requirements + +On Debian or Ubuntu, install the compiler, PAM development files, and +other build tools: + +```sh +sudo apt update +sudo apt install build-essential libpam0g-dev +``` + +The Makefile also uses `dpkg-architecture` to determine Debian's +multiarch PAM module directory. This is normally already available on +Debian systems. + +## Build + +Clone or unpack the source and run: + +```sh +make +``` + +This builds: + +```text +pam_bloom.so +pam_bloom_create +``` + +Install them with: + +```sh +sudo make install +``` + +On a typical x86-64 Debian system this installs: + +```text +/usr/bin/pam_bloom_create +/usr/lib/x86_64-linux-gnu/security/pam_bloom.so +/etc/security/pam_bloom/filters.d/ +``` + +Finally, run `pam-auth-update` to enable `pam_bloom`: + +```sh +sudo pam-auth-update +``` + +## Create a Bloom filter + +`pam_bloom_create` creates a Bloom filter from a wordlist: + +```sh +pam_bloom_create WORDLIST OUTPUT NAME +``` + +For example: + +```sh +pam_bloom_create rockyou.txt rockyou.bloom rockyou +``` + +The default false-positive rate is `0.001`. + +A different false-positive rate can be selected with `-p`: + +```sh +pam_bloom_create -p 0.000001 rockyou.txt rockyou.bloom rockyou +``` + +Install the resulting filter: + +```sh +sudo cp rockyou.bloom /etc/security/pam_bloom/filters.d/ +``` + +The PAM module checks every `.bloom` file in this directory. A +password matching any installed filter is rejected. + +## Configure PAM + +**Be careful when modifying PAM configuration. An incorrect PAM +configuration can prevent authentication or password changes. Keep a +root session open while testing changes.** + +On Debian and Ubuntu, edit: + +```text +/etc/pam.d/common-password +``` + +Add `pam_bloom.so` before the `pam_unix.so` password entry. + +For example: + +```text +password requisite pam_bloom.so +password [success=1 default=ignore] pam_unix.so obscure yescrypt use_authtok +password requisite pam_deny.so +password required pam_permit.so +``` + +The important points are that `pam_bloom.so` runs before `pam_unix.so` +and that `pam_unix.so` uses `use_authtok`. + +`pam_bloom` obtains the new password through PAM, checks it against +the installed Bloom filters, and rejects it if a match is +found. `pam_unix` then uses the same password token. + +## Test + +Change a test user's password normally: + +```sh +passwd +``` + +Try selecting a password contained in one of the installed wordlists. + +`pam_bloom` should reject the password and identify the Bloom filter +that matched it. + +A password that does not match any filter is passed on to the rest of +the PAM password stack. + +## Custom filter directory + +The default filter directory is: + +```text +/etc/security/pam_bloom/filters.d +``` + +A different directory can be configured with the PAM `dir=` option: + +```text +password requisite pam_bloom.so dir=/srv/security/bloom +``` + +The directory and filter files must satisfy `pam_bloom`'s ownership +and permission checks. + +## Included RockYou filter + +`pam_bloom` includes a pre-built Bloom filter generated from the +RockYou password list: + +```text +rockyou.txt.bloom +``` + +The supplied filter was created with a false-positive rate of: + +```text +0.001 +``` + +This filter is installed to +`/etc/security/pam_bloom/filters.d/rockyou.txt.bloom` by default. + +Because this is a Bloom filter, there is a small probability of false +positives. With the supplied filter, the configured false-positive +probability is 0.001 (approximately 0.1%). In the 1 in 1000 chance +that there is a false-positive, the user will simply have to choose +another password. + +The original RockYou plaintext wordlist is not required at runtime. + +## Creating your own filters + +Additional Bloom filters can be created with: + +```sh +pam_bloom_create WORDLIST OUTPUT NAME +``` + +For example: + +```sh +pam_bloom_create passwords.txt passwords.bloom passwords +``` + +The default false-positive rate is `0.001`, the same value used for +the supplied RockYou filter. + +A different false-positive rate can be selected with `-p`: + +```sh +pam_bloom_create -p 0.000001 passwords.txt passwords.bloom passwords +``` + +Install the resulting filter by copying it into the filter directory: + +```sh +sudo cp passwords.bloom /etc/security/pam_bloom/filters.d/ +``` + +Multiple `.bloom` files may be installed at the same time. A password +matching any installed filter is rejected. + + + +## Adding multiple filters + +Multiple filters can be installed simply by copying additional +`.bloom` files into the filter directory: + +```text +/etc/security/pam_bloom/filters.d/rockyou.bloom +/etc/security/pam_bloom/filters.d/company.bloom +/etc/security/pam_bloom/filters.d/banned-passwords.bloom +``` + +If the candidate password matches **any** filter, it is rejected. + +## Removing a filter + +Remove the corresponding `.bloom` file: + +```sh +sudo rm /etc/security/pam_bloom/filters.d/rockyou.bloom +``` + +No rebuild of the PAM module is necessary. + +## Uninstall + +uninstall the binaries: + +```sh +sudo make uninstall +``` + +`make uninstall` deliberately does not remove: + +```text +/etc/security/pam_bloom/filters.d/ +``` + +or any Bloom filters stored there. Administrator-created filters are +preserved. + +Finally, update PAM configuration to remove `pam_bloom`: + +```sh +sudo pam-auth-update +``` + +Alternatively, edit `/etc/pam.d/common-password` to remove `pam_bloom` + +## Security + +Bloom filters can produce false positives. The false-positive +probability can be controlled when creating a filter with +`pam_bloom_create -p`. + +The PAM module treats installed Bloom filters as trusted +administrative data. Filter directories and files are checked for +appropriate ownership and permissions before they are used. + +Malformed, inaccessible, or otherwise invalid filters cause the +password check to fail closed rather than silently bypassing the +password policy. + +## Murmur3 + +The mmh3 implementation by Austin Appleby was referenced when +implementing this. Appleby's implementation was released under the +public domain. diff --git a/bloom.c b/bloom.c new file mode 100644 index 0000000..bf57819 --- /dev/null +++ b/bloom.c @@ -0,0 +1,439 @@ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "mmh3.h" +#include "bloom.h" + +static const char *const bloom_errors[] = { + "Success", + "Out of memory", + "Unable to open file", + "Unable to read file", + "Unable to write to file", + "fstat() failure", + "Invalid file format" +}; + +static const uint8_t bloom_magic[8] = { '!', 'b', 'l', 'o', 'o', 'm', 'f', '!' }; + +static bool +ideal_size(size_t expected, double accuracy, size_t *result) +{ + double bits = -((double)expected * log(accuracy)) / (log(2.0) * log(2.0)); + + bits = ceil(bits); + + if (!isfinite(bits) || bits < 1.0 || bits > (double)SIZE_MAX) { + return false; + } + + *result = (size_t)bits; + return true; +} + + +bloom_error_t bloom_init(bloomfilter *bf, const size_t expected, const double accuracy) { + if (!bf || expected == 0 || !isfinite(accuracy) || accuracy <= 0.0 || accuracy >= 1.0) { + return BF_INVALIDFILE; + } + + float stored_accuracy = (float)accuracy; + + if (!isfinite(stored_accuracy) || stored_accuracy <= 0.0f || stored_accuracy >= 1.0f) { + return BF_INVALIDFILE; + } + + if (!ideal_size(expected, accuracy, &bf->size)) { + return BF_INVALIDFILE; + } + + bf->bitmap_size = bf->size / 8 + ((bf->size % 8) != 0); + + double hashes = ((double)bf->size / (double)expected) * log(2.0); + + if (!isfinite(hashes) || hashes < 1.0 || hashes > (double)BLOOM_MAX_HASHCOUNT) { + return BF_INVALIDFILE; + } + + bf->hashcount = (size_t)llround(hashes); + + bf->expected = expected; + bf->accuracy = stored_accuracy; + + snprintf(bf->name, sizeof(bf->name), "DEFAULT"); + + bf->bitmap = calloc(bf->bitmap_size, 1); + + if (!bf->bitmap) { + return BF_OUTOFMEMORY; + } + + return BF_SUCCESS; +} + +void +bloom_destroy(bloomfilter *bf) { + if (bf->bitmap) { + free(bf->bitmap); + bf->bitmap = NULL; + } +} + + +// lookup table for bloom_saturation() and bloom_saturation_count() +static const uint8_t +bit_count_table[256] = { + 0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4, + 1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5, + 1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5, + 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, + 1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5, + 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, + 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, + 3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7, + 1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5, + 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, + 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, + 3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7, + 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, + 3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7, + 3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7, + 4, 5, 5, 6, 5, 6, 6, 7, 5, 6, 6, 7, 6, 7, 7, 8 +}; + + +size_t +bloom_saturation_count(const bloomfilter *bf) { + size_t count = 0; + + for (size_t i = 0; i < bf->bitmap_size; i++) { + count += bit_count_table[bf->bitmap[i]]; + } + + return count; +} + +float +bloom_saturation(const bloomfilter *bf) { + size_t set_bits = bloom_saturation_count(bf); + + return (float)set_bits / (float)bf->size * 100.0f; +} + + +void +bloom_add(bloomfilter *bf, const void *element, const size_t len) +{ + uint64_t hash[2]; + + mmh3_128(element, len, 0, hash); + + for (size_t i = 0; i < bf->hashcount; i++) { + uint64_t result = (hash[0] + i * hash[1]) % bf->size; + + uint64_t byte_position = result / 8; + uint8_t bit_position = result % 8; + + bf->bitmap[byte_position] |= (0x01 << bit_position); + } +} + +bool +bloom_set_name(bloomfilter *bf, const char *name) +{ + size_t len = strlen(name); + + if (!bloom_valid_filter_name((const uint8_t *)name, len + 1)) + return false; + + memcpy(bf->name, name, len + 1); + return true; +} + +static bloom_error_t +bloom_write_file(const bloomfilter *bf, FILE *fp) +{ + bloomfilter_file bff = {0}; + + memcpy(bff.magic, bloom_magic, sizeof(bff.magic)); + + bff.version = BLOOM_FILE_VERSION; + bff.size = bf->size; + bff.hashcount = bf->hashcount; + bff.bitmap_size = bf->bitmap_size; + bff.expected = bf->expected; + bff.accuracy = bf->accuracy; + + memcpy(bff.name, bf->name, sizeof(bff.name)); + + if (fwrite(&bff, sizeof(bff), 1, fp) != 1) { + return BF_FWRITE; + } + + if (fwrite(bf->bitmap, 1, bf->bitmap_size, fp) != bf->bitmap_size) { + return BF_FWRITE; + } + + return BF_SUCCESS; +} + +bloom_error_t +bloom_save(const bloomfilter *bf, const char *path) +{ + bloom_error_t result; + FILE *fp = NULL; + int fd = -1; + + size_t path_len = strlen(path); + + if (path_len > SIZE_MAX - sizeof(".tmp.XXXXXX")) { + return BF_FOPEN; + } + + char *tmp_path = malloc(path_len + sizeof(".tmp.XXXXXX")); + if (tmp_path == NULL) { + return BF_OUTOFMEMORY; + } + + snprintf(tmp_path, path_len + sizeof(".tmp.XXXXXX"), "%s.tmp.XXXXXX", path); + + fd = mkstemp(tmp_path); + if (fd < 0) { + free(tmp_path); + return BF_FOPEN; + } + + fp = fdopen(fd, "wb"); + if (fp == NULL) { + close(fd); + unlink(tmp_path); + free(tmp_path); + return BF_FOPEN; + } + + result = bloom_write_file(bf, fp); + + if (result != BF_SUCCESS) + goto fail; + + if (fflush(fp) != 0) { + result = BF_FWRITE; + goto fail; + } + + if (fsync(fd) != 0) { + result = BF_FWRITE; + goto fail; + } + + if (fclose(fp) != 0) { + fp = NULL; + result = BF_FWRITE; + goto fail_closed; + } + + fp = NULL; + + if (rename(tmp_path, path) != 0) { + result = BF_FWRITE; + goto fail_closed; + } + + free(tmp_path); + return BF_SUCCESS; + +fail: + fclose(fp); + fp = NULL; + +fail_closed: + unlink(tmp_path); + free(tmp_path); + + return result; +} + +const char * +bloom_strerror(bloom_error_t error) { + if (error < 0 || error >= BF_ERRORCOUNT) { + return "Unknown error"; + } + + return bloom_errors[error]; +} + +bool +bloom_valid_filter_name(const uint8_t *name, size_t size) +{ + size_t len = 0; + + while (len < size && name[len] != '\0') { + len++; + } + + if (len == 0 || len == size) { + return false; + } + + for (size_t i = 0; i < len; i++) { + uint8_t c = name[i]; + + if (!((c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == ' ' || c == '-' || c == '_' || c == '.')) { + return false; + } + } + + return true; +} + +static bloom_error_t +validate_mapped_header(const bloomfilter_file *header, size_t file_size) +{ + if (memcmp(header->magic, bloom_magic, sizeof(header->magic)) != 0) { + return BF_INVALIDFILE; + } + + if (header->version != BLOOM_FILE_VERSION) return BF_INVALIDFILE; + if (header->size == 0) return BF_INVALIDFILE; + if (header->hashcount == 0) return BF_INVALIDFILE; + + /* + * Bound the amount of work for each lookup. Legitimate Bloom + * filters should require nowhere near this many hashes. + */ + if (header->hashcount > BLOOM_MAX_HASHCOUNT) { + return BF_INVALIDFILE; + } + + /* + * Compute ceil(size / 8) without doing size + 7, + * which could overflow. + */ + uint64_t expected_bitmap_size = header->size / 8 + ((header->size % 8) != 0); + if (header->bitmap_size != expected_bitmap_size) { + return BF_INVALIDFILE; + } + + /* + * Make sure converting bitmap_size to size_t is safe. + */ + if (header->bitmap_size > SIZE_MAX) { + return BF_INVALIDFILE; + } + + size_t bitmap_size = (size_t)header->bitmap_size; + + /* + * Avoid overflow in sizeof(header) + bitmap_size + */ + if (bitmap_size > SIZE_MAX - sizeof(bloomfilter_file)) { + return BF_INVALIDFILE; + } + + if (sizeof(bloomfilter_file) + bitmap_size != file_size) { + return BF_INVALIDFILE; + } + + /* + * Validate filter name. Shouldn't contain many special + * characters. + */ + if (!bloom_valid_filter_name(header->name, sizeof(header->name))) { + return BF_INVALIDFILE; + } + + if (!isfinite(header->accuracy)) { + return BF_INVALIDFILE; + } + + if (header->accuracy <= 0.0f || header->accuracy >= 1.0f) { + return BF_INVALIDFILE; + } + + if (header->expected == 0) { + return BF_INVALIDFILE; + } + + return BF_SUCCESS; +} + +bloom_error_t +bloom_map_fd(bloomfilter_mapped *bf, int fd) +{ + struct stat st; + + if (bf == NULL) return BF_INVALIDFILE; + if (fstat(fd, &st) < 0) return BF_FSTAT; + if (st.st_size < 0) return BF_INVALIDFILE; + if ((uintmax_t)st.st_size > SIZE_MAX) return BF_INVALIDFILE; + + size_t file_size = (size_t)st.st_size; + if (file_size < sizeof(bloomfilter_file)) return BF_INVALIDFILE; + + void *mapping = mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, fd, 0); + if (mapping == MAP_FAILED) { + return BF_FREAD; + } + + const bloomfilter_file *header = (const bloomfilter_file *)mapping; + + bloom_error_t err = validate_mapped_header(header, file_size); + if (err != BF_SUCCESS) { + munmap(mapping, file_size); + return err; + } + + const uint8_t *bitmap = (const uint8_t *)mapping + sizeof(bloomfilter_file); + + bf->header = header; + bf->bitmap = bitmap; + bf->mapping = mapping; + bf->mapping_size = file_size; + + return BF_SUCCESS; +} + +void +bloom_unmap(bloomfilter_mapped *bf) +{ + if (bf->mapping) { + munmap(bf->mapping, bf->mapping_size); + } + + bf->header = NULL; + bf->bitmap = NULL; + bf->mapping = NULL; + bf->mapping_size = 0; +} + +bool +bloom_mapped_lookup(const bloomfilter_mapped *bf, const void *element, const size_t len) +{ + uint64_t hash[2]; + + mmh3_128(element, len, 0, hash); + + for (size_t i = 0; i < bf->header->hashcount; i++) { + uint64_t result = (hash[0] + i * hash[1]) % bf->header->size; + + uint64_t byte_position = result / 8; + uint8_t bit_position = result % 8; + + if ((bf->bitmap[byte_position] & (0x01 << bit_position)) == 0) { + return false; + } + } + + return true; +} diff --git a/bloom.h b/bloom.h new file mode 100644 index 0000000..3b95f9b --- /dev/null +++ b/bloom.h @@ -0,0 +1,76 @@ + +#ifndef BLOOM_H +#define BLOOM_H + +#include +#include +#include + +#define BLOOM_MAX_NAME_LENGTH 255 +#define BLOOM_MAX_HASHCOUNT 1024 + +typedef enum { + BF_SUCCESS = 0, + BF_OUTOFMEMORY, + BF_FOPEN, + BF_FREAD, + BF_FWRITE, + BF_FSTAT, + BF_INVALIDFILE, + // ERRORCOUNT is used as a counter. do not add anything below this line. + BF_ERRORCOUNT +} bloom_error_t; + + +typedef struct { + size_t size; /**< Size of the Bloom filter in bits */ + size_t hashcount; /**< Number of hashes performed per element */ + size_t bitmap_size; /**< Size of the bitmap in bytes */ + size_t expected; /**< Expected capacity of the filter */ + float accuracy; /**< Desired margin of error */ + char name[BLOOM_MAX_NAME_LENGTH + 1]; + uint8_t *bitmap; /**< Pointer to the bitmap of the filter */ +} bloomfilter; + +#define BLOOM_FILE_VERSION 1 + +typedef struct { + uint8_t magic[8]; + uint32_t version; + + uint8_t name[BLOOM_MAX_NAME_LENGTH + 1]; + + uint64_t size; + uint64_t hashcount; + uint64_t bitmap_size; + uint64_t expected; + + float accuracy; +} bloomfilter_file; + + +typedef struct { + const bloomfilter_file *header; + const uint8_t *bitmap; + + void *mapping; + size_t mapping_size; +} bloomfilter_mapped; + +/* function declarations + */ +bloom_error_t bloom_init(bloomfilter *, const size_t, const double); +void bloom_destroy(bloomfilter *); +bool bloom_set_name(bloomfilter *, const char *); +const char *bloom_strerror(const bloom_error_t); +bloom_error_t bloom_save(const bloomfilter *, const char *); +size_t bloom_saturation_count(const bloomfilter *); +float bloom_saturation(const bloomfilter *); + +void bloom_add(bloomfilter *, const void *, const size_t); +bloom_error_t bloom_map_fd(bloomfilter_mapped *, int); +void bloom_unmap(bloomfilter_mapped *); +bool bloom_valid_name(const char *name); + +bool bloom_mapped_lookup(const bloomfilter_mapped *, const void *, const size_t); +#endif /* BLOOM_H */ diff --git a/mmh3.c b/mmh3.c new file mode 100644 index 0000000..dbbb09a --- /dev/null +++ b/mmh3.c @@ -0,0 +1,105 @@ +/* mmh3.c + */ +#include +#include + +#include "mmh3.h" + + +static uint64_t +load64_le(const uint8_t *p) +{ + return ((uint64_t)p[0]) | + ((uint64_t)p[1] << 8) | + ((uint64_t)p[2] << 16) | + ((uint64_t)p[3] << 24) | + ((uint64_t)p[4] << 32) | + ((uint64_t)p[5] << 40) | + ((uint64_t)p[6] << 48) | + ((uint64_t)p[7] << 56); +} + +/* mmh3_128() -- calculate 128 bit mmh3 hash. + * + * Args: + * key + * len + * seed + * out + * + * Returns: + * Nothing. Output is written to 'out'. + */ +void mmh3_128(const void *key, const size_t len, const uint64_t seed, uint64_t *out) { + const uint64_t c1 = 0x87c37b91114253d5; + const uint64_t c2 = 0x4cf5ad432745937f; + const uint8_t *data = (const uint8_t *)key; + const size_t nblocks = len / 16; + uint64_t h1 = seed; + uint64_t h2 = seed; + + // Process the data in 128-bit chunks + for (size_t i = 0; i < nblocks; i++) { + const uint8_t *block = data + i * 16; + + uint64_t k1 = load64_le(block); + uint64_t k2 = load64_le(block + 8); + + k1 *= c1; k1 = (k1 << 31) | (k1 >> 33); k1 *= c2; h1 ^= k1; + h1 = (h1 << 27) | (h1 >> 37); h1 += h2; h1 = h1 * 5 + 0x52dce729; + + k2 *= c2; k2 = (k2 << 33) | (k2 >> 31); k2 *= c1; h2 ^= k2; + h2 = (h2 << 31) | (h2 >> 33); h2 += h1; h2 = h2 * 5 + 0x38495ab5; + } + + // Process the remaining bytes + const uint8_t *tail = (const uint8_t *)(data + nblocks * 16); + uint64_t k1 = 0; + uint64_t k2 = 0; + + switch (len & 15) { + case 15: k2 ^= ((uint64_t)tail[14]) << 48; /* fall through */ + case 14: k2 ^= ((uint64_t)tail[13]) << 40; /* fall through */ + case 13: k2 ^= ((uint64_t)tail[12]) << 32; /* fall through */ + case 12: k2 ^= ((uint64_t)tail[11]) << 24; /* fall through */ + case 11: k2 ^= ((uint64_t)tail[10]) << 16; /* fall through */ + case 10: k2 ^= ((uint64_t)tail[9]) << 8; /* fall through */ + case 9: k2 ^= ((uint64_t)tail[8]) << 0; /* fall through */ + k2 *= c2; k2 = (k2 << 33) | (k2 >> 31); k2 *= c1; h2 ^= k2; + /* fall through */ + case 8: k1 ^= ((uint64_t)tail[7]) << 56; /* fall through */ + case 7: k1 ^= ((uint64_t)tail[6]) << 48; /* fall through */ + case 6: k1 ^= ((uint64_t)tail[5]) << 40; /* fall through */ + case 5: k1 ^= ((uint64_t)tail[4]) << 32; /* fall through */ + case 4: k1 ^= ((uint64_t)tail[3]) << 24; /* fall through */ + case 3: k1 ^= ((uint64_t)tail[2]) << 16; /* fall through */ + case 2: k1 ^= ((uint64_t)tail[1]) << 8; /* fall through */ + case 1: k1 ^= ((uint64_t)tail[0]) << 0; /* fall through */ + k1 *= c1; k1 = (k1 << 31) | (k1 >> 33); k1 *= c2; h1 ^= k1; + } + + // Finalization + h1 ^= len; + h2 ^= len; + + h1 += h2; + h2 += h1; + + h1 ^= h1 >> 33; + h1 *= 0xff51afd7ed558ccd; + h1 ^= h1 >> 33; + h1 *= 0xc4ceb9fe1a85ec53; + h1 ^= h1 >> 33; + + h2 ^= h2 >> 33; + h2 *= 0xff51afd7ed558ccd; + h2 ^= h2 >> 33; + h2 *= 0xc4ceb9fe1a85ec53; + h2 ^= h2 >> 33; + + h1 += h2; + h2 += h1; + + out[0] = h1; + out[1] = h2; +} diff --git a/mmh3.h b/mmh3.h new file mode 100644 index 0000000..9ec6326 --- /dev/null +++ b/mmh3.h @@ -0,0 +1,11 @@ +/* mmh3.h + */ +#ifndef MMH3_H +#define MMH3_H + +#include +#include + +void mmh3_128(const void *, const size_t, const uint64_t, uint64_t *); + +#endif /* MMH3_H */ diff --git a/pam-configs/pam_bloom b/pam-configs/pam_bloom new file mode 100644 index 0000000..5bff319 --- /dev/null +++ b/pam-configs/pam_bloom @@ -0,0 +1,7 @@ +Name: pam_bloom password filter +Default: yes +Priority: 1024 + +Password-Type: Primary +Password: + requisite pam_bloom.so diff --git a/pam_bloom.c b/pam_bloom.c new file mode 100644 index 0000000..7233ed8 --- /dev/null +++ b/pam_bloom.c @@ -0,0 +1,246 @@ + +#define PAM_SM_PASSWORD + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bloom.h" + +#define PAM_BLOOM_FILTER_DIR "/etc/security/pam_bloom/filters.d" + +static int +has_bloom_extension(const char *name) +{ + size_t len = strlen(name); + + return len > 6 && + strcmp(name + len - 6, ".bloom") == 0; +} + +static int +validate_filter_fd(pam_handle_t *pamh, int fd, const char *filename) +{ + struct stat st; + + if (fstat(fd, &st) < 0) { + pam_syslog(pamh, LOG_ERR, "unable to stat Bloom filter %s", filename); + return -1; + } + + if (!S_ISREG(st.st_mode)) { + pam_syslog(pamh, LOG_ERR, "Bloom filter %s is not a regular file", filename); + return -1; + } + + if (st.st_uid != 0) { + pam_syslog(pamh, LOG_ERR, "Bloom filter %s is not owned by root", filename); + return -1; + } + + if (st.st_mode & (S_IWGRP | S_IWOTH)) { + pam_syslog(pamh, LOG_ERR, "Bloom filter %s is writable by group or others", filename); + return -1; + } + + return 0; +} + +static int +check_filter_fd(pam_handle_t *pamh, int fd, const char *filename, const char *password) +{ + bloomfilter_mapped bf = {0}; + + bloom_error_t err = bloom_map_fd(&bf, fd); + + if (err != BF_SUCCESS) { + pam_syslog(pamh, LOG_ERR, + "unable to map Bloom filter %s: %s", filename, bloom_strerror(err)); + return -1; + } + + bool found = bloom_mapped_lookup(&bf, password, strlen(password)); + + if (found) { + pam_error(pamh, + "Password rejected: it matches the %s filter.", + (const char *)bf.header->name); + } + + bloom_unmap(&bf); + + return found ? 1 : 0; +} + +static int +validate_filter_directory(pam_handle_t *pamh, int dirfd, const char *directory) +{ + struct stat st; + + if (fstat(dirfd, &st) < 0) { + pam_syslog(pamh, LOG_ERR, "unable to stat Bloom filter directory %s", directory); + return -1; + } + + if (!S_ISDIR(st.st_mode)) { + pam_syslog(pamh, LOG_ERR, "Bloom filter path %s is not a directory", directory); + return -1; + } + + if (st.st_uid != 0) { + pam_syslog(pamh, LOG_ERR, "Bloom filter directory %s is not owned by root", directory); + return -1; + } + + if (st.st_mode & (S_IWGRP | S_IWOTH)) { + pam_syslog(pamh, LOG_ERR, + "Bloom filter directory %s is writable by group or others", directory); + return -1; + } + + return 0; +} + +static int +check_filter_directory(pam_handle_t *pamh, const char *directory, const char *password) +{ + int dirfd = open(directory, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + + if (dirfd < 0) { + pam_syslog(pamh, LOG_ERR, "unable to open Bloom filter directory %s", directory); + return -1; + } + + if (validate_filter_directory(pamh, dirfd, directory) < 0) { + close(dirfd); + return -1; + } + + DIR *dir = fdopendir(dirfd); + + if (dir == NULL) { + close(dirfd); + pam_syslog(pamh, LOG_ERR, "unable to read Bloom filter directory %s", directory); + return -1; + } + + bool saw_filter = false; + int result = 0; + struct dirent *entry; + + for (;;) { + errno = 0; + entry = readdir(dir); + + if (entry == NULL) { + if (errno != 0) { + pam_syslog(pamh, LOG_ERR, "error reading Bloom filter directory %s", directory); + result = -1; + } + + break; + } + + if (!has_bloom_extension(entry->d_name)) { + continue; + } + + saw_filter = true; + + int fd = openat(dirfd, entry->d_name, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + + if (fd < 0) { + pam_syslog(pamh, LOG_ERR, "unable to open Bloom filter %s", entry->d_name); + result = -1; + break; + } + + if (validate_filter_fd(pamh, fd, entry->d_name) < 0) { + close(fd); + result = -1; + break; + } + + result = check_filter_fd(pamh, fd, entry->d_name, password); + + close(fd); + + if (result != 0) { + break; + } + } + + if (result == 0 && !saw_filter) { + pam_syslog(pamh, LOG_WARNING, "no Bloom filters found in directory %s", directory); + } + + + if (closedir(dir) != 0) { + pam_syslog(pamh, LOG_ERR, "error closing Bloom filter directory %s", directory); + + if (result == 0) { + result = -1; + } + } + + return result; +} + +PAM_EXTERN int +pam_sm_chauthtok(pam_handle_t *pamh, int flags, int argc, const char **argv) +{ + const char *filter_dir = PAM_BLOOM_FILTER_DIR; + + bool dir_seen = false; + + for (int i = 0; i < argc; i++) { + static const char prefix[] = "dir="; + + if (strncmp(argv[i], prefix, sizeof(prefix) - 1) != 0) { + pam_syslog(pamh, LOG_ERR, "pam_bloom: unknown option: %s", argv[i]); + return PAM_SERVICE_ERR; + } + + if (dir_seen) { + pam_syslog(pamh, LOG_ERR, "pam_bloom: duplicate dir= option"); + return PAM_SERVICE_ERR; + } + + dir_seen = true; + + const char *value = argv[i] + sizeof(prefix) - 1; + + if (*value == '\0') { + pam_syslog(pamh, LOG_ERR, "pam_bloom: empty dir= option"); + return PAM_SERVICE_ERR; + } + + filter_dir = value; + } + + if (!(flags & PAM_UPDATE_AUTHTOK)) { + return PAM_SUCCESS; + } + + const char *password = NULL; + + int rc = pam_get_authtok(pamh, PAM_AUTHTOK, &password, NULL); + + if (rc != PAM_SUCCESS || password == NULL) { + return PAM_AUTHTOK_ERR; + } + + int result = check_filter_directory(pamh, filter_dir, password); + if (result < 0) return PAM_SERVICE_ERR; + if (result > 0) return PAM_AUTHTOK_ERR; + + return PAM_SUCCESS; +} diff --git a/pam_bloom_create.c b/pam_bloom_create.c new file mode 100644 index 0000000..fb59a24 --- /dev/null +++ b/pam_bloom_create.c @@ -0,0 +1,236 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include + +#include "bloom.h" + + +static const size_t PAM_BLOOM_MAX_ENTRY_LENGTH = 4096; + + +static uint64_t +count_lines(FILE *fp) +{ + char *line = NULL; + size_t cap = 0; + uint64_t count = 0; + + while (getline(&line, &cap, fp) != -1) { + count++; + } + + free(line); + + if (ferror(fp)) { + perror("getline"); + exit(EXIT_FAILURE); + } + + return count; +} + +static size_t +trim_newline(char *line, size_t len) +{ + if (len > 0 && line[len - 1] == '\n') { + len--; + + if (len > 0 && line[len - 1] == '\r') { + len--; + } + } + + return len; +} + +static int +validate_entry(const char *line, size_t len, uint64_t line_number) +{ + if (len == 0) { + fprintf(stderr, "line %llu: empty entry\n", (unsigned long long)line_number); + return -1; + } + + if (len > PAM_BLOOM_MAX_ENTRY_LENGTH) { + fprintf( + stderr, + "line %llu: entry exceeds maximum length of %zu bytes\n", + (unsigned long long)line_number, + PAM_BLOOM_MAX_ENTRY_LENGTH + ); + + return -1; + } + + if (memchr(line, '\0', len) != NULL) { + fprintf(stderr, "line %llu: entry contains NUL byte\n", (unsigned long long)line_number); + return -1; + } + + return 0; +} + +int +main(int argc, char **argv) +{ + double fpr = 0.001; + int opt; + + while ((opt = getopt(argc, argv, "p:")) != -1) { + switch (opt) { + case 'p': { + char *end = NULL; + + errno = 0; + + double value = strtod(optarg, &end); + + if (errno != 0 || end == optarg || *end != '\0' || + !isfinite(value) || value <= 0.0 || value >= 1.0) { + fprintf(stderr, "invalid false-positive rate: %s\n", optarg); + return EXIT_FAILURE; + } + + fpr = value; + break; + } + + default: + fprintf(stderr, "usage: %s [-p FPR] WORDLIST OUTPUT NAME\n", argv[0]); + return EXIT_FAILURE; + } + } + + if (argc - optind != 3) { + fprintf(stderr, "usage: %s [-p FPR] WORDLIST OUTPUT NAME\n", argv[0]); + return EXIT_FAILURE; + } + + const char *input = argv[optind]; + const char *output = argv[optind + 1]; + const char *name = argv[optind + 2]; + + if (!bloom_valid_filter_name((const uint8_t *)name, strlen(name) + 1)) { + fprintf(stderr, "invalid filter name\n"); + return EXIT_FAILURE; + } + + FILE *fp = fopen(input, "rb"); + if (!fp) { + perror(input); + return EXIT_FAILURE; + } + + uint64_t count = count_lines(fp); + + if (count == 0) { + fprintf(stderr, "%s: empty wordlist\n", input); + fclose(fp); + return EXIT_FAILURE; + } + + printf("entries: %llu\n", (unsigned long long)count); + + if (fseeko(fp, 0, SEEK_SET) == -1) { + perror("fseeko"); + fclose(fp); + return EXIT_FAILURE; + } + + if (count > SIZE_MAX) { + fprintf(stderr, "%s: too many entries for this platform\n", input); + fclose(fp); + return EXIT_FAILURE; + } + + bloomfilter bf = {0}; + bloom_error_t err = bloom_init(&bf, (size_t)count, fpr); + + if (err != BF_SUCCESS) { + fprintf(stderr, "bloom_init: %s\n", bloom_strerror(err)); + fclose(fp); + return EXIT_FAILURE; + } + + if (!bloom_set_name(&bf, name)) { + fprintf(stderr, "filter name too long\n"); + bloom_destroy(&bf); + fclose(fp); + return EXIT_FAILURE; + } + + char *line = NULL; + size_t capacity = 0; + ssize_t nread; + + uint64_t inserted = 0; + uint64_t line_number = 0; + + while ((nread = getline(&line, &capacity, fp)) != -1) { + line_number++; + + size_t len = trim_newline(line, (size_t)nread); + + if (validate_entry(line, len, line_number) < 0) { + free(line); + bloom_destroy(&bf); + fclose(fp); + + return EXIT_FAILURE; + } + + bloom_add(&bf, line, len); + + inserted++; + } + + if (ferror(fp)) { + perror("getline"); + + free(line); + bloom_destroy(&bf); + fclose(fp); + + return EXIT_FAILURE; + } + + free(line); + fclose(fp); + + if (inserted != count) { + fprintf(stderr, + "input changed while processing (counted %llu, read %llu)\n", + (unsigned long long)count, + (unsigned long long)inserted); + + bloom_destroy(&bf); + return EXIT_FAILURE; + } + + err = bloom_save(&bf, output); + + if (err != BF_SUCCESS) { + fprintf(stderr, "bloom_save: %s\n", bloom_strerror(err)); + + bloom_destroy(&bf); + return EXIT_FAILURE; + } + + printf("filter: %s\n", name); + printf("elements: %llu\n", (unsigned long long)inserted); + printf("bits: %zu\n", bf.size); + printf("bytes: %zu\n", bf.bitmap_size); + printf("hashes: %zu\n", bf.hashcount); + printf("fpr: %.10g\n", fpr); + printf("saturation: %.2f%%\n", bloom_saturation(&bf)); + + bloom_destroy(&bf); + + return EXIT_SUCCESS; +} diff --git a/rockyou.txt.bloom b/rockyou.txt.bloom new file mode 100644 index 0000000..3f608a8 Binary files /dev/null and b/rockyou.txt.bloom differ diff --git a/test_mmh3.c b/test_mmh3.c new file mode 100644 index 0000000..8946f70 --- /dev/null +++ b/test_mmh3.c @@ -0,0 +1,173 @@ +#include +#include +#include +#include + +#include "mmh3.h" + +typedef struct { + const char *name; + const void *data; + size_t len; + uint64_t seed; + uint64_t expected0; + uint64_t expected1; +} test_vector; + +static int +test_mmh3_128_vector(const test_vector *test) +{ + uint64_t out[2] = {0, 0}; + + mmh3_128( + test->data, + test->len, + test->seed, + out + ); + + if (out[0] != test->expected0 || + out[1] != test->expected1) { + + fprintf( + stderr, + "FAIL: %s\n" + " expected: %016" PRIx64 " %016" PRIx64 "\n" + " actual: %016" PRIx64 " %016" PRIx64 "\n", + test->name, + test->expected0, + test->expected1, + out[0], + out[1] + ); + + return 1; + } + + printf("PASS: %s\n", test->name); + return 0; +} + +static int +test_mmh3_128_unaligned(void) +{ + static const char text[] = + "The quick brown fox jumps over the lazy dog"; + + uint8_t buffer[sizeof(text) + 1]; + + /* + * Force the input to begin at an address offset by one byte. + * This catches implementations that incorrectly cast arbitrary + * byte pointers to uint64_t * and perform aligned word reads. + */ + memcpy(buffer + 1, text, sizeof(text) - 1); + + const test_vector test = { + .name = "x64_128 unaligned input", + .data = buffer + 1, + .len = sizeof(text) - 1, + .seed = 0, + .expected0 = UINT64_C(0xe34bbc7bbc071b6c), + .expected1 = UINT64_C(0x7a433ca9c49a9347), + }; + + return test_mmh3_128_vector(&test); +} + + +int +main(void) +{ + static const char empty[] = ""; + static const char foo[] = "foo"; + static const char hello[] = "hello"; + static const char block16[] = "0123456789abcdef"; + static const char block17[] = "0123456789abcdefg"; + static const char quickfox[] = + "The quick brown fox jumps over the lazy dog"; + + static const test_vector tests[] = { + { + .name = "x64_128 empty, seed 0", + .data = empty, + .len = 0, + .seed = 0, + .expected0 = UINT64_C(0x0000000000000000), + .expected1 = UINT64_C(0x0000000000000000), + }, + { + .name = "x64_128 foo, seed 0", + .data = foo, + .len = sizeof(foo) - 1, + .seed = 0, + .expected0 = UINT64_C(0xe271865701f54561), + .expected1 = UINT64_C(0x7eaf87e42bba7d87), + }, + { + .name = "x64_128 hello, seed 0", + .data = hello, + .len = sizeof(hello) - 1, + .seed = 0, + .expected0 = UINT64_C(0xcbd8a7b341bd9b02), + .expected1 = UINT64_C(0x5b1e906a48ae1d19), + }, + { + .name = "x64_128 exactly 16 bytes", + .data = block16, + .len = sizeof(block16) - 1, + .seed = 0, + .expected0 = UINT64_C(0x4be06d94cf4ad1a7), + .expected1 = UINT64_C(0x87c35b5c63a708da), + }, + { + .name = "x64_128 17 bytes", + .data = block17, + .len = sizeof(block17) - 1, + .seed = 0, + .expected0 = UINT64_C(0x8e32612daa45f9de), + .expected1 = UINT64_C(0x0800f4c206c372ee), + }, + { + .name = "x64_128 quick fox, seed 0", + .data = quickfox, + .len = sizeof(quickfox) - 1, + .seed = 0, + .expected0 = UINT64_C(0xe34bbc7bbc071b6c), + .expected1 = UINT64_C(0x7a433ca9c49a9347), + }, + { + .name = "x64_128 foo, seed 42", + .data = foo, + .len = sizeof(foo) - 1, + .seed = 42, + .expected0 = UINT64_C(0xf4569d51637053f2), + .expected1 = UINT64_C(0xa279b5d8eeb09aa9), + }, + }; + + int failures = 0; + + for (size_t i = 0; + i < sizeof(tests) / sizeof(tests[0]); + i++) { + + failures += test_mmh3_128_vector(&tests[i]); + } + + failures += test_mmh3_128_unaligned(); + + if (failures != 0) { + fprintf( + stderr, + "\n%d test%s failed\n", + failures, + failures == 1 ? "" : "s" + ); + + return 1; + } + + printf("\nAll mmh3 tests passed.\n"); + return 0; +} -- cgit v1.2.3