diff --git a/README.md b/README.md index 20c5a6d..2b629a4 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,10 @@ Import Maven dependency: The normal usage is to import the dependency of the Stringprep profile to use, and lookup the provider service that contains the profile. -### Example: +### Example + Import the `SASLprep` dependency, this transitively imports the `Stringprep` dependency. + ```xml com.ongres.stringprep @@ -63,6 +65,7 @@ Import the `SASLprep` dependency, this transitively imports the `Stringprep` dep ``` Get the `SASLprep` provider service: + ```java Profile saslPrep = Stringprep.getProvider("SASLprep"); String prepared = saslPrep.prepareStored("I\u00ADX \u2168"); @@ -72,6 +75,7 @@ prepared.equals("IX IX"); // true You could also (only) use the stringprep dependency to create your own profiles by implementing the `Profile` interface, just override the `profile()` method with the set of options. Anonymous on-the-fly profile usage: + ```java Profile saslPrep = () -> EnumSet.of(Option.NORMALIZE_KC, Option.MAP_TO_NOTHING); String prepared = saslPrep.prepareStored("I\u00ADX ⑳"); @@ -80,7 +84,8 @@ prepared.equals("IX 20"); // true > Please note that when two protocols that use different profiles of stringprep interoperate, there may be conflict about what characters are and are not allowed in the final string. Thus, protocol developers should strongly consider re-using existing profiles of stringprep. -### Java Modules (JPMS): +### Java Modules (JPMS) + The Stringprep and profiles implementation are explicit Java modules with the names: * `com.ongres.stringprep` @@ -90,6 +95,7 @@ The Stringprep and profiles implementation are explicit Java modules with the na If you depend on a specific profile (`saslprep` or `nameprep`) there is an implied readability on `stringprep`, so you will only need to declare in your `module-info.java` the profile module and get the service from the provider. Example `module-info.java`: + ```java module test.app { requires com.ongres.saslprep; diff --git a/saslprep/src/test/java/test/saslprep/SaslPrepTest.java b/saslprep/src/test/java/test/saslprep/SaslPrepTest.java index 51d5dae..5758df0 100644 --- a/saslprep/src/test/java/test/saslprep/SaslPrepTest.java +++ b/saslprep/src/test/java/test/saslprep/SaslPrepTest.java @@ -8,16 +8,22 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import java.io.IOException; import java.util.EnumSet; +import java.util.Locale; +import java.util.stream.Collectors; import com.ongres.saslprep.SASLprep; import com.ongres.stringprep.Option; import com.ongres.stringprep.Profile; import com.ongres.stringprep.Stringprep; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EmptySource; +import org.junit.jupiter.params.provider.ValueSource; class SaslPrepTest { @@ -159,4 +165,24 @@ void unassigned() { assertEquals("Unassigned code point \"0x0588\"", e.getMessage()); } } + + @ParameterizedTest + @ValueSource(strings = { "\u200B\u200C\u200D\u034F", "\uFEFF" }) + @EmptySource + void testEmptyMap(String string) { + String stored = saslPrep.prepareStored(string); + assertTrue(stored.isEmpty(), () -> stored.codePoints() + .mapToObj(cp -> String.format(Locale.ROOT, "0x%04X", cp)) + .collect(Collectors.joining(", "))); + } + + @Test + void testAdditionalMappingOptions() { + String stored = saslPrep.prepareStored("\uFEFF\u2000\u3000\u00A0\uFEFF"); + assertEquals(" ", stored, + stored.codePoints() + .mapToObj(cp -> String.format(Locale.ROOT, "0x%04X", cp)) + .collect(Collectors.joining(", "))); + } + } diff --git a/stringprep/src/main/java/com/ongres/stringprep/SecureStringBuilder.java b/stringprep/src/main/java/com/ongres/stringprep/SecureStringBuilder.java new file mode 100644 index 0000000..c7b4396 --- /dev/null +++ b/stringprep/src/main/java/com/ongres/stringprep/SecureStringBuilder.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026 OnGres, Inc. + * SPDX-License-Identifier: BSD-2-Clause + */ + +package com.ongres.stringprep; + +import java.util.Arrays; + +/** + * A memory-safe string builder alternative designed specifically for cryptographic operations. + * + *

