pre-compute password denylist Bloom filter to speed up server startup

Fixes #47356

Signed-off-by: Faseela K <faseela.k@est.tech>
This commit is contained in:
Faseela K
2026-05-07 16:01:12 +02:00
committed by GitHub
parent 8e808ca15f
commit 26c2a9e3ed
8 changed files with 325 additions and 22 deletions
@@ -153,6 +153,28 @@ The current implementation uses a BloomFilter for fast and memory efficient cont
* By default a false positive probability of `0.01%` is used.
* To change the false positive probability by CLI configuration, use `+--spi-password-policy--password-blacklist--false-positive-probability=0.00001+`.
.Pre-computing the Bloom filter
For large denylist files, {project_name} builds the Bloom filter from the plaintext file on every startup or reload, which can take several seconds.
To reduce load time to milliseconds, pre-compute the Bloom filter once using the `build-password-denylist` CLI command:
[source,bash]
----
bin/kc.sh tools build-password-denylist /path/to/100k_passwords
----
This generates a `100k_passwords.bloom` file next to the input file.
Place it in the password-blacklists folder and configure the realm password policy to use the `.bloom` filename (for example, `100k_passwords.bloom`) instead of the plaintext file.
{project_name} detects the file type by extension: files ending in `.bloom` are loaded as pre-computed Bloom filter binaries; all other files are read as plaintext.
Re-run the command and update the policy value each time the denylist is updated.
You can also control the false positive probability for the pre-computed filter:
[source,bash]
----
bin/kc.sh tools build-password-denylist /path/to/100k_passwords --fpp 0.00001
----
[[maximum-authentication-age]]
===== Maximum Authentication Age
@@ -0,0 +1,120 @@
/*
* Copyright 2026 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.quarkus.runtime.cli.command;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.keycloak.policy.BlacklistPasswordPolicyProviderFactory;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
// Builds a pre-computed Bloom filter (.bloom) alongside a plaintext password denylist.
// The server loads the .bloom file instead of rebuilding from plaintext, reducing reload latency.
@Command(name = BuildPasswordDenylist.NAME,
header = BuildPasswordDenylist.HEADER,
sortOptions = false,
description = "%n" + BuildPasswordDenylist.HEADER
+ "%n%nKeycloak's password-blacklist policy rejects passwords found in a plaintext denylist file."
+ " For large lists, loading from plaintext on every startup or reload can take seconds."
+ " Run this command once after creating or updating DENYLIST_FILE to generate a pre-computed"
+ " .bloom file. To use it, configure the password policy with the .bloom filename instead of"
+ " the plaintext file. The server detects the file type by extension and loads it accordingly,"
+ " reducing load time to milliseconds.",
footerHeading = "%nExamples:%n",
footer = {
" ${PARENT-COMMAND-FULL-NAME:-$PARENTCOMMAND} ${COMMAND-NAME} /path/to/denylist.txt%n",
" ${PARENT-COMMAND-FULL-NAME:-$PARENTCOMMAND} ${COMMAND-NAME} /path/to/denylist.txt --fpp 0.00001 -o /path/to/out.bloom%n"
})
public class BuildPasswordDenylist extends AbstractCommand {
public static final String NAME = "build-password-denylist";
public static final String HEADER = "Pre-compute a Bloom filter for a password denylist.";
@Parameters(index = "0",
paramLabel = "DENYLIST_FILE",
description = "Path to the plaintext password denylist file (one password per line, UTF-8).")
private Path inputFile;
@Option(names = "--fpp",
paramLabel = "PROBABILITY",
description = "Desired false-positive probability for the Bloom filter, defaults to 0.0001.",
defaultValue = "0.0001")
private double fpp;
@Option(names = {"-o", "--output"},
paramLabel = "OUTPUT_FILE",
description = "Path for the generated .bloom file. Must end with '.bloom'. Defaults to <DENYLIST_FILE>.bloom in the same directory.")
private Path outputFile;
@Override
public String getName() {
return NAME;
}
@Override
public boolean isHelpAll() {
return false;
}
@Override
protected void runCommand() {
if (!Files.isRegularFile(inputFile)) {
executionError(spec.commandLine(), "File not found or not a regular file: " + inputFile);
}
if (fpp <= 0.0 || fpp >= 1.0) {
executionError(spec.commandLine(), "--fpp must be between 0 and 1 (exclusive), got: " + fpp);
}
if (outputFile == null) {
outputFile = inputFile.resolveSibling(inputFile.getFileName() + ".bloom");
} else if (!outputFile.getFileName().toString().endsWith(".bloom")) {
executionError(spec.commandLine(), "--output must end with '.bloom', got: " + outputFile);
} else {
Path outputParent = outputFile.toAbsolutePath().getParent();
if (outputParent != null && !Files.isDirectory(outputParent)) {
executionError(spec.commandLine(), "Output directory does not exist: " + outputParent);
}
}
picocli.println("Building Bloom filter from: " + inputFile);
picocli.println(" False-positive probability: " + fpp);
try {
long startMs = System.currentTimeMillis();
BlacklistPasswordPolicyProviderFactory.buildBloomFile(inputFile, outputFile, fpp);
long elapsedMs = System.currentTimeMillis() - startMs;
long outputSizeBytes = Files.size(outputFile);
String sizeStr;
if (outputSizeBytes < 1024) {
sizeStr = outputSizeBytes + " B";
} else if (outputSizeBytes < 1024 * 1024) {
sizeStr = (outputSizeBytes / 1024) + " KB";
} else {
sizeStr = (outputSizeBytes / (1024 * 1024)) + " MB";
}
picocli.println("Done in " + elapsedMs + " ms. Output: " + outputFile + " (" + sizeStr + ")");
picocli.println("Next step: place " + outputFile.getFileName() + " in your password-blacklists folder and"
+ " configure the password blacklist policy value to '" + outputFile.getFileName() + "'.");
} catch (IOException e) {
executionError(spec.commandLine(), "Failed to build Bloom filter: " + e.getMessage(), e);
}
}
}
@@ -21,7 +21,7 @@ import picocli.CommandLine.Command;
@Command(name = Tools.NAME,
description = "Utilities for use and interaction with the server.",
subcommands = {Completion.class, WindowsService.class})
subcommands = {Completion.class, WindowsService.class, BuildPasswordDenylist.class})
public class Tools {
public static final String NAME = "tools";
@@ -30,6 +30,8 @@ Commands:
windows-service Manage Keycloak as a Windows service.
install Install Keycloak as a Windows service.
uninstall Uninstall Keycloak Windows service.
build-password-denylist
Pre-compute a Bloom filter for a password denylist.
bootstrap-admin Commands for bootstrapping admin access
user Add an admin user with a password
service Add an admin service account
@@ -30,6 +30,8 @@ Commands:
windows-service Manage Keycloak as a Windows service.
install Install Keycloak as a Windows service.
uninstall Uninstall Keycloak Windows service.
build-password-denylist
Pre-compute a Bloom filter for a password denylist.
bootstrap-admin Commands for bootstrapping admin access
user Add an admin user with a password
service Add an admin service account
@@ -30,6 +30,8 @@ Commands:
windows-service Manage Keycloak as a Windows service.
install Install Keycloak as a Windows service.
uninstall Uninstall Keycloak Windows service.
build-password-denylist
Pre-compute a Bloom filter for a password denylist.
bootstrap-admin Commands for bootstrapping admin access
user Add an admin user with a password
service Add an admin service account
@@ -17,15 +17,19 @@
package org.keycloak.policy;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.DecimalFormat;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@@ -247,6 +251,32 @@ public class BlacklistPasswordPolicyProviderFactory implements PasswordPolicyPro
return builder.build();
}
/**
* Builds a pre-computed Bloom filter (.bloom) file from a plaintext password denylist file.
* Each line is treated as one password (lowercased before insertion).
*
* @param inputFile path to the plaintext password list (one password per line, UTF-8)
* @param outputFile path for the generated .bloom file
* @param fpp desired false-positive probability (e.g. 0.0001)
* @throws IOException if the input file cannot be read or the output file cannot be written
*/
public static void buildBloomFile(Path inputFile, Path outputFile, double fpp) throws IOException {
long count;
try (var lines = Files.lines(inputFile, StandardCharsets.UTF_8)) {
count = lines.count();
}
BloomFilter<String> filter = BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8), Math.max(count, 1), fpp);
try (var lines = Files.lines(inputFile, StandardCharsets.UTF_8)) {
lines.map(s -> s.toLowerCase(Locale.ROOT)).forEach(filter::put);
}
try (OutputStream out = new BufferedOutputStream(Files.newOutputStream(outputFile))) {
filter.writeTo(out);
}
LOG.infof("Built pre-computed denylist: input=%s passwords=%d fpp=%f output=%s",
inputFile, count, fpp, outputFile);
}
/**
* A {@link PasswordBlacklist} describes a list of too easy to guess
* or potentially leaked passwords that users should not be able to use.
@@ -371,12 +401,56 @@ public class BlacklistPasswordPolicyProviderFactory implements PasswordPolicyPro
}
/**
* Loads the referenced blacklist into a {@link BloomFilter}.
* Loads the denylist into a {@link BloomFilter}.
* If the configured file ends with {@code .bloom}, it is loaded as a pre-computed Bloom filter binary.
* Otherwise, it is read as a plaintext password list.
*
* @return the {@link BloomFilter} backing a password blacklist
* @return the {@link BloomFilter} backing a password denylist
*/
private BloomFilter<String> load() {
if (name.endsWith(".bloom")) {
return loadFromBloom();
}
return loadFromPlaintext();
}
/**
* Fast path: deserialise a pre-computed Bloom filter binary (.bloom).
* Emits a warning when the stored false-positive probability differs from the configured value.
*
* @return the deserialised {@link BloomFilter}
* @throws IOException if the binary file cannot be read
*/
private BloomFilter<String> loadFromBloom() {
try {
LOG.infof("Loading pre-computed denylist start: name=%s path=%s", name, path);
long loadStartMillis = System.currentTimeMillis();
BloomFilter<String> filter;
try (BufferedInputStream in = new BufferedInputStream(
Files.newInputStream(path), BUFFER_SIZE_IN_BYTES)) {
filter = BloomFilter.readFrom(in, Funnels.stringFunnel(StandardCharsets.UTF_8));
}
long loadTimeMillis = System.currentTimeMillis() - loadStartMillis;
LOG.infof("Loading pre-computed denylist finished: name=%s path=%s expectedFpp=%s loadTime=%dms",
name, path, filter.expectedFpp(), loadTimeMillis);
if (Math.abs(filter.expectedFpp() - falsePositiveProbability) > 1e-9) {
LOG.warnf("Pre-computed denylist '%s' has fpp=%.6f but configured fpp=%.6f. "
+ "Regenerate the .bloom file with 'kc.sh tools build-password-denylist' if this is unintended.",
name, filter.expectedFpp(), falsePositiveProbability);
}
return filter;
} catch (IOException e) {
throw new RuntimeException("Loading pre-computed denylist failed: path=" + path, e);
}
}
/**
* Slow path: build a BloomFilter from the plaintext denylist file.
* Requires two passes: one to count passwords, one to insert them.
*
* @return a newly constructed {@link BloomFilter} populated from the plaintext file
*/
private BloomFilter<String> loadFromPlaintext() {
try {
LOG.infof("Loading blacklist start: name=%s path=%s", name, path);
long loadStartMillis = System.currentTimeMillis();
@@ -1,5 +1,6 @@
package org.keycloak.policy;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -18,34 +19,17 @@ public class BlacklistPasswordPolicyProviderTest {
public TemporaryFolder tempFolder = new TemporaryFolder();
@Test
public void testUpperCaseInFile() {
public void testPasswordLookupIsCaseInsensitive() {
FileBasedPasswordBlacklist blacklist =
new FileBasedPasswordBlacklist(Paths.get("src/test/java/org/keycloak/policy"), "short_blacklist.txt",
BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY,
BlacklistPasswordPolicyProviderFactory.DEFAULT_CHECK_INTERVAL_SECONDS * 1000L);
// all passwords in the deny list are in lower case
// passwords in the deny list are stored in lower case; lookups must be case-insensitive
Assert.assertFalse(blacklist.contains("1Password!"));
}
@Test
public void testAlwaysLowercaseInFile() {
FileBasedPasswordBlacklist blacklist =
new FileBasedPasswordBlacklist(Paths.get("src/test/java/org/keycloak/policy"), "short_blacklist.txt",
BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY,
BlacklistPasswordPolicyProviderFactory.DEFAULT_CHECK_INTERVAL_SECONDS * 1000L);
Assert.assertTrue(blacklist.contains("1Password!".toLowerCase()));
}
@Test
public void testLowerCaseInFile() {
FileBasedPasswordBlacklist blacklist =
new FileBasedPasswordBlacklist(Paths.get("src/test/java/org/keycloak/policy"), "short_blacklist.txt",
BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY,
BlacklistPasswordPolicyProviderFactory.DEFAULT_CHECK_INTERVAL_SECONDS * 1000L);
Assert.assertTrue(blacklist.contains("pass1!word"));
}
@Test
public void testReloadOnFileMtimeChange() throws Exception {
Path file = tempFolder.newFile("blacklist.txt").toPath();
@@ -111,4 +95,101 @@ public class BlacklistPasswordPolicyProviderTest {
Assert.assertFalse(blacklist.contains("newpassword"));
}
@Test
public void testLoadFromBloomFile() throws Exception {
// Write plaintext denylist and pre-compute .bloom alongside it
Path txtFile = tempFolder.newFile("denylist.txt").toPath();
Files.writeString(txtFile, "secret123\nbadpassword\n");
writeBloomFile(tempFolder.getRoot().toPath(), "denylist.txt",
BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY);
// Admin explicitly configures the .bloom file
FileBasedPasswordBlacklist denylist =
new FileBasedPasswordBlacklist(tempFolder.getRoot().toPath(), "denylist.txt.bloom",
BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY, 0);
Assert.assertTrue("Should find password from .bloom file", denylist.contains("secret123"));
Assert.assertTrue("Should find password from .bloom file", denylist.contains("badpassword"));
Assert.assertFalse("Should not find password that was never added", denylist.contains("goodpassword"));
}
@Test(expected = RuntimeException.class)
public void testCorruptBloomFileThrows() throws Exception {
// Write a corrupt (non-Guava) .bloom file
Path bloomFile = tempFolder.newFile("denylist.txt.bloom").toPath();
Files.writeString(bloomFile, "this is not a valid bloom filter binary");
// Admin configured .bloom explicitly — must fail hard on corrupt file, no fallback
new FileBasedPasswordBlacklist(tempFolder.getRoot().toPath(), "denylist.txt.bloom",
BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY, 0);
}
@Test
public void testReloadBloomFileOnChange() throws Exception {
Path txtFile = tempFolder.newFile("denylist.txt").toPath();
Files.writeString(txtFile, "initialpassword\n");
writeBloomFile(tempFolder.getRoot().toPath(), "denylist.txt",
BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY);
// Admin explicitly configures the .bloom file
FileBasedPasswordBlacklist denylist =
new FileBasedPasswordBlacklist(tempFolder.getRoot().toPath(), "denylist.txt.bloom",
BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY, 1);
Assert.assertTrue(denylist.contains("initialpassword"));
Assert.assertFalse(denylist.contains("updatedpassword"));
// Update the plaintext file, regenerate and bump mtime on .bloom
Thread.sleep(2);
Files.writeString(txtFile, "updatedpassword\n");
writeBloomFile(tempFolder.getRoot().toPath(), "denylist.txt",
BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY);
Path bloomFile = txtFile.resolveSibling("denylist.txt.bloom");
Files.setLastModifiedTime(bloomFile, FileTime.fromMillis(Files.getLastModifiedTime(bloomFile).toMillis() + 1000));
Assert.assertFalse("Old password should no longer be found after reload", denylist.contains("initialpassword"));
Assert.assertTrue("Updated password should be found after reload", denylist.contains("updatedpassword"));
}
@Test
public void testBuildBloomFileCreatesReadableOutput() throws Exception {
Path txtFile = tempFolder.newFile("denylist.txt").toPath();
Files.writeString(txtFile, "alpha\nbeta\ngamma\n");
Path bloomFile = txtFile.resolveSibling("denylist.txt.bloom");
BlacklistPasswordPolicyProviderFactory.buildBloomFile(
txtFile, bloomFile, BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY);
Assert.assertTrue("bloom file must be created", Files.exists(bloomFile));
Assert.assertTrue("bloom file must have non-zero size", Files.size(bloomFile) > 0);
}
@Test(expected = IOException.class)
public void testBuildBloomFileMissingInputThrows() throws Exception {
Path missing = tempFolder.getRoot().toPath().resolve("nonexistent.txt");
BlacklistPasswordPolicyProviderFactory.buildBloomFile(
missing, missing.resolveSibling("nonexistent.txt.bloom"),
BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY);
}
@Test
public void testFppMismatchStillLoadsSuccessfully() throws Exception {
Path txtFile = tempFolder.newFile("denylist.txt").toPath();
Files.writeString(txtFile, "mismatchpw\n");
// Build bloom with a DIFFERENT fpp than what the server will use
double bloomFpp = 0.01; // coarser
double serverFpp = BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY; // 0.0001
BlacklistPasswordPolicyProviderFactory.buildBloomFile(txtFile, txtFile.resolveSibling("denylist.txt.bloom"), bloomFpp);
// Server should still load without throwing (only logs a warning)
FileBasedPasswordBlacklist bl = new FileBasedPasswordBlacklist(
tempFolder.getRoot().toPath(), "denylist.txt.bloom", serverFpp, 0);
Assert.assertTrue("password must still be found despite fpp mismatch", bl.contains("mismatchpw"));
}
private static void writeBloomFile(Path baseDir, String name, double fpp) throws IOException {
Path input = baseDir.resolve(name);
BlacklistPasswordPolicyProviderFactory.buildBloomFile(input, input.resolveSibling(name + ".bloom"), fpp);
}
}