From cffebf68bf36ef304fa886a64948ab9a93594653 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Oct 2025 19:17:10 +0000 Subject: [PATCH 1/7] Initial plan From 44906bb356ef76ec39b595986f9620a901e68c95 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Oct 2025 19:24:10 +0000 Subject: [PATCH 2/7] Phase 1: Add PasswordSecurityUtil with secure password handling Co-authored-by: devondragon <1254537+devondragon@users.noreply.github.com> --- .../user/util/PasswordSecurityUtil.java | 135 ++++++++++++ .../user/util/PasswordSecurityUtilTest.java | 197 ++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/util/PasswordSecurityUtil.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/util/PasswordSecurityUtilTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/util/PasswordSecurityUtil.java b/src/main/java/com/digitalsanctuary/spring/user/util/PasswordSecurityUtil.java new file mode 100644 index 0000000..38eb544 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/util/PasswordSecurityUtil.java @@ -0,0 +1,135 @@ +package com.digitalsanctuary.spring.user.util; + +import java.util.Arrays; + +/** + * Utility class for secure password handling to minimize password exposure in memory. + * + *
This class provides methods for: + *
Security Rationale: + *
This method always compares the full length of both arrays to avoid + * leaking information about password length or content through timing. + * + * @param password1 first password array + * @param password2 second password array + * @return true if both arrays are non-null and contain the same characters + */ + public static boolean constantTimeEquals(char[] password1, char[] password2) { + if (password1 == null || password2 == null) { + return password1 == password2; + } + + // Different lengths - always return false but continue timing to avoid leak + if (password1.length != password2.length) { + // Still perform a comparison to maintain constant time + int diff = 0; + for (int i = 0; i < password1.length && i < password2.length; i++) { + diff |= password1[i] ^ password2[i]; + } + return false; + } + + // Same length - compare all characters + int diff = 0; + for (int i = 0; i < password1.length; i++) { + diff |= password1[i] ^ password2[i]; + } + + return diff == 0; + } + + /** + * Securely clears a char[] array by overwriting with zeros. + * + *
This method should be called in a finally block to ensure + * sensitive data is cleared even if an exception occurs. + * + * @param password the password array to clear (can be null) + */ + public static void clearPassword(char[] password) { + if (password != null) { + Arrays.fill(password, '\0'); + } + } + + /** + * Converts a char[] to String when required by libraries. + * + *
Important: The resulting String cannot be cleared + * from memory until garbage collected. Use this method only when necessary + * and clear the char[] array immediately after conversion. + * + * @param password the password char array + * @return String representation of the password, or null if input is null + */ + public static String toString(char[] password) { + if (password == null) { + return null; + } + return new String(password); + } + + /** + * Converts a String to char[] for secure handling. + * + *
Note: The original String will still remain in memory until
+ * garbage collected. This method is primarily useful for transitioning
+ * existing String-based code to char[]-based handling.
+ *
+ * @param password the password string
+ * @return char array representation, or null if input is null
+ */
+ public static char[] toCharArray(String password) {
+ if (password == null) {
+ return null;
+ }
+ return password.toCharArray();
+ }
+
+ /**
+ * Validates that a char[] password is not null and not empty.
+ *
+ * @param password the password to validate
+ * @return true if password is non-null and has length > 0
+ */
+ public static boolean isNotEmpty(char[] password) {
+ return password != null && password.length > 0;
+ }
+
+ /**
+ * Validates that a char[] password is null or empty.
+ *
+ * @param password the password to validate
+ * @return true if password is null or has length 0
+ */
+ public static boolean isEmpty(char[] password) {
+ return password == null || password.length == 0;
+ }
+}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/util/PasswordSecurityUtilTest.java b/src/test/java/com/digitalsanctuary/spring/user/util/PasswordSecurityUtilTest.java
new file mode 100644
index 0000000..d50b823
--- /dev/null
+++ b/src/test/java/com/digitalsanctuary/spring/user/util/PasswordSecurityUtilTest.java
@@ -0,0 +1,197 @@
+package com.digitalsanctuary.spring.user.util;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for PasswordSecurityUtil.
+ */
+class PasswordSecurityUtilTest {
+
+ @Test
+ void constantTimeEquals_returnsTrue_whenBothNull() {
+ assertTrue(PasswordSecurityUtil.constantTimeEquals(null, null));
+ }
+
+ @Test
+ void constantTimeEquals_returnsFalse_whenOneNull() {
+ assertFalse(PasswordSecurityUtil.constantTimeEquals(null, "password".toCharArray()));
+ assertFalse(PasswordSecurityUtil.constantTimeEquals("password".toCharArray(), null));
+ }
+
+ @Test
+ void constantTimeEquals_returnsTrue_whenIdentical() {
+ char[] password1 = "MySecureP@ssw0rd".toCharArray();
+ char[] password2 = "MySecureP@ssw0rd".toCharArray();
+ assertTrue(PasswordSecurityUtil.constantTimeEquals(password1, password2));
+ }
+
+ @Test
+ void constantTimeEquals_returnsFalse_whenDifferent() {
+ char[] password1 = "MySecureP@ssw0rd".toCharArray();
+ char[] password2 = "DifferentP@ss".toCharArray();
+ assertFalse(PasswordSecurityUtil.constantTimeEquals(password1, password2));
+ }
+
+ @Test
+ void constantTimeEquals_returnsFalse_whenDifferentLength() {
+ char[] password1 = "short".toCharArray();
+ char[] password2 = "muchlongerpassword".toCharArray();
+ assertFalse(PasswordSecurityUtil.constantTimeEquals(password1, password2));
+ }
+
+ @Test
+ void constantTimeEquals_returnsFalse_whenOneCharacterDifferent() {
+ char[] password1 = "MySecureP@ssw0rd".toCharArray();
+ char[] password2 = "MySecureP@ssw0rD".toCharArray(); // last char different
+ assertFalse(PasswordSecurityUtil.constantTimeEquals(password1, password2));
+ }
+
+ @Test
+ void constantTimeEquals_returnsTrue_whenBothEmpty() {
+ char[] password1 = new char[0];
+ char[] password2 = new char[0];
+ assertTrue(PasswordSecurityUtil.constantTimeEquals(password1, password2));
+ }
+
+ @Test
+ void clearPassword_clearsArray() {
+ char[] password = "MySecureP@ssw0rd".toCharArray();
+ PasswordSecurityUtil.clearPassword(password);
+
+ // Verify all characters are cleared to '\0'
+ for (char c : password) {
+ assertEquals('\0', c, "Password should be cleared to null characters");
+ }
+ }
+
+ @Test
+ void clearPassword_handlesNull() {
+ // Should not throw exception
+ assertDoesNotThrow(() -> PasswordSecurityUtil.clearPassword(null));
+ }
+
+ @Test
+ void clearPassword_handlesEmptyArray() {
+ char[] password = new char[0];
+ assertDoesNotThrow(() -> PasswordSecurityUtil.clearPassword(password));
+ }
+
+ @Test
+ void toString_convertsCorrectly() {
+ char[] password = "MySecureP@ssw0rd".toCharArray();
+ String result = PasswordSecurityUtil.toString(password);
+ assertEquals("MySecureP@ssw0rd", result);
+ }
+
+ @Test
+ void toString_handlesNull() {
+ assertNull(PasswordSecurityUtil.toString(null));
+ }
+
+ @Test
+ void toString_handlesEmptyArray() {
+ char[] password = new char[0];
+ String result = PasswordSecurityUtil.toString(password);
+ assertEquals("", result);
+ }
+
+ @Test
+ void toCharArray_convertsCorrectly() {
+ String password = "MySecureP@ssw0rd";
+ char[] result = PasswordSecurityUtil.toCharArray(password);
+ assertArrayEquals("MySecureP@ssw0rd".toCharArray(), result);
+ }
+
+ @Test
+ void toCharArray_handlesNull() {
+ assertNull(PasswordSecurityUtil.toCharArray(null));
+ }
+
+ @Test
+ void toCharArray_handlesEmptyString() {
+ String password = "";
+ char[] result = PasswordSecurityUtil.toCharArray(password);
+ assertNotNull(result);
+ assertEquals(0, result.length);
+ }
+
+ @Test
+ void isNotEmpty_returnsTrue_whenNotEmpty() {
+ char[] password = "password".toCharArray();
+ assertTrue(PasswordSecurityUtil.isNotEmpty(password));
+ }
+
+ @Test
+ void isNotEmpty_returnsFalse_whenNull() {
+ assertFalse(PasswordSecurityUtil.isNotEmpty(null));
+ }
+
+ @Test
+ void isNotEmpty_returnsFalse_whenEmpty() {
+ char[] password = new char[0];
+ assertFalse(PasswordSecurityUtil.isNotEmpty(password));
+ }
+
+ @Test
+ void isEmpty_returnsTrue_whenNull() {
+ assertTrue(PasswordSecurityUtil.isEmpty(null));
+ }
+
+ @Test
+ void isEmpty_returnsTrue_whenEmpty() {
+ char[] password = new char[0];
+ assertTrue(PasswordSecurityUtil.isEmpty(password));
+ }
+
+ @Test
+ void isEmpty_returnsFalse_whenNotEmpty() {
+ char[] password = "password".toCharArray();
+ assertFalse(PasswordSecurityUtil.isEmpty(password));
+ }
+
+ @Test
+ void constructor_throwsException() {
+ assertThrows(java.lang.reflect.InvocationTargetException.class, () -> {
+ // Use reflection to try to instantiate
+ java.lang.reflect.Constructor Note: This DTO supports both String and char[] for passwords. The char[] methods are preferred
+ * for enhanced security as they allow explicit memory clearing. String methods are maintained
+ * for backward compatibility.
*/
@Data
-public class PasswordDto {
+public class PasswordDto implements AutoCloseable {
/** The old password. */
@ToString.Exclude
@@ -26,4 +31,86 @@ public class PasswordDto {
/** The token. */
private String token;
+ /** The old password as char array (for secure handling). */
+ @ToString.Exclude
+ private transient char[] oldPasswordChars;
+
+ /** The new password as char array (for secure handling). */
+ @ToString.Exclude
+ private transient char[] newPasswordChars;
+
+ /**
+ * Gets the old password as a char array for secure handling.
+ * If oldPasswordChars is null but oldPassword is set, converts from String.
+ *
+ * @return the old password as char array, or null if not set
+ */
+ public char[] getOldPasswordChars() {
+ if (oldPasswordChars == null && oldPassword != null) {
+ return PasswordSecurityUtil.toCharArray(oldPassword);
+ }
+ return oldPasswordChars;
+ }
+
+ /**
+ * Sets the old password from a char array (preferred for security).
+ * Also updates the String oldPassword field for backward compatibility.
+ *
+ * @param oldPasswordChars the old password as char array
+ */
+ public void setOldPasswordChars(char[] oldPasswordChars) {
+ this.oldPasswordChars = oldPasswordChars;
+ if (oldPasswordChars != null) {
+ this.oldPassword = PasswordSecurityUtil.toString(oldPasswordChars);
+ } else {
+ this.oldPassword = null;
+ }
+ }
+
+ /**
+ * Gets the new password as a char array for secure handling.
+ * If newPasswordChars is null but newPassword is set, converts from String.
+ *
+ * @return the new password as char array, or null if not set
+ */
+ public char[] getNewPasswordChars() {
+ if (newPasswordChars == null && newPassword != null) {
+ return PasswordSecurityUtil.toCharArray(newPassword);
+ }
+ return newPasswordChars;
+ }
+
+ /**
+ * Sets the new password from a char array (preferred for security).
+ * Also updates the String newPassword field for backward compatibility.
+ *
+ * @param newPasswordChars the new password as char array
+ */
+ public void setNewPasswordChars(char[] newPasswordChars) {
+ this.newPasswordChars = newPasswordChars;
+ if (newPasswordChars != null) {
+ this.newPassword = PasswordSecurityUtil.toString(newPasswordChars);
+ } else {
+ this.newPassword = null;
+ }
+ }
+
+ /**
+ * Clears sensitive password data from memory.
+ * This method should be called in a finally block to ensure passwords are cleared.
+ */
+ public void clearPasswords() {
+ PasswordSecurityUtil.clearPassword(oldPasswordChars);
+ PasswordSecurityUtil.clearPassword(newPasswordChars);
+ oldPasswordChars = null;
+ newPasswordChars = null;
+ }
+
+ /**
+ * Closes this resource, clearing password data from memory.
+ */
+ @Override
+ public void close() {
+ clearPasswords();
+ }
}
diff --git a/src/main/java/com/digitalsanctuary/spring/user/dto/SavePasswordDto.java b/src/main/java/com/digitalsanctuary/spring/user/dto/SavePasswordDto.java
index 4a7ac64..0ed8761 100644
--- a/src/main/java/com/digitalsanctuary/spring/user/dto/SavePasswordDto.java
+++ b/src/main/java/com/digitalsanctuary/spring/user/dto/SavePasswordDto.java
@@ -1,16 +1,22 @@
package com.digitalsanctuary.spring.user.dto;
+import com.digitalsanctuary.spring.user.util.PasswordSecurityUtil;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
+import lombok.ToString;
/**
* Data Transfer Object for saving a new password after password reset.
* Used in the password reset flow after the user clicks the link in their email
* and enters a new password.
+ *
+ * Note: This DTO supports both String and char[] for passwords. The char[] methods are preferred
+ * for enhanced security as they allow explicit memory clearing. String methods are maintained
+ * for backward compatibility.
*/
@Data
-public class SavePasswordDto {
+public class SavePasswordDto implements AutoCloseable {
/** The password reset token from the email link. */
@NotNull
@@ -18,12 +24,97 @@ public class SavePasswordDto {
private String token;
/** The new password to set. */
+ @ToString.Exclude
@NotNull
@NotEmpty
private String newPassword;
/** Confirmation of the new password (must match newPassword). */
+ @ToString.Exclude
@NotNull
@NotEmpty
private String confirmPassword;
+
+ /** The new password as char array (for secure handling). */
+ @ToString.Exclude
+ private transient char[] newPasswordChars;
+
+ /** The confirm password as char array (for secure handling). */
+ @ToString.Exclude
+ private transient char[] confirmPasswordChars;
+
+ /**
+ * Gets the new password as a char array for secure handling.
+ * If newPasswordChars is null but newPassword is set, converts from String.
+ *
+ * @return the new password as char array, or null if not set
+ */
+ public char[] getNewPasswordChars() {
+ if (newPasswordChars == null && newPassword != null) {
+ return PasswordSecurityUtil.toCharArray(newPassword);
+ }
+ return newPasswordChars;
+ }
+
+ /**
+ * Sets the new password from a char array (preferred for security).
+ * Also updates the String newPassword field for backward compatibility.
+ *
+ * @param newPasswordChars the new password as char array
+ */
+ public void setNewPasswordChars(char[] newPasswordChars) {
+ this.newPasswordChars = newPasswordChars;
+ if (newPasswordChars != null) {
+ this.newPassword = PasswordSecurityUtil.toString(newPasswordChars);
+ } else {
+ this.newPassword = null;
+ }
+ }
+
+ /**
+ * Gets the confirm password as a char array for secure handling.
+ * If confirmPasswordChars is null but confirmPassword is set, converts from String.
+ *
+ * @return the confirm password as char array, or null if not set
+ */
+ public char[] getConfirmPasswordChars() {
+ if (confirmPasswordChars == null && confirmPassword != null) {
+ return PasswordSecurityUtil.toCharArray(confirmPassword);
+ }
+ return confirmPasswordChars;
+ }
+
+ /**
+ * Sets the confirm password from a char array (preferred for security).
+ * Also updates the String confirmPassword field for backward compatibility.
+ *
+ * @param confirmPasswordChars the confirm password as char array
+ */
+ public void setConfirmPasswordChars(char[] confirmPasswordChars) {
+ this.confirmPasswordChars = confirmPasswordChars;
+ if (confirmPasswordChars != null) {
+ this.confirmPassword = PasswordSecurityUtil.toString(confirmPasswordChars);
+ } else {
+ this.confirmPassword = null;
+ }
+ }
+
+ /**
+ * Clears sensitive password data from memory.
+ * This method should be called in a finally block to ensure passwords are cleared.
+ */
+ public void clearPasswords() {
+ PasswordSecurityUtil.clearPassword(newPasswordChars);
+ PasswordSecurityUtil.clearPassword(confirmPasswordChars);
+ newPasswordChars = null;
+ confirmPasswordChars = null;
+ }
+
+ /**
+ * Closes this resource, clearing password data from memory.
+ */
+ @Override
+ public void close() {
+ clearPasswords();
+ }
}
diff --git a/src/main/java/com/digitalsanctuary/spring/user/dto/UserDto.java b/src/main/java/com/digitalsanctuary/spring/user/dto/UserDto.java
index ff503b5..3510ba7 100644
--- a/src/main/java/com/digitalsanctuary/spring/user/dto/UserDto.java
+++ b/src/main/java/com/digitalsanctuary/spring/user/dto/UserDto.java
@@ -1,5 +1,6 @@
package com.digitalsanctuary.spring.user.dto;
+import com.digitalsanctuary.spring.user.util.PasswordSecurityUtil;
import com.digitalsanctuary.spring.user.validation.PasswordMatches;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
@@ -10,10 +11,14 @@
/**
* A user dto. This object is used for handling user related form data (registration, forms passing in email addresses,
* etc...).
+ *
+ * Note: This DTO supports both String and char[] for passwords. The char[] methods are preferred
+ * for enhanced security as they allow explicit memory clearing. String methods are maintained
+ * for backward compatibility.
*/
@Data
@PasswordMatches
-public class UserDto {
+public class UserDto implements AutoCloseable {
/** The first name. */
@NotBlank(message = "First name is required")
@@ -44,4 +49,87 @@ public class UserDto {
/** The role. */
private Integer role;
+
+ /** The password as char array (for secure handling). */
+ @ToString.Exclude
+ private transient char[] passwordChars;
+
+ /** The matching password as char array (for secure handling). */
+ @ToString.Exclude
+ private transient char[] matchingPasswordChars;
+
+ /**
+ * Gets the password as a char array for secure handling.
+ * If passwordChars is null but password is set, converts from String.
+ *
+ * @return the password as char array, or null if not set
+ */
+ public char[] getPasswordChars() {
+ if (passwordChars == null && password != null) {
+ return PasswordSecurityUtil.toCharArray(password);
+ }
+ return passwordChars;
+ }
+
+ /**
+ * Sets the password from a char array (preferred for security).
+ * Also updates the String password field for backward compatibility.
+ *
+ * @param passwordChars the password as char array
+ */
+ public void setPasswordChars(char[] passwordChars) {
+ this.passwordChars = passwordChars;
+ if (passwordChars != null) {
+ this.password = PasswordSecurityUtil.toString(passwordChars);
+ } else {
+ this.password = null;
+ }
+ }
+
+ /**
+ * Gets the matching password as a char array for secure handling.
+ * If matchingPasswordChars is null but matchingPassword is set, converts from String.
+ *
+ * @return the matching password as char array, or null if not set
+ */
+ public char[] getMatchingPasswordChars() {
+ if (matchingPasswordChars == null && matchingPassword != null) {
+ return PasswordSecurityUtil.toCharArray(matchingPassword);
+ }
+ return matchingPasswordChars;
+ }
+
+ /**
+ * Sets the matching password from a char array (preferred for security).
+ * Also updates the String matchingPassword field for backward compatibility.
+ *
+ * @param matchingPasswordChars the matching password as char array
+ */
+ public void setMatchingPasswordChars(char[] matchingPasswordChars) {
+ this.matchingPasswordChars = matchingPasswordChars;
+ if (matchingPasswordChars != null) {
+ this.matchingPassword = PasswordSecurityUtil.toString(matchingPasswordChars);
+ } else {
+ this.matchingPassword = null;
+ }
+ }
+
+ /**
+ * Clears sensitive password data from memory.
+ * This method should be called in a finally block to ensure passwords are cleared.
+ */
+ public void clearPasswords() {
+ PasswordSecurityUtil.clearPassword(passwordChars);
+ PasswordSecurityUtil.clearPassword(matchingPasswordChars);
+ passwordChars = null;
+ matchingPasswordChars = null;
+ }
+
+ /**
+ * Closes this resource, clearing password data from memory.
+ */
+ @Override
+ public void close() {
+ clearPasswords();
+ }
}
From 7114ea9495bd7ce56f3bd7dcae87f52d1a2a66b1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 26 Oct 2025 19:28:06 +0000
Subject: [PATCH 4/7] Phase 3: Add char[] overloads to PasswordPolicyService
and UserService
Co-authored-by: devondragon <1254537+devondragon@users.noreply.github.com>
---
.../user/service/PasswordPolicyService.java | 35 +++++++++++++++
.../spring/user/service/UserService.java | 45 +++++++++++++++++++
2 files changed, 80 insertions(+)
diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java b/src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java
index 67a1e25..2ec11e7 100644
--- a/src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java
+++ b/src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java
@@ -154,6 +154,41 @@ public List Note: The password char array is converted to String internally only when needed
+ * for validation, and the String is not retained beyond the validation process. Note: The user parameter may be null during new user registration.
+ * When null, password history checking is skipped (since new users have no history).
+ * This is intentional - history checks only apply to existing users changing their passwords.