Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 and store it alongside the plaintext file using the `build-password-denylist` CLI command:

[source,bash]
----
bin/kc.sh tools build-password-denylist /path/to/100k_passwords.txt
----

This generates a `100k_passwords.txt.bloom` file next to the plaintext file.
{project_name} automatically detects this file on startup and skips rebuilding the filter from scratch.
The plaintext file must remain present; the `.bloom` file is supplementary only.
Re-run the command each time the plaintext file 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.txt --fpp 0.00001
----

[[maximum-authentication-age]]
===== Maximum Authentication Age

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* 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,
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 alongside it. The server will pick it up automatically, reducing load time"
+ " to milliseconds. The plaintext file must remain present; the .bloom file is supplementary only.",
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%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;

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

Path outputFile = inputFile.resolveSibling(inputFile.getFileName() + ".bloom");
picocli.println("Building Bloom filter from: " + inputFile);
picocli.println(" False-positive probability: " + fpp);

try {
long startMs = System.currentTimeMillis();
BlacklistPasswordPolicyProviderFactory.buildBloomFile(inputFile, fpp);
long elapsedMs = System.currentTimeMillis() - startMs;
long outputSizeKb = Files.size(outputFile) / 1024;
picocli.println("Done in " + elapsedMs + " ms. Output: " + outputFile + " (" + outputSizeKb + " KB)");
} catch (IOException e) {
executionError(spec.commandLine(), "Failed to build Bloom filter: " + e.getMessage(), e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

@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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -247,6 +251,32 @@ public List<ProviderConfigProperty> getConfigMetadata() {
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 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, double fpp) throws IOException {
Path outputFile = inputFile.resolveSibling(inputFile.getFileName() + ".bloom");
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.
Expand Down Expand Up @@ -291,6 +321,17 @@ public static class FileBasedPasswordBlacklist implements PasswordBlacklist {
*/
private final Path path;

/**
* Optional path to a pre-computed Bloom filter binary (.bloom).
*/
private final Path bloomPath;

/**
* The file whose mtime/size is watched for changes; set to bloomPath when
* a pre-computed Bloom filter exists at construction time, otherwise path.
*/
private final Path watchPath;

private final double falsePositiveProbability;

private volatile BloomFilter<String> blacklist;
Expand All @@ -312,15 +353,22 @@ public FileBasedPasswordBlacklist(Path blacklistBasePath, String name, double fa

this.name = name;
this.path = blacklistBasePath.resolve(name);
this.bloomPath = blacklistBasePath.resolve(name + ".bloom");
this.falsePositiveProbability = falsePositiveProbability;
this.checkIntervalMillis = checkIntervalMillis;

if (!Files.exists(this.path)) {
throw new IllegalArgumentException("Password blacklist " + name + " not found!");
}

this.lastModifiedMillis = path.toFile().lastModified();
this.lastSizeBytes = path.toFile().length();
// Decide once which file to watch for changes. If a pre-computed .bloom file
// exists at construction time we watch that; otherwise we watch the plaintext
// file. The decision is fixed for the lifetime of this instance so that the
// load strategy never switches mid-run.
this.watchPath = Files.exists(this.bloomPath) ? this.bloomPath : this.path;

this.lastModifiedMillis = watchPath.toFile().lastModified();
this.lastSizeBytes = watchPath.toFile().length();
this.blacklist = load();
this.lastCheckedMillis = System.currentTimeMillis();
}
Expand Down Expand Up @@ -356,8 +404,8 @@ private void reloadIfNeeded() {
return;
}
try {
long currentModified = Files.getLastModifiedTime(path).toMillis();
long currentSize = Files.size(path);
long currentModified = Files.getLastModifiedTime(watchPath).toMillis();
long currentSize = Files.size(watchPath);
if (currentModified != lastModifiedMillis || currentSize != lastSizeBytes) {
blacklist = load();
lastModifiedMillis = currentModified;
Expand All @@ -371,12 +419,56 @@ private void reloadIfNeeded() {
}

/**
* Loads the referenced blacklist into a {@link BloomFilter}.
* Loads the denylist into a {@link BloomFilter}, using the pre-computed .bloom binary
* when available and falling back to the plaintext file otherwise.
*
* @return the {@link BloomFilter} backing a password blacklist
* @return the {@link BloomFilter} backing a password denylist
*/
private BloomFilter<String> load() {
if (Files.exists(bloomPath)) {
LOG.infof("Loading pre-computed denylist start: name=%s path=%s", name, bloomPath);
try {
return loadFromBloom();
} catch (IOException e) {
LOG.warnf("Failed to load pre-computed denylist (path=%s), falling back to plaintext: %s",
bloomPath, e.getMessage());
}
}
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() throws IOException {
long loadStartMillis = System.currentTimeMillis();
BloomFilter<String> filter;
try (BufferedInputStream in = new BufferedInputStream(
Files.newInputStream(bloomPath), 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, bloomPath, 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 build-password-denylist' if this is unintended.",
name, filter.expectedFpp(), falsePositiveProbability);
}
return filter;
}

/**
* 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();
Expand Down
Loading
Loading