Skip to content
Merged
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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<dependency>
<groupId>com.ongres.stringprep</groupId>
Expand All @@ -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");
Expand All @@ -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 ⑳");
Expand All @@ -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`
Expand All @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions saslprep/src/test/java/test/saslprep/SaslPrepTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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(", ")));
}

}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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.
*
* <p><b>Security Warning:</b> This method allocates a <i>new</i> 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.
*
* <p>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;
}
}
Loading
Loading