Added ClusterIOTest and improved some stuff.

This commit is contained in:
Andrew Lalis 2021-05-31 00:17:31 +02:00
parent 4e401d98a5
commit 0c5cd95905
12 changed files with 283 additions and 110 deletions

View File

@ -36,6 +36,11 @@
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
</plugin>
</plugins>
</build>
@ -60,7 +65,7 @@
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.7.0</version>
<version>5.7.1</version>
<scope>test</scope>
</dependency>
</dependencies>

View File

@ -2,21 +2,19 @@ package nl.andrewlalis.crystalkeep.control;
import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.scene.control.Alert;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import nl.andrewlalis.crystalkeep.io.ClusterIO;
import nl.andrewlalis.crystalkeep.model.*;
import nl.andrewlalis.crystalkeep.model.serialization.ClusterLoader;
import nl.andrewlalis.crystalkeep.view.ClusterTreeItem;
import nl.andrewlalis.crystalkeep.view.CrystalItemTreeCell;
import nl.andrewlalis.crystalkeep.view.ShardTreeItem;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.util.Optional;
public class MainViewController implements ModelListener {
private Model model;
@ -65,42 +63,50 @@ public class MainViewController implements ModelListener {
public void load() {
FileChooser chooser = new FileChooser();
chooser.setTitle("Load a Cluster");
chooser.setInitialDirectory(ClusterLoader.CLUSTER_PATH.toFile());
chooser.setInitialDirectory(ClusterIO.CLUSTER_PATH.toFile());
chooser.setSelectedExtensionFilter(new FileChooser.ExtensionFilter("Cluster Files", "cts"));
File file = chooser.showOpenDialog(this.clusterTreeView.getScene().getWindow());
if (file == null) return;
ClusterLoader loader = new ClusterLoader();
var password = loader.promptPassword();
if (password.isEmpty() || password.get().isEmpty()) return;
ClusterIO loader = new ClusterIO();
var password = this.promptPassword();
Cluster cluster;
try {
var cluster = loader.load(file.toPath(), password.get());
model.setActiveCluster(cluster);
model.setActiveClusterPath(file.toPath());
if (password.isEmpty() || password.get().isEmpty()) {
cluster = loader.loadUnencrypted(file.toPath());
} else {
cluster = loader.load(file.toPath(), password.get());
}
} catch (Exception e) {
e.printStackTrace();
new Alert(Alert.AlertType.WARNING, "Could not load cluster.").showAndWait();
return;
}
model.setActiveCluster(cluster);
model.setActiveClusterPath(file.toPath());
}
@FXML
public void save() {
if (model.getActiveCluster() == null) return;
ClusterLoader loader = new ClusterLoader();
ClusterIO loader = new ClusterIO();
Path path = model.getActiveClusterPath();
if (path == null) {
FileChooser chooser = new FileChooser();
chooser.setTitle("Save Cluster");
chooser.setInitialDirectory(ClusterLoader.CLUSTER_PATH.toFile());
chooser.setInitialDirectory(ClusterIO.CLUSTER_PATH.toFile());
chooser.setSelectedExtensionFilter(new FileChooser.ExtensionFilter("Cluster Files", "cts"));
File file = chooser.showSaveDialog(this.clusterTreeView.getScene().getWindow());
if (file == null) return;
path = file.toPath();
}
var password = loader.promptPassword();
if (password.isEmpty() || password.get().isEmpty()) return;
var password = this.promptPassword();
try {
new ClusterLoader().save(model.getActiveCluster(), path, password.get());
} catch (IOException | GeneralSecurityException e) {
if (password.isEmpty() || password.get().isEmpty()) {
loader.saveUnencrypted(model.getActiveCluster(), path);
} else {
loader.save(model.getActiveCluster(), path, password.get());
}
} catch (Exception e) {
e.printStackTrace();
var alert = new Alert(Alert.AlertType.ERROR, "Could not save cluster.");
alert.showAndWait();
@ -113,4 +119,23 @@ public class MainViewController implements ModelListener {
model.setActiveCluster(c);
model.setActiveClusterPath(null);
}
private Optional<String> promptPassword() {
Dialog<String> 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();
}
}

View File

@ -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.
*
* <p>
* 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.
* </p>
*
* @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");
}
}

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.crystalkeep.model.serialization;
package nl.andrewlalis.crystalkeep.io.serialization;
import java.io.ByteArrayOutputStream;
import java.io.IOException;

View File

@ -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

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.crystalkeep.model.serialization;
package nl.andrewlalis.crystalkeep.io.serialization;
import nl.andrewlalis.crystalkeep.model.Shard;

View File

@ -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<String> promptPassword() {
Dialog<String> 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");
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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<LoginCredentialsSh
passwordsContainer.getChildren().add(passwordField);
passwordsContainer.getChildren().add(rawPasswordField);
gp.add(passwordsContainer, 1, 1);
var passwordActionsPane = new HBox(5);
var showPasswordCheckbox = new CheckBox("Show password");
showPasswordCheckbox.setSelected(false);
showPasswordCheckbox.selectedProperty().addListener((observable, oldValue, newValue) -> {
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;
}
}

View File

@ -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<Cluster> testClusters() {
List<Cluster> 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"));
}
}

View File

@ -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<Shard> testShardIOData() {
public static List<Shard> 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<Cluster> testClusterIOData() {
public static List<Cluster> testClusterIOData() {
List<Cluster> clusters = new ArrayList<>();
clusters.add(new Cluster("test"));