diff --git a/cluster_format.md b/cluster_format.md new file mode 100644 index 0000000..5b0e3a1 --- /dev/null +++ b/cluster_format.md @@ -0,0 +1,10 @@ +# Cluster File Format Specification + +This document describes the format of serialized cluster files. It is arranged as a sequential list of the data that is stored in a file. + +* 4 bytes to indicate the version of the cluster file as an integer. +* 1 byte with value 1 to indicate that the contents are encrypted, or 0 if unencrypted. +* If encrypted: + * 8 bytes contain the salt value used when encrypting. + * 16 bytes contain the initialization vector used when encrypting. +* The rest of the file contains the cluster content. \ No newline at end of file diff --git a/src/main/java/nl/andrewlalis/crystalkeep/CrystalKeep.java b/src/main/java/nl/andrewlalis/crystalkeep/CrystalKeep.java index 9495923..d1c1749 100644 --- a/src/main/java/nl/andrewlalis/crystalkeep/CrystalKeep.java +++ b/src/main/java/nl/andrewlalis/crystalkeep/CrystalKeep.java @@ -6,6 +6,7 @@ import javafx.scene.Scene; import javafx.stage.Stage; import nl.andrewlalis.crystalkeep.control.MainViewController; import nl.andrewlalis.crystalkeep.model.Model; +import nl.andrewlalis.crystalkeep.util.ImageCache; import java.net.URL; @@ -22,6 +23,9 @@ public class CrystalKeep extends Application { var scene = new Scene(loader.load()); stage.setScene(scene); stage.setTitle("CrystalKeep"); + ImageCache.get("/nl/andrewlalis/crystalkeep/ui/images/cluster_node_icon.png", 32, 32).ifPresent(img -> { + stage.getIcons().add(img); + }); stage.sizeToScene(); model.setActiveCluster(null); // TODO: Auto load last cluster? stage.show(); diff --git a/src/main/java/nl/andrewlalis/crystalkeep/control/MainViewController.java b/src/main/java/nl/andrewlalis/crystalkeep/control/MainViewController.java index 1cce2b7..5c560ce 100644 --- a/src/main/java/nl/andrewlalis/crystalkeep/control/MainViewController.java +++ b/src/main/java/nl/andrewlalis/crystalkeep/control/MainViewController.java @@ -73,8 +73,11 @@ public class MainViewController implements ModelListener { try { if (password.isEmpty() || password.get().isEmpty()) { cluster = loader.loadUnencrypted(file.toPath()); + this.model.setActiveClusterPassword(null); } else { - cluster = loader.load(file.toPath(), password.get()); + char[] pw = password.get().toCharArray(); + cluster = loader.load(file.toPath(), pw); + this.model.setActiveClusterPassword(pw); } } catch (Exception e) { e.printStackTrace(); @@ -99,12 +102,15 @@ public class MainViewController implements ModelListener { if (file == null) return; path = file.toPath(); } - var password = this.promptPassword(); + char[] pw = this.model.getActiveClusterPassword(); + if (pw == null) { + pw = this.promptPassword().orElse("").toCharArray(); + } try { - if (password.isEmpty() || password.get().isEmpty()) { + if (pw.length == 0) { loader.saveUnencrypted(model.getActiveCluster(), path); } else { - loader.save(model.getActiveCluster(), path, password.get()); + loader.save(model.getActiveCluster(), path, pw); } } catch (Exception e) { e.printStackTrace(); diff --git a/src/main/java/nl/andrewlalis/crystalkeep/io/ClusterIO.java b/src/main/java/nl/andrewlalis/crystalkeep/io/ClusterIO.java index 26f24c5..725e538 100644 --- a/src/main/java/nl/andrewlalis/crystalkeep/io/ClusterIO.java +++ b/src/main/java/nl/andrewlalis/crystalkeep/io/ClusterIO.java @@ -1,5 +1,6 @@ package nl.andrewlalis.crystalkeep.io; +import nl.andrewlalis.crystalkeep.io.serialization.ByteUtils; import nl.andrewlalis.crystalkeep.io.serialization.ClusterSerializer; import nl.andrewlalis.crystalkeep.model.Cluster; @@ -33,6 +34,7 @@ import java.security.spec.KeySpec; */ public class ClusterIO { public static final Path CLUSTER_PATH = Path.of("clusters"); + public static final int FILE_VERSION = 1; private final SecureRandom random; @@ -53,14 +55,19 @@ public class ClusterIO { * @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 { + public Cluster load(Path path, char[] password) throws Exception { try (var is = Files.newInputStream(path)) { + int version = ByteUtils.readInt(is); + System.out.println("File version: " + version); + if (version < FILE_VERSION) { + System.err.println("Warning! Reading older file version: " + version); + } 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)); + cipher.init(Cipher.DECRYPT_MODE, this.getSecretKey(password, salt), new IvParameterSpec(iv)); try (CipherInputStream cis = new CipherInputStream(is, cipher)) { return ClusterSerializer.readCluster(cis); } @@ -76,15 +83,16 @@ public class ClusterIO { * @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 { + public void save(Cluster cluster, Path path, char[] 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)); + cipher.init(Cipher.ENCRYPT_MODE, this.getSecretKey(password, salt), new IvParameterSpec(iv)); try (OutputStream fos = Files.newOutputStream(path)) { + ByteUtils.writeInt(fos, FILE_VERSION); fos.write(1); fos.write(salt); fos.write(iv); @@ -103,6 +111,7 @@ public class ClusterIO { public void saveUnencrypted(Cluster cluster, Path path) throws IOException { Files.createDirectories(path.getParent()); try (var os = Files.newOutputStream(path)) { + ByteUtils.writeInt(os, FILE_VERSION); os.write(0); ClusterSerializer.writeCluster(cluster, os); } @@ -116,6 +125,7 @@ public class ClusterIO { */ public Cluster loadUnencrypted(Path path) throws IOException { try (var is = Files.newInputStream(path)) { + int version = ByteUtils.readInt(is); int encryptionFlag = is.read(); if (encryptionFlag == 1) throw new IOException("File is encrypted."); return ClusterSerializer.readCluster(is); diff --git a/src/main/java/nl/andrewlalis/crystalkeep/io/serialization/ByteUtils.java b/src/main/java/nl/andrewlalis/crystalkeep/io/serialization/ByteUtils.java index 4eec879..5d4f0a6 100644 --- a/src/main/java/nl/andrewlalis/crystalkeep/io/serialization/ByteUtils.java +++ b/src/main/java/nl/andrewlalis/crystalkeep/io/serialization/ByteUtils.java @@ -17,6 +17,14 @@ public class ByteUtils { return ByteBuffer.wrap(bytes).getInt(); } + public static int readInt(InputStream is) throws IOException { + return toInt(is.readNBytes(4)); + } + + public static void writeInt(OutputStream os, int value) throws IOException { + os.write(toBytes(value)); + } + public static void writeLengthPrefixed(byte[] bytes, OutputStream os) throws IOException { os.write(toBytes(bytes.length)); os.write(bytes); diff --git a/src/main/java/nl/andrewlalis/crystalkeep/model/Model.java b/src/main/java/nl/andrewlalis/crystalkeep/model/Model.java index 83f9169..2490e62 100644 --- a/src/main/java/nl/andrewlalis/crystalkeep/model/Model.java +++ b/src/main/java/nl/andrewlalis/crystalkeep/model/Model.java @@ -8,6 +8,7 @@ import java.util.Set; public class Model { private Cluster activeCluster; private Path activeClusterPath; + private char[] activeClusterPassword; private final Set listeners = new HashSet<>(); @@ -32,6 +33,14 @@ public class Model { this.activeClusterPath = activeClusterPath; } + public char[] getActiveClusterPassword() { + return activeClusterPassword; + } + + public void setActiveClusterPassword(char[] activeClusterPassword) { + this.activeClusterPassword = activeClusterPassword; + } + public void notifyListeners() { this.listeners.forEach(ModelListener::activeClusterUpdated); } diff --git a/src/test/java/nl/andrewlalis/crystalkeep/io/ClusterIOTest.java b/src/test/java/nl/andrewlalis/crystalkeep/io/ClusterIOTest.java index b854761..8704ce9 100644 --- a/src/test/java/nl/andrewlalis/crystalkeep/io/ClusterIOTest.java +++ b/src/test/java/nl/andrewlalis/crystalkeep/io/ClusterIOTest.java @@ -49,7 +49,7 @@ public class ClusterIOTest { Cluster loaded = io.loadUnencrypted(file); assertEquals(cluster, loaded); // Test attempting to load an encrypted file, which throws an IOException. - io.save(cluster, file, "testpass"); + io.save(cluster, file, "testpass".toCharArray()); assertThrows(IOException.class, () -> io.loadUnencrypted(file)); } @@ -59,14 +59,14 @@ public class ClusterIOTest { 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"); + io.save(cluster, file, "test".toCharArray()); + Cluster loaded = io.load(file, "test".toCharArray()); 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")); + assertThrows(IOException.class, () -> io.load(file, "test".toCharArray())); // 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")); + io.save(cluster, file, "other".toCharArray()); + assertThrows(Exception.class, () -> io.load(file, "not_password".toCharArray())); } }