From 4a0fd703d5fb58501837c66470edd0e368f1de2c Mon Sep 17 00:00:00 2001 From: Faseela K Date: Mon, 27 Apr 2026 17:54:07 +0200 Subject: [PATCH 1/4] pre-compute password denylist Bloom filter to speed up server startup Signed-off-by: Faseela K --- .../cli/command/BuildPasswordDenylist.java | 94 +++++++++++++++ .../quarkus/runtime/cli/command/Tools.java | 2 +- ...lacklistPasswordPolicyProviderFactory.java | 104 +++++++++++++++- .../BlacklistPasswordPolicyProviderTest.java | 113 ++++++++++++++++++ 4 files changed, 306 insertions(+), 7 deletions(-) create mode 100644 quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/BuildPasswordDenylist.java diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/BuildPasswordDenylist.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/BuildPasswordDenylist.java new file mode 100644 index 000000000000..7ab8b1413816 --- /dev/null +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/BuildPasswordDenylist.java @@ -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 password denylist to speed up server startup and reload."; + + @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); + } + } +} diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Tools.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Tools.java index e01cb40a4d4f..351d02366abb 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Tools.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Tools.java @@ -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"; diff --git a/server-spi-private/src/main/java/org/keycloak/policy/BlacklistPasswordPolicyProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/policy/BlacklistPasswordPolicyProviderFactory.java index f7bfff1131b4..d59aae19fc54 100644 --- a/server-spi-private/src/main/java/org/keycloak/policy/BlacklistPasswordPolicyProviderFactory.java +++ b/server-spi-private/src/main/java/org/keycloak/policy/BlacklistPasswordPolicyProviderFactory.java @@ -17,13 +17,17 @@ 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.util.Locale; import java.text.DecimalFormat; import java.util.List; import java.util.Objects; @@ -247,6 +251,32 @@ public List 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 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. @@ -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 blacklist; @@ -312,6 +353,7 @@ 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; @@ -319,8 +361,14 @@ public FileBasedPasswordBlacklist(Path blacklistBasePath, String name, double fa 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(); } @@ -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; @@ -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 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 loadFromBloom() throws IOException { + long loadStartMillis = System.currentTimeMillis(); + BloomFilter 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 loadFromPlaintext() { try { LOG.infof("Loading blacklist start: name=%s path=%s", name, path); long loadStartMillis = System.currentTimeMillis(); diff --git a/server-spi-private/src/test/java/org/keycloak/policy/BlacklistPasswordPolicyProviderTest.java b/server-spi-private/src/test/java/org/keycloak/policy/BlacklistPasswordPolicyProviderTest.java index 56e244e826ee..a9bd755f8c3d 100644 --- a/server-spi-private/src/test/java/org/keycloak/policy/BlacklistPasswordPolicyProviderTest.java +++ b/server-spi-private/src/test/java/org/keycloak/policy/BlacklistPasswordPolicyProviderTest.java @@ -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; @@ -111,4 +112,116 @@ public void testReloadCheckIntervalZero() throws Exception { Assert.assertFalse(blacklist.contains("newpassword")); } + @Test + public void testLoadFromBloomFile() throws Exception { + // Write plaintext denylist + Path txtFile = tempFolder.newFile("denylist.txt").toPath(); + Files.writeString(txtFile, "secret123\nbadpassword\n"); + + // Pre-compute and write .bloom file + writeBloomFile(tempFolder.getRoot().toPath(), "denylist.txt", + BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY); + + FileBasedPasswordBlacklist denylist = + new FileBasedPasswordBlacklist(tempFolder.getRoot().toPath(), "denylist.txt", + 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 + public void testCorruptBloomFileFallsBackToPlaintext() throws Exception { + Path txtFile = tempFolder.newFile("denylist.txt").toPath(); + Files.writeString(txtFile, "plaintextpassword\n"); + + // Write a corrupt (non-Guava) .bloom file + Path bloomFile = txtFile.resolveSibling("denylist.txt.bloom"); + Files.writeString(bloomFile, "this is not a valid bloom filter binary"); + + FileBasedPasswordBlacklist denylist = + new FileBasedPasswordBlacklist(tempFolder.getRoot().toPath(), "denylist.txt", + BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY, 0); + + // Should have fallen back to plaintext load — password must still be found + Assert.assertTrue("Fallback to plaintext should still find the password", + denylist.contains("plaintextpassword")); + } + + @Test + public void testReloadWatchesBloomFileWhenPresent() 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); + + FileBasedPasswordBlacklist denylist = + new FileBasedPasswordBlacklist(tempFolder.getRoot().toPath(), "denylist.txt", + BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY, 1); + + Assert.assertTrue(denylist.contains("initialpassword")); + Assert.assertFalse(denylist.contains("updatedpassword")); + + // Update the plaintext file and regenerate the .bloom file + Thread.sleep(2); + Files.writeString(txtFile, "updatedpassword\n"); + writeBloomFile(tempFolder.getRoot().toPath(), "denylist.txt", + BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY); + // Bump mtime on .bloom to ensure change detection + 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"); + + BlacklistPasswordPolicyProviderFactory.buildBloomFile( + txtFile, BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY); + + Path bloomFile = txtFile.resolveSibling("denylist.txt.bloom"); + Assert.assertTrue("bloom file must be created", Files.exists(bloomFile)); + Assert.assertTrue("bloom file must have non-zero size", Files.size(bloomFile) > 0); + + // The server must be able to load and use it + FileBasedPasswordBlacklist bl = new FileBasedPasswordBlacklist( + tempFolder.getRoot().toPath(), "denylist.txt", + BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY, 0); + Assert.assertTrue(bl.contains("alpha")); + Assert.assertTrue(bl.contains("beta")); + Assert.assertFalse(bl.contains("delta")); + } + + @Test(expected = IOException.class) + public void testBuildBloomFileMissingInputThrows() throws Exception { + Path missing = tempFolder.getRoot().toPath().resolve("nonexistent.txt"); + BlacklistPasswordPolicyProviderFactory.buildBloomFile( + missing, 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, bloomFpp); + + // Server should still load without throwing (only logs a warning) + FileBasedPasswordBlacklist bl = new FileBasedPasswordBlacklist( + tempFolder.getRoot().toPath(), "denylist.txt", 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 { + BlacklistPasswordPolicyProviderFactory.buildBloomFile(baseDir.resolve(name), fpp); + } + } From a34583415b90c9387fd982082d8eb1b50a2aa3f6 Mon Sep 17 00:00:00 2001 From: Faseela K Date: Tue, 28 Apr 2026 09:37:51 +0200 Subject: [PATCH 2/4] fix spotless errors Signed-off-by: Faseela K --- .../keycloak/policy/BlacklistPasswordPolicyProviderFactory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server-spi-private/src/main/java/org/keycloak/policy/BlacklistPasswordPolicyProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/policy/BlacklistPasswordPolicyProviderFactory.java index d59aae19fc54..b1bcd2894b5f 100644 --- a/server-spi-private/src/main/java/org/keycloak/policy/BlacklistPasswordPolicyProviderFactory.java +++ b/server-spi-private/src/main/java/org/keycloak/policy/BlacklistPasswordPolicyProviderFactory.java @@ -27,9 +27,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Locale; 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; From bc55d75e28185381c64ec8612fdff0c60e3e56ed Mon Sep 17 00:00:00 2001 From: Faseela K Date: Tue, 28 Apr 2026 12:42:14 +0200 Subject: [PATCH 3/4] Add documentation + fix integration test Signed-off-by: Faseela K --- .../authentication/password-policies.adoc | 22 +++++++++++++++++++ .../cli/command/BuildPasswordDenylist.java | 2 +- ...andDistTest.testDefaultToHelp.approved.txt | 2 ++ .../HelpCommandDistTest.testHelp.approved.txt | 2 ++ ...CommandDistTest.testHelpShort.approved.txt | 2 ++ 5 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/documentation/server_admin/topics/authentication/password-policies.adoc b/docs/documentation/server_admin/topics/authentication/password-policies.adoc index 6846cd1e1f03..e18b95c35ff9 100644 --- a/docs/documentation/server_admin/topics/authentication/password-policies.adoc +++ b/docs/documentation/server_admin/topics/authentication/password-policies.adoc @@ -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 diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/BuildPasswordDenylist.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/BuildPasswordDenylist.java index 7ab8b1413816..81e94d8b6d38 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/BuildPasswordDenylist.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/BuildPasswordDenylist.java @@ -45,7 +45,7 @@ public class BuildPasswordDenylist extends AbstractCommand { public static final String NAME = "build-password-denylist"; - public static final String HEADER = "Pre-compute a password denylist to speed up server startup and reload."; + public static final String HEADER = "Pre-compute a Bloom filter for a password denylist."; @Parameters(index = "0", paramLabel = "DENYLIST_FILE", diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testDefaultToHelp.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testDefaultToHelp.approved.txt index d8f0bfeecb29..61c17a991fbe 100644 --- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testDefaultToHelp.approved.txt +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testDefaultToHelp.approved.txt @@ -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 diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testHelp.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testHelp.approved.txt index d8f0bfeecb29..61c17a991fbe 100644 --- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testHelp.approved.txt +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testHelp.approved.txt @@ -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 diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testHelpShort.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testHelpShort.approved.txt index d8f0bfeecb29..61c17a991fbe 100644 --- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testHelpShort.approved.txt +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testHelpShort.approved.txt @@ -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 From bf807370297099ccc5fe442cfa687199a9a3b2ec Mon Sep 17 00:00:00 2001 From: Faseela K Date: Wed, 6 May 2026 12:59:32 +0200 Subject: [PATCH 4/4] Refine build-password-denylist CLI: add output dir validation Signed-off-by: Faseela K --- .../authentication/password-policies.adoc | 14 ++-- .../cli/command/BuildPasswordDenylist.java | 40 +++++++-- ...lacklistPasswordPolicyProviderFactory.java | 84 ++++++++----------- .../BlacklistPasswordPolicyProviderTest.java | 78 +++++------------ 4 files changed, 96 insertions(+), 120 deletions(-) diff --git a/docs/documentation/server_admin/topics/authentication/password-policies.adoc b/docs/documentation/server_admin/topics/authentication/password-policies.adoc index e18b95c35ff9..e7cc968e7364 100644 --- a/docs/documentation/server_admin/topics/authentication/password-policies.adoc +++ b/docs/documentation/server_admin/topics/authentication/password-policies.adoc @@ -156,23 +156,23 @@ The current implementation uses a BloomFilter for fast and memory efficient cont .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: +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.txt +bin/kc.sh tools build-password-denylist /path/to/100k_passwords ---- -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. +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.txt --fpp 0.00001 +bin/kc.sh tools build-password-denylist /path/to/100k_passwords --fpp 0.00001 ---- [[maximum-authentication-age]] diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/BuildPasswordDenylist.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/BuildPasswordDenylist.java index 81e94d8b6d38..fc9db92ec662 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/BuildPasswordDenylist.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/BuildPasswordDenylist.java @@ -31,16 +31,18 @@ // 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 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.", + + " .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%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 { @@ -58,6 +60,11 @@ public class BuildPasswordDenylist extends AbstractCommand { 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 .bloom in the same directory.") + private Path outputFile; + @Override public String getName() { return NAME; @@ -77,16 +84,35 @@ protected void runCommand() { executionError(spec.commandLine(), "--fpp must be between 0 and 1 (exclusive), got: " + fpp); } - Path outputFile = inputFile.resolveSibling(inputFile.getFileName() + ".bloom"); + 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, fpp); + BlacklistPasswordPolicyProviderFactory.buildBloomFile(inputFile, outputFile, fpp); long elapsedMs = System.currentTimeMillis() - startMs; - long outputSizeKb = Files.size(outputFile) / 1024; - picocli.println("Done in " + elapsedMs + " ms. Output: " + outputFile + " (" + outputSizeKb + " KB)"); + 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); } diff --git a/server-spi-private/src/main/java/org/keycloak/policy/BlacklistPasswordPolicyProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/policy/BlacklistPasswordPolicyProviderFactory.java index b1bcd2894b5f..b25f09b89e11 100644 --- a/server-spi-private/src/main/java/org/keycloak/policy/BlacklistPasswordPolicyProviderFactory.java +++ b/server-spi-private/src/main/java/org/keycloak/policy/BlacklistPasswordPolicyProviderFactory.java @@ -255,12 +255,12 @@ public List getConfigMetadata() { * 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) + * @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, double fpp) throws IOException { - Path outputFile = inputFile.resolveSibling(inputFile.getFileName() + ".bloom"); + 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(); @@ -321,17 +321,6 @@ 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 blacklist; @@ -353,7 +342,6 @@ 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; @@ -361,14 +349,8 @@ public FileBasedPasswordBlacklist(Path blacklistBasePath, String name, double fa throw new IllegalArgumentException("Password blacklist " + name + " not found!"); } - // 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.lastModifiedMillis = path.toFile().lastModified(); + this.lastSizeBytes = path.toFile().length(); this.blacklist = load(); this.lastCheckedMillis = System.currentTimeMillis(); } @@ -404,8 +386,8 @@ private void reloadIfNeeded() { return; } try { - long currentModified = Files.getLastModifiedTime(watchPath).toMillis(); - long currentSize = Files.size(watchPath); + long currentModified = Files.getLastModifiedTime(path).toMillis(); + long currentSize = Files.size(path); if (currentModified != lastModifiedMillis || currentSize != lastSizeBytes) { blacklist = load(); lastModifiedMillis = currentModified; @@ -419,20 +401,15 @@ private void reloadIfNeeded() { } /** - * Loads the denylist into a {@link BloomFilter}, using the pre-computed .bloom binary - * when available and falling back to the plaintext file otherwise. + * 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 denylist */ private BloomFilter 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()); - } + if (name.endsWith(".bloom")) { + return loadFromBloom(); } return loadFromPlaintext(); } @@ -444,22 +421,27 @@ private BloomFilter load() { * @return the deserialised {@link BloomFilter} * @throws IOException if the binary file cannot be read */ - private BloomFilter loadFromBloom() throws IOException { - long loadStartMillis = System.currentTimeMillis(); - BloomFilter 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); + private BloomFilter loadFromBloom() { + try { + LOG.infof("Loading pre-computed denylist start: name=%s path=%s", name, path); + long loadStartMillis = System.currentTimeMillis(); + BloomFilter 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); } - return filter; } /** diff --git a/server-spi-private/src/test/java/org/keycloak/policy/BlacklistPasswordPolicyProviderTest.java b/server-spi-private/src/test/java/org/keycloak/policy/BlacklistPasswordPolicyProviderTest.java index a9bd755f8c3d..cf3f4c92e7d2 100644 --- a/server-spi-private/src/test/java/org/keycloak/policy/BlacklistPasswordPolicyProviderTest.java +++ b/server-spi-private/src/test/java/org/keycloak/policy/BlacklistPasswordPolicyProviderTest.java @@ -19,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(); @@ -114,16 +97,15 @@ public void testReloadCheckIntervalZero() throws Exception { @Test public void testLoadFromBloomFile() throws Exception { - // Write plaintext denylist + // Write plaintext denylist and pre-compute .bloom alongside it Path txtFile = tempFolder.newFile("denylist.txt").toPath(); Files.writeString(txtFile, "secret123\nbadpassword\n"); - - // Pre-compute and write .bloom file 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", + 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")); @@ -131,44 +113,37 @@ public void testLoadFromBloomFile() throws Exception { Assert.assertFalse("Should not find password that was never added", denylist.contains("goodpassword")); } - @Test - public void testCorruptBloomFileFallsBackToPlaintext() throws Exception { - Path txtFile = tempFolder.newFile("denylist.txt").toPath(); - Files.writeString(txtFile, "plaintextpassword\n"); - + @Test(expected = RuntimeException.class) + public void testCorruptBloomFileThrows() throws Exception { // Write a corrupt (non-Guava) .bloom file - Path bloomFile = txtFile.resolveSibling("denylist.txt.bloom"); + Path bloomFile = tempFolder.newFile("denylist.txt.bloom").toPath(); Files.writeString(bloomFile, "this is not a valid bloom filter binary"); - FileBasedPasswordBlacklist denylist = - new FileBasedPasswordBlacklist(tempFolder.getRoot().toPath(), "denylist.txt", - BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY, 0); - - // Should have fallen back to plaintext load — password must still be found - Assert.assertTrue("Fallback to plaintext should still find the password", - denylist.contains("plaintextpassword")); + // 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 testReloadWatchesBloomFileWhenPresent() throws Exception { + 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", + 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 and regenerate the .bloom file + // 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); - // Bump mtime on .bloom to ensure change detection Path bloomFile = txtFile.resolveSibling("denylist.txt.bloom"); Files.setLastModifiedTime(bloomFile, FileTime.fromMillis(Files.getLastModifiedTime(bloomFile).toMillis() + 1000)); @@ -181,27 +156,19 @@ public void testBuildBloomFileCreatesReadableOutput() throws Exception { Path txtFile = tempFolder.newFile("denylist.txt").toPath(); Files.writeString(txtFile, "alpha\nbeta\ngamma\n"); - BlacklistPasswordPolicyProviderFactory.buildBloomFile( - txtFile, BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY); - 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); - - // The server must be able to load and use it - FileBasedPasswordBlacklist bl = new FileBasedPasswordBlacklist( - tempFolder.getRoot().toPath(), "denylist.txt", - BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY, 0); - Assert.assertTrue(bl.contains("alpha")); - Assert.assertTrue(bl.contains("beta")); - Assert.assertFalse(bl.contains("delta")); } @Test(expected = IOException.class) public void testBuildBloomFileMissingInputThrows() throws Exception { Path missing = tempFolder.getRoot().toPath().resolve("nonexistent.txt"); BlacklistPasswordPolicyProviderFactory.buildBloomFile( - missing, BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY); + missing, missing.resolveSibling("nonexistent.txt.bloom"), + BlacklistPasswordPolicyProviderFactory.DEFAULT_FALSE_POSITIVE_PROBABILITY); } @Test @@ -212,16 +179,17 @@ public void testFppMismatchStillLoadsSuccessfully() throws Exception { // 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, bloomFpp); + 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", serverFpp, 0); + 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 { - BlacklistPasswordPolicyProviderFactory.buildBloomFile(baseDir.resolve(name), fpp); + Path input = baseDir.resolve(name); + BlacklistPasswordPolicyProviderFactory.buildBloomFile(input, input.resolveSibling(name + ".bloom"), fpp); } }