Standard {@link String} and {@link StringBuilder} classes leave sensitive data (like passwords + * or encryption keys) in memory until garbage collection occurs. This class mitigates that risk + * by ensuring that internal character arrays are explicitly zeroed out when the buffer is resized, + * and when the resource is closed. + * + * @see AutoCloseable + */ +final class SecureStringBuilder implements AutoCloseable { + private char[] buffer; + private int length; + + /** + * Constructs a new {@code SecureStringBuilder} with the specified initial capacity. + * + * @param initialCapacity the initial capacity of the secure buffer. + * @throws IllegalArgumentException if {@code initialCapacity} is negative. + */ + SecureStringBuilder(int initialCapacity) { + if (initialCapacity < 0) { + throw new IllegalArgumentException("Initial capacity cannot be negative"); + } + this.buffer = new char[initialCapacity]; + this.length = 0; + } + + /** + * Ensures that the internal buffer has enough capacity to hold the specified minimum + * number of characters. If a resize is required, the old array is securely wiped + * before being discarded. + * + * @param minCapacity the desired minimum capacity. + * @throws OutOfMemoryError if the required size exceeds JVM array limits. + */ + private void ensureCapacity(int minCapacity) { + if (minCapacity > buffer.length) { + // Use long to prevent integer overflow when doubling + int newCapacity = (int) Math.min(Integer.MAX_VALUE, Math.max(buffer.length * 2L, minCapacity)); + char[] newBuffer = new char[newCapacity]; + System.arraycopy(buffer, 0, newBuffer, 0, length); + + // SECURE WIPE: Zero out the old array before abandoning it to the GC + Arrays.fill(buffer, '\0'); + buffer = newBuffer; + } + } + + /** + * Appends a single Unicode code point to this buffer. + * + *

This method correctly handles supplementary characters by converting them + * into their corresponding UTF-16 surrogate pairs if necessary. + * + * @param codePoint the Unicode code point to append. + * @throws IllegalArgumentException if the specified code point is not a valid Unicode code point. + * @throws IllegalStateException if the builder has been closed. + */ + void appendCodePoint(int codePoint) { + if (buffer == null) { + throw new IllegalStateException("SecureStringBuilder is closed"); + } + int charCount = Character.charCount(codePoint); + ensureCapacity(this.length + charCount); + Character.toChars(codePoint, this.buffer, this.length); + this.length += charCount; + } + + /** + * Extracts a copy of the current buffer sized exactly to the appended content. + * + *

Security Warning: This method allocates a new array containing the + * sensitive data. The internal buffer remains intact until {@link #close()} is called. + * The caller assumes full responsibility for securely wiping the returned array + * (e.g., using {@link Arrays#fill(char[], char)}) as soon as it is no longer needed. + * + * @return a new, exact-sized character array containing the buffer's contents. + * @throws IllegalStateException if the builder has been closed. + */ + char[] toCharArray() { + if (buffer == null) { + throw new IllegalStateException("SecureStringBuilder is closed"); + } + char[] result = new char[length]; + System.arraycopy(buffer, 0, result, 0, length); + return result; + } + + /** + * Securely wipes the internal buffer by overwriting all contents with null characters + * ('\0') and resets the length to zero. + * + *

This method should be called inside a {@code finally} block or implicitly via + * a {@code try-with-resources} statement to guarantee cleanup. + */ + @Override + public void close() { + if (buffer != null) { + Arrays.fill(buffer, '\0'); + buffer = null; //NOPMD + } + length = 0; + } +} diff --git a/stringprep/src/main/java/com/ongres/stringprep/Stringprep.java b/stringprep/src/main/java/com/ongres/stringprep/Stringprep.java index 7bebca8..b2e53b2 100644 --- a/stringprep/src/main/java/com/ongres/stringprep/Stringprep.java +++ b/stringprep/src/main/java/com/ongres/stringprep/Stringprep.java @@ -7,6 +7,7 @@ import java.nio.CharBuffer; import java.text.Normalizer; +import java.util.Arrays; import java.util.EnumSet; import java.util.Locale; import java.util.Objects; @@ -22,24 +23,7 @@ public final class Stringprep { private final Profile profile; - private final boolean mapToNothing; - private final boolean additionalMapping; - private final boolean caseFoldNfkc; - private final boolean caseFoldNoNormalization; - private final boolean normalizeKc; - private final boolean checkBidi; - private final boolean forbidAdditionalCharacters; - private final boolean forbidAsciiSpaces; - private final boolean forbidNonAsciiSpaces; - private final boolean forbidAsciiControl; - private final boolean forbidNonAsciiControl; - private final boolean forbidPrivateUse; - private final boolean forbidNonCharacter; - private final boolean forbidSurrogate; - private final boolean forbidInappropriatePlainText; - private final boolean forbidInappropriateCanonRep; - private final boolean forbidChangeDisplayDeprecated; - private final boolean forbidTagging; + private final Set