d = new Dialog<>();
+ d.setTitle("Enter Password");
+ d.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
+
+ PasswordField pwField = new PasswordField();
+ VBox content = new VBox(10);
+ content.setAlignment(Pos.CENTER);
+ content.getChildren().addAll(new Label("Enter password"), pwField);
+ d.getDialogPane().setContent(content);
+ d.setResultConverter(param -> {
+ if (param == ButtonType.OK) {
+ return pwField.getText();
+ }
+ return null;
+ });
+ return d.showAndWait();
+ }
}
diff --git a/src/main/java/nl/andrewlalis/crystalkeep/io/ClusterIO.java b/src/main/java/nl/andrewlalis/crystalkeep/io/ClusterIO.java
new file mode 100644
index 0000000..26f24c5
--- /dev/null
+++ b/src/main/java/nl/andrewlalis/crystalkeep/io/ClusterIO.java
@@ -0,0 +1,130 @@
+package nl.andrewlalis.crystalkeep.io;
+
+import nl.andrewlalis.crystalkeep.io.serialization.ClusterSerializer;
+import nl.andrewlalis.crystalkeep.model.Cluster;
+
+import javax.crypto.*;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.PBEKeySpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.GeneralSecurityException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.KeySpec;
+
+/**
+ * This class is responsible for reading and writing clusters to files, which
+ * may be encrypted with a password-based AES-256 CBC.
+ *
+ *
+ * All saved files start with a single byte with value 1 to indicate that
+ * the file is encrypted, or 0 if it is not encrypted. If encrypted, the
+ * next 8 bytes will contain the salt that was used to encrypt the file.
+ * Encrypted files will then contain a 16 byte initialization vector.
+ * The remaining part of the file is simply the contents.
+ *
+ *
+ * @see nl.andrewlalis.crystalkeep.io.serialization.ClusterSerializer
+ */
+public class ClusterIO {
+ public static final Path CLUSTER_PATH = Path.of("clusters");
+
+ private final SecureRandom random;
+
+ public ClusterIO() {
+ try {
+ this.random = SecureRandom.getInstanceStrong();
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException("Could not initialize secure random.", e);
+ }
+ }
+
+ /**
+ * Loads an encrypted cluster from the given path, using the given password
+ * as the secret key value.
+ * @param path The path of the file to load from.
+ * @param password The password to use to decrypt the contents.
+ * @return The cluster that was loaded.
+ * @throws Exception If the file could not be found, or could not be
+ * decrypted and read properly.
+ */
+ public Cluster load(Path path, String password) throws Exception {
+ try (var is = Files.newInputStream(path)) {
+ int encryptionFlag = is.read();
+ if (encryptionFlag == 0) throw new IOException("File is not encrypted.");
+ byte[] salt = is.readNBytes(8);
+ byte[] iv = is.readNBytes(16);
+ Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+ cipher.init(Cipher.DECRYPT_MODE, this.getSecretKey(password.toCharArray(), salt), new IvParameterSpec(iv));
+ try (CipherInputStream cis = new CipherInputStream(is, cipher)) {
+ return ClusterSerializer.readCluster(cis);
+ }
+ }
+ }
+
+ /**
+ * Saves a cluster to an encrypted file, using the given password as the
+ * secret key.
+ * @param cluster The cluster to save.
+ * @param path The path to the file to save the cluster at.
+ * @param password The password to use when saving.
+ * @throws IOException If an error occurs when writing the file.
+ * @throws GeneralSecurityException If we could not obtain a secret key.
+ */
+ public void save(Cluster cluster, Path path, String password) throws IOException, GeneralSecurityException {
+ Files.createDirectories(path.getParent());
+ Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+ byte[] iv = new byte[16];
+ this.random.nextBytes(iv);
+ byte[] salt = new byte[8];
+ this.random.nextBytes(salt);
+ cipher.init(Cipher.ENCRYPT_MODE, this.getSecretKey(password.toCharArray(), salt), new IvParameterSpec(iv));
+ try (OutputStream fos = Files.newOutputStream(path)) {
+ fos.write(1);
+ fos.write(salt);
+ fos.write(iv);
+ try (CipherOutputStream cos = new CipherOutputStream(fos, cipher)) {
+ ClusterSerializer.writeCluster(cluster, cos);
+ }
+ }
+ }
+
+ /**
+ * Saves an unencrypted cluster file.
+ * @param cluster The cluster to save.
+ * @param path The file to save the cluster to.
+ * @throws IOException If the file could not be saved.
+ */
+ public void saveUnencrypted(Cluster cluster, Path path) throws IOException {
+ Files.createDirectories(path.getParent());
+ try (var os = Files.newOutputStream(path)) {
+ os.write(0);
+ ClusterSerializer.writeCluster(cluster, os);
+ }
+ }
+
+ /**
+ * Loads an unencrypted cluster from a file.
+ * @param path The path to load the cluster from.
+ * @return The cluster that was loaded.
+ * @throws IOException If the file could not be loaded.
+ */
+ public Cluster loadUnencrypted(Path path) throws IOException {
+ try (var is = Files.newInputStream(path)) {
+ int encryptionFlag = is.read();
+ if (encryptionFlag == 1) throw new IOException("File is encrypted.");
+ return ClusterSerializer.readCluster(is);
+ }
+ }
+
+ private SecretKey getSecretKey(char[] password, byte[] salt) throws NoSuchAlgorithmException, InvalidKeySpecException {
+ SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
+ KeySpec spec = new PBEKeySpec(password, salt, 65536, 256);
+ return new SecretKeySpec(factory.generateSecret(spec).getEncoded(), "AES");
+ }
+}
diff --git a/src/main/java/nl/andrewlalis/crystalkeep/model/serialization/ByteUtils.java b/src/main/java/nl/andrewlalis/crystalkeep/io/serialization/ByteUtils.java
similarity index 95%
rename from src/main/java/nl/andrewlalis/crystalkeep/model/serialization/ByteUtils.java
rename to src/main/java/nl/andrewlalis/crystalkeep/io/serialization/ByteUtils.java
index 397a1a2..4eec879 100644
--- a/src/main/java/nl/andrewlalis/crystalkeep/model/serialization/ByteUtils.java
+++ b/src/main/java/nl/andrewlalis/crystalkeep/io/serialization/ByteUtils.java
@@ -1,4 +1,4 @@
-package nl.andrewlalis.crystalkeep.model.serialization;
+package nl.andrewlalis.crystalkeep.io.serialization;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
diff --git a/src/main/java/nl/andrewlalis/crystalkeep/model/serialization/ClusterSerializer.java b/src/main/java/nl/andrewlalis/crystalkeep/io/serialization/ClusterSerializer.java
similarity index 96%
rename from src/main/java/nl/andrewlalis/crystalkeep/model/serialization/ClusterSerializer.java
rename to src/main/java/nl/andrewlalis/crystalkeep/io/serialization/ClusterSerializer.java
index 1e5fe2e..8603034 100644
--- a/src/main/java/nl/andrewlalis/crystalkeep/model/serialization/ClusterSerializer.java
+++ b/src/main/java/nl/andrewlalis/crystalkeep/io/serialization/ClusterSerializer.java
@@ -1,4 +1,4 @@
-package nl.andrewlalis.crystalkeep.model.serialization;
+package nl.andrewlalis.crystalkeep.io.serialization;
import nl.andrewlalis.crystalkeep.model.Cluster;
import nl.andrewlalis.crystalkeep.model.Shard;
@@ -16,7 +16,7 @@ import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
-import static nl.andrewlalis.crystalkeep.model.serialization.ByteUtils.toInt;
+import static nl.andrewlalis.crystalkeep.io.serialization.ByteUtils.toInt;
/**
* This serializer class offers some methods for reading and writing clusters
diff --git a/src/main/java/nl/andrewlalis/crystalkeep/model/serialization/ShardSerializer.java b/src/main/java/nl/andrewlalis/crystalkeep/io/serialization/ShardSerializer.java
similarity index 85%
rename from src/main/java/nl/andrewlalis/crystalkeep/model/serialization/ShardSerializer.java
rename to src/main/java/nl/andrewlalis/crystalkeep/io/serialization/ShardSerializer.java
index b789351..1106aec 100644
--- a/src/main/java/nl/andrewlalis/crystalkeep/model/serialization/ShardSerializer.java
+++ b/src/main/java/nl/andrewlalis/crystalkeep/io/serialization/ShardSerializer.java
@@ -1,4 +1,4 @@
-package nl.andrewlalis.crystalkeep.model.serialization;
+package nl.andrewlalis.crystalkeep.io.serialization;
import nl.andrewlalis.crystalkeep.model.Shard;
diff --git a/src/main/java/nl/andrewlalis/crystalkeep/model/serialization/ClusterLoader.java b/src/main/java/nl/andrewlalis/crystalkeep/model/serialization/ClusterLoader.java
deleted file mode 100644
index 8abfc54..0000000
--- a/src/main/java/nl/andrewlalis/crystalkeep/model/serialization/ClusterLoader.java
+++ /dev/null
@@ -1,74 +0,0 @@
-package nl.andrewlalis.crystalkeep.model.serialization;
-
-import javafx.geometry.Pos;
-import javafx.scene.control.*;
-import javafx.scene.layout.HBox;
-import javafx.scene.layout.VBox;
-import nl.andrewlalis.crystalkeep.model.Cluster;
-
-import javax.crypto.Cipher;
-import javax.crypto.SecretKey;
-import javax.crypto.SecretKeyFactory;
-import javax.crypto.spec.IvParameterSpec;
-import javax.crypto.spec.PBEKeySpec;
-import javax.crypto.spec.SecretKeySpec;
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.security.GeneralSecurityException;
-import java.security.NoSuchAlgorithmException;
-import java.security.spec.InvalidKeySpecException;
-import java.security.spec.KeySpec;
-import java.util.Optional;
-
-public class ClusterLoader {
- public static final Path CLUSTER_PATH = Path.of("clusters");
- public static final Path DEFAULT_CLUSTER = CLUSTER_PATH.resolve("default.cts");
- private static final byte[] SALT = "zf9i78vy".getBytes(StandardCharsets.UTF_8);
- private static final byte[] IV = "Fafioje;a324fsde".getBytes(StandardCharsets.UTF_8);
-
- public Optional promptPassword() {
- Dialog d = new Dialog<>();
- d.setTitle("Enter Password");
- d.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
-
- PasswordField pwField = new PasswordField();
- VBox content = new VBox(10);
- content.setAlignment(Pos.CENTER);
- content.getChildren().addAll(new Label("Enter password"), pwField);
- d.getDialogPane().setContent(content);
- d.setResultConverter(param -> {
- if (param == ButtonType.OK) {
- return pwField.getText();
- }
- return null;
- });
- return d.showAndWait();
- }
-
- public Cluster load(Path path, String password) throws Exception {
- Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
- cipher.init(Cipher.DECRYPT_MODE, this.getSecretKey(password), new IvParameterSpec(IV));
- byte[] raw = Files.readAllBytes(path);
- return ClusterSerializer.readCluster(new ByteArrayInputStream(cipher.doFinal(raw)));
- }
-
- public void save(Cluster cluster, Path path, String password) throws IOException, GeneralSecurityException {
- Files.createDirectories(CLUSTER_PATH);
- ByteArrayOutputStream bos = new ByteArrayOutputStream();
- ClusterSerializer.writeCluster(cluster, bos);
- Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
- cipher.init(Cipher.ENCRYPT_MODE, this.getSecretKey(password), new IvParameterSpec(IV));
- Files.write(path, cipher.doFinal(bos.toByteArray()));
- bos.close();
- }
-
- private SecretKey getSecretKey(String password) throws NoSuchAlgorithmException, InvalidKeySpecException {
- SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
- KeySpec spec = new PBEKeySpec(password.toCharArray(), SALT, 65536, 256);
- return new SecretKeySpec(factory.generateSecret(spec).getEncoded(), "AES");
- }
-}
diff --git a/src/main/java/nl/andrewlalis/crystalkeep/model/shards/LoginCredentialsShard.java b/src/main/java/nl/andrewlalis/crystalkeep/model/shards/LoginCredentialsShard.java
index 5189dba..cc4ce07 100644
--- a/src/main/java/nl/andrewlalis/crystalkeep/model/shards/LoginCredentialsShard.java
+++ b/src/main/java/nl/andrewlalis/crystalkeep/model/shards/LoginCredentialsShard.java
@@ -2,8 +2,8 @@ package nl.andrewlalis.crystalkeep.model.shards;
import nl.andrewlalis.crystalkeep.model.Shard;
import nl.andrewlalis.crystalkeep.model.ShardType;
-import nl.andrewlalis.crystalkeep.model.serialization.ByteUtils;
-import nl.andrewlalis.crystalkeep.model.serialization.ShardSerializer;
+import nl.andrewlalis.crystalkeep.io.serialization.ByteUtils;
+import nl.andrewlalis.crystalkeep.io.serialization.ShardSerializer;
import java.io.IOException;
import java.io.InputStream;
diff --git a/src/main/java/nl/andrewlalis/crystalkeep/model/shards/TextShard.java b/src/main/java/nl/andrewlalis/crystalkeep/model/shards/TextShard.java
index 0d42427..729d94c 100644
--- a/src/main/java/nl/andrewlalis/crystalkeep/model/shards/TextShard.java
+++ b/src/main/java/nl/andrewlalis/crystalkeep/model/shards/TextShard.java
@@ -2,8 +2,8 @@ package nl.andrewlalis.crystalkeep.model.shards;
import nl.andrewlalis.crystalkeep.model.Shard;
import nl.andrewlalis.crystalkeep.model.ShardType;
-import nl.andrewlalis.crystalkeep.model.serialization.ByteUtils;
-import nl.andrewlalis.crystalkeep.model.serialization.ShardSerializer;
+import nl.andrewlalis.crystalkeep.io.serialization.ByteUtils;
+import nl.andrewlalis.crystalkeep.io.serialization.ShardSerializer;
import java.io.IOException;
import java.io.InputStream;
diff --git a/src/main/java/nl/andrewlalis/crystalkeep/view/shard_details/LoginCredentialsViewModel.java b/src/main/java/nl/andrewlalis/crystalkeep/view/shard_details/LoginCredentialsViewModel.java
index 989f361..887c0d4 100644
--- a/src/main/java/nl/andrewlalis/crystalkeep/view/shard_details/LoginCredentialsViewModel.java
+++ b/src/main/java/nl/andrewlalis/crystalkeep/view/shard_details/LoginCredentialsViewModel.java
@@ -2,11 +2,11 @@ package nl.andrewlalis.crystalkeep.view.shard_details;
import javafx.geometry.Insets;
import javafx.scene.Node;
-import javafx.scene.control.CheckBox;
-import javafx.scene.control.Label;
-import javafx.scene.control.PasswordField;
-import javafx.scene.control.TextField;
+import javafx.scene.control.*;
+import javafx.scene.input.Clipboard;
+import javafx.scene.input.ClipboardContent;
import javafx.scene.layout.GridPane;
+import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import nl.andrewlalis.crystalkeep.model.shards.LoginCredentialsShard;
@@ -47,13 +47,28 @@ public class LoginCredentialsViewModel extends ShardViewModel {
passwordField.setVisible(!newValue);
rawPasswordField.setVisible(newValue);
});
- gp.add(showPasswordCheckbox, 1, 2);
+ var copyPasswordButton = new Button("Copy to Clipboard");
+ copyPasswordButton.setOnAction(event -> {
+ ClipboardContent content = new ClipboardContent();
+ content.putString(shard.getPassword());
+ Clipboard.getSystemClipboard().setContent(content);
+ });
+ var typePasswordButton = new Button("Auto Type");
+ typePasswordButton.setOnAction(event -> {
+ System.out.println("Not yet implemented.");
+ });
+ typePasswordButton.setDisable(true);
+ passwordActionsPane.getChildren().addAll(showPasswordCheckbox, copyPasswordButton, typePasswordButton);
+
+ gp.add(passwordActionsPane, 1, 2);
return gp;
}
}
diff --git a/src/test/java/nl/andrewlalis/crystalkeep/io/ClusterIOTest.java b/src/test/java/nl/andrewlalis/crystalkeep/io/ClusterIOTest.java
new file mode 100644
index 0000000..b854761
--- /dev/null
+++ b/src/test/java/nl/andrewlalis/crystalkeep/io/ClusterIOTest.java
@@ -0,0 +1,72 @@
+package nl.andrewlalis.crystalkeep.io;
+
+import nl.andrewlalis.crystalkeep.model.Cluster;
+import nl.andrewlalis.crystalkeep.model.shards.LoginCredentialsShard;
+import nl.andrewlalis.crystalkeep.model.shards.TextShard;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.GeneralSecurityException;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests the functionality of the {@link ClusterIO} class in loading and saving
+ * clusters.
+ */
+public class ClusterIOTest {
+ public static List testClusters() {
+ List clusters = new ArrayList<>();
+ clusters.add(new Cluster("Root"));
+ Cluster c1 = new Cluster("C1");
+ c1.addShard(new TextShard("text", LocalDateTime.now(), "hello world!"));
+ clusters.add(c1);
+ Cluster c2 = new Cluster("C2");
+ Cluster c2Nested = new Cluster("Nested");
+ c2Nested.addShard(new LoginCredentialsShard("login", LocalDateTime.now(), "andrew", "pass"));
+ c2Nested.addShard(new TextShard("data", LocalDateTime.now(), "test, 1, 2, 3"));
+ c2.addCluster(c2Nested);
+ c2.addShard(new TextShard("more_data", LocalDateTime.now(), "Antidisestablishmentarianism"));
+ clusters.add(c2);
+ return clusters;
+ }
+
+ @ParameterizedTest
+ @MethodSource("testClusters")
+ public void testUnencryptedIO(Cluster cluster) throws IOException, GeneralSecurityException {
+ Path file = Files.createTempFile(UUID.randomUUID().toString(), "cts");
+ var io = new ClusterIO();
+ // Test normal save and load.
+ io.saveUnencrypted(cluster, file);
+ Cluster loaded = io.loadUnencrypted(file);
+ assertEquals(cluster, loaded);
+ // Test attempting to load an encrypted file, which throws an IOException.
+ io.save(cluster, file, "testpass");
+ assertThrows(IOException.class, () -> io.loadUnencrypted(file));
+ }
+
+ @ParameterizedTest
+ @MethodSource("testClusters")
+ public void testEncryptedIO(Cluster cluster) throws Exception {
+ Path file = Files.createTempFile(UUID.randomUUID().toString(), "cts");
+ var io = new ClusterIO();
+ // Test normal save and load.
+ io.save(cluster, file, "test");
+ Cluster loaded = io.load(file, "test");
+ assertEquals(cluster, loaded);
+ // Test attempting to load an unencrypted file, which throws an IOException.
+ io.saveUnencrypted(cluster, file);
+ assertThrows(IOException.class, () -> io.load(file, "test"));
+ // Test attempting to load an encrypted file with the wrong password. An exception is thrown when reading it.
+ io.save(cluster, file, "other");
+ assertThrows(Exception.class, () -> io.load(file, "not_password"));
+ }
+}
diff --git a/src/test/java/nl/andrewlalis/crystalkeep/model/serialization/ClusterSerializerTest.java b/src/test/java/nl/andrewlalis/crystalkeep/io/serialization/ClusterSerializerTest.java
similarity index 95%
rename from src/test/java/nl/andrewlalis/crystalkeep/model/serialization/ClusterSerializerTest.java
rename to src/test/java/nl/andrewlalis/crystalkeep/io/serialization/ClusterSerializerTest.java
index 7af4f6a..19f38e2 100644
--- a/src/test/java/nl/andrewlalis/crystalkeep/model/serialization/ClusterSerializerTest.java
+++ b/src/test/java/nl/andrewlalis/crystalkeep/io/serialization/ClusterSerializerTest.java
@@ -1,4 +1,4 @@
-package nl.andrewlalis.crystalkeep.model.serialization;
+package nl.andrewlalis.crystalkeep.io.serialization;
import nl.andrewlalis.crystalkeep.model.Cluster;
import nl.andrewlalis.crystalkeep.model.Shard;
@@ -22,7 +22,7 @@ import static org.junit.jupiter.api.Assertions.*;
* transforming key model components to and from byte arrays for storage.
*/
public class ClusterSerializerTest {
- private static List testShardIOData() {
+ public static List testShardIOData() {
return List.of(
new TextShard("a", LocalDateTime.now(), "Hello world!"),
new TextShard("Another", LocalDateTime.now(), "Testing"),
@@ -58,7 +58,7 @@ public class ClusterSerializerTest {
assertArrayEquals(data, data2, "Serialized data from a shard should not change.");
}
- private static List testClusterIOData() {
+ public static List testClusterIOData() {
List clusters = new ArrayList<>();
clusters.add(new Cluster("test"));