diff --git a/main/pom.xml b/main/pom.xml
index 5d98886e9..98916b6b7 100644
--- a/main/pom.xml
+++ b/main/pom.xml
@@ -221,6 +221,12 @@
hamcrest-all
${hamcrest.version}
+
+ org.openjfx
+ javafx-swing
+ ${javafx.version}
+ test
+
diff --git a/main/ui/pom.xml b/main/ui/pom.xml
index 710fe7c3f..1465683f0 100644
--- a/main/ui/pom.xml
+++ b/main/ui/pom.xml
@@ -109,5 +109,10 @@
1.1
test
+
+ org.openjfx
+ javafx-swing
+ test
+
diff --git a/main/ui/src/main/java/org/cryptomator/ui/controls/SecPasswordField.java b/main/ui/src/main/java/org/cryptomator/ui/controls/SecPasswordField.java
index 8a6d0539e..960a7220a 100644
--- a/main/ui/src/main/java/org/cryptomator/ui/controls/SecPasswordField.java
+++ b/main/ui/src/main/java/org/cryptomator/ui/controls/SecPasswordField.java
@@ -15,6 +15,8 @@ import javafx.scene.input.Dragboard;
import javafx.scene.input.TransferMode;
import java.nio.CharBuffer;
+import java.text.Normalizer;
+import java.text.Normalizer.Form;
import java.util.Arrays;
/**
@@ -53,15 +55,38 @@ public class SecPasswordField extends PasswordField {
event.consume();
}
+ /**
+ * Replaces a range of characters with the given text.
+ * The text will be normalized to NFC.
+ *
+ * @param start The starting index in the range, inclusive. This must be >= 0 and < the end.
+ * @param end The ending index in the range, exclusive. This is one-past the last character to
+ * delete (consistent with the String manipulation methods). This must be > the start,
+ * and <= the length of the text.
+ * @param text The text that is to replace the range. This must not be null.
+ * @implNote Internally calls {@link PasswordField#replaceText(int, int, String)} with a dummy String for visual purposes.
+ */
@Override
public void replaceText(int start, int end, String text) {
+ String normalizedText = Normalizer.normalize(text, Form.NFC);
int removed = end - start;
- int added = text.length();
- this.length += added - removed;
- growContentIfNeeded();
- text.getChars(0, text.length(), content, start);
+ int added = normalizedText.length();
+ int delta = added - removed;
- String placeholderString = Strings.repeat(PLACEHOLDER, text.length());
+ // ensure sufficient content buffer size
+ int oldLength = length;
+ this.length += delta;
+ growContentIfNeeded();
+
+ // shift existing content
+ if (delta != 0 && start < oldLength) {
+ System.arraycopy(content, end, content, end + delta, oldLength - end);
+ }
+
+ // copy new text to content buffer
+ normalizedText.getChars(0, normalizedText.length(), content, start);
+
+ String placeholderString = Strings.repeat(PLACEHOLDER, normalizedText.length());
super.replaceText(start, end, placeholderString);
}
@@ -69,7 +94,7 @@ public class SecPasswordField extends PasswordField {
if (length > content.length) {
char[] newContent = new char[length + GROW_BUFFER_SIZE];
System.arraycopy(content, 0, newContent, 0, content.length);
- swipe();
+ swipe(content);
this.content = newContent;
}
}
@@ -80,6 +105,7 @@ public class SecPasswordField extends PasswordField {
* @return A character sequence backed by the SecPasswordField's buffer (not a copy).
* @implNote The CharSequence will not copy the backing char[].
* Therefore any mutation to the SecPasswordField's content will mutate or eventually swipe the returned CharSequence.
+ * @implSpec The CharSequence is usually in NFC representation (unless NFD-encoded char[] is set via {@link #setPassword(char[])}).
* @see #swipe()
*/
@Override
@@ -87,6 +113,28 @@ public class SecPasswordField extends PasswordField {
return CharBuffer.wrap(content, 0, length);
}
+ /**
+ * Convenience method wrapper for {@link #setPassword(char[])}.
+ *
+ * @param password
+ * @see #setPassword(char[])
+ */
+ public void setPassword(CharSequence password) {
+ char[] buf = new char[password.length()];
+ for (int i = 0; i < password.length(); i++) {
+ buf[i] = password.charAt(i);
+ }
+ setPassword(buf);
+ Arrays.fill(buf, SWIPE_CHAR);
+ }
+
+ /**
+ * Directly sets the content of this password field to a copy of the given password.
+ * No conversion whatsoever happens. If you want to normalize the unicode representation of the password,
+ * do it before calling this method.
+ *
+ * @param password
+ */
public void setPassword(char[] password) {
swipe();
content = Arrays.copyOf(password, password.length);
@@ -100,7 +148,12 @@ public class SecPasswordField extends PasswordField {
* Destroys the stored password by overriding each character with a different character.
*/
public void swipe() {
- Arrays.fill(content, SWIPE_CHAR);
+ swipe(content);
+ length = 0;
+ }
+
+ private void swipe(char[] buffer) {
+ Arrays.fill(buffer, SWIPE_CHAR);
}
}
diff --git a/main/ui/src/test/java/org/cryptomator/ui/controls/SecPasswordFieldTest.java b/main/ui/src/test/java/org/cryptomator/ui/controls/SecPasswordFieldTest.java
new file mode 100644
index 000000000..0c5538892
--- /dev/null
+++ b/main/ui/src/test/java/org/cryptomator/ui/controls/SecPasswordFieldTest.java
@@ -0,0 +1,163 @@
+package org.cryptomator.ui.controls;
+
+import javafx.embed.swing.JFXPanel;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import javax.swing.SwingUtilities;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+
+class SecPasswordFieldTest {
+
+ private SecPasswordField pwField = new SecPasswordField();
+
+ @BeforeAll
+ static void initJavaFx() throws InterruptedException {
+ final CountDownLatch latch = new CountDownLatch(1);
+ SwingUtilities.invokeLater(() -> {
+ new JFXPanel(); // initializes JavaFX environment
+ latch.countDown();
+ });
+
+ if (!latch.await(5L, TimeUnit.SECONDS)) {
+ throw new ExceptionInInitializerError();
+ }
+ }
+
+ @Nested
+ @DisplayName("Content Update Events")
+ class TextChange {
+
+ @Test
+ @DisplayName("\"ant\".append(\"eater\")")
+ public void append() {
+ pwField.setPassword("ant");
+ pwField.appendText("eater");
+
+ Assertions.assertEquals("anteater", pwField.getCharacters().toString());
+ }
+
+ @Test
+ @DisplayName("\"eater\".insert(0, \"ant\")")
+ public void insert1() {
+ pwField.setPassword("eater");
+ pwField.insertText(0, "ant");
+
+ Assertions.assertEquals("anteater", pwField.getCharacters().toString());
+ }
+
+ @Test
+ @DisplayName("\"anteater\".insert(3, \"b\")")
+ public void insert2() {
+ pwField.setPassword("anteater");
+ pwField.insertText(3, "b");
+
+ Assertions.assertEquals("antbeater", pwField.getCharacters().toString());
+ }
+
+ @Test
+ @DisplayName("\"anteater\".delete(0, 3)")
+ public void delete1() {
+ pwField.setPassword("anteater");
+ pwField.deleteText(0, 3);
+
+ Assertions.assertEquals("eater", pwField.getCharacters().toString());
+ }
+
+ @Test
+ @DisplayName("\"anteater\".delete(3, 8)")
+ public void delete2() {
+ pwField.setPassword("anteater");
+ pwField.deleteText(3, 8);
+
+ Assertions.assertEquals("ant", pwField.getCharacters().toString());
+ }
+
+ @Test
+ @DisplayName("\"anteater\".replace(0, 3, \"hand\")")
+ public void replace1() {
+ pwField.setPassword("anteater");
+ pwField.replaceText(0, 3, "hand");
+
+ Assertions.assertEquals("handeater", pwField.getCharacters().toString());
+ }
+
+ @Test
+ @DisplayName("\"anteater\".replace(3, 6, \"keep\")")
+ public void replace2() {
+ pwField.setPassword("anteater");
+ pwField.replaceText(3, 6, "keep");
+
+ Assertions.assertEquals("antkeeper", pwField.getCharacters().toString());
+ }
+
+ @Test
+ @DisplayName("\"anteater\".replace(0, 3, \"bee\")")
+ public void replace3() {
+ pwField.setPassword("anteater");
+ pwField.replaceText(0, 3, "bee");
+
+ Assertions.assertEquals("beeeater", pwField.getCharacters().toString());
+ }
+
+ }
+
+ @Test
+ @DisplayName("entering NFC string leads to NFC char[]")
+ public void enterNfcString() {
+ pwField.appendText("str\u00F6m"); // ström
+ pwField.insertText(0, "\u212Bng"); // Ång
+ pwField.appendText("\uD83D\uDCA9"); // 💩
+
+ CharSequence result = pwField.getCharacters();
+ Assertions.assertEquals('\u00C5', result.charAt(0));
+ Assertions.assertEquals('n', result.charAt(1));
+ Assertions.assertEquals('g', result.charAt(2));
+ Assertions.assertEquals('s', result.charAt(3));
+ Assertions.assertEquals('t', result.charAt(4));
+ Assertions.assertEquals('r', result.charAt(5));
+ Assertions.assertEquals('ö', result.charAt(6));
+ Assertions.assertEquals('m', result.charAt(7));
+ Assertions.assertEquals('\uD83D', result.charAt(8));
+ Assertions.assertEquals('\uDCA9', result.charAt(9));
+ }
+
+ @Test
+ @DisplayName("entering NFD string leads to NFC char[]")
+ public void enterNfdString() {
+ pwField.appendText("str\u006F\u0308m"); // ström
+ pwField.insertText(0, "\u0041\u030Ang"); // Ång
+ pwField.appendText("\uD83D\uDCA9"); // 💩
+
+ CharSequence result = pwField.getCharacters();
+ Assertions.assertEquals('\u00C5', result.charAt(0));
+ Assertions.assertEquals('n', result.charAt(1));
+ Assertions.assertEquals('g', result.charAt(2));
+ Assertions.assertEquals('s', result.charAt(3));
+ Assertions.assertEquals('t', result.charAt(4));
+ Assertions.assertEquals('r', result.charAt(5));
+ Assertions.assertEquals('ö', result.charAt(6));
+ Assertions.assertEquals('m', result.charAt(7));
+ Assertions.assertEquals('\uD83D', result.charAt(8));
+ Assertions.assertEquals('\uDCA9', result.charAt(9));
+ }
+
+ @Test
+ @DisplayName("test swipe char[]")
+ public void swipe() {
+ pwField.appendText("topSecret");
+
+ CharSequence result1 = pwField.getCharacters();
+ Assertions.assertEquals("topSecret", result1.toString());
+ pwField.swipe();
+ CharSequence result2 = pwField.getCharacters();
+ Assertions.assertEquals(" ", result1.toString());
+ Assertions.assertEquals("", result2.toString());
+ }
+
+}