Added version number to file format.
This commit is contained in:
parent
3c6c165bc6
commit
50535d4bc0
|
@ -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.
|
|
@ -6,6 +6,7 @@ import javafx.scene.Scene;
|
||||||
import javafx.stage.Stage;
|
import javafx.stage.Stage;
|
||||||
import nl.andrewlalis.crystalkeep.control.MainViewController;
|
import nl.andrewlalis.crystalkeep.control.MainViewController;
|
||||||
import nl.andrewlalis.crystalkeep.model.Model;
|
import nl.andrewlalis.crystalkeep.model.Model;
|
||||||
|
import nl.andrewlalis.crystalkeep.util.ImageCache;
|
||||||
|
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
|
|
||||||
|
@ -22,6 +23,9 @@ public class CrystalKeep extends Application {
|
||||||
var scene = new Scene(loader.load());
|
var scene = new Scene(loader.load());
|
||||||
stage.setScene(scene);
|
stage.setScene(scene);
|
||||||
stage.setTitle("CrystalKeep");
|
stage.setTitle("CrystalKeep");
|
||||||
|
ImageCache.get("/nl/andrewlalis/crystalkeep/ui/images/cluster_node_icon.png", 32, 32).ifPresent(img -> {
|
||||||
|
stage.getIcons().add(img);
|
||||||
|
});
|
||||||
stage.sizeToScene();
|
stage.sizeToScene();
|
||||||
model.setActiveCluster(null); // TODO: Auto load last cluster?
|
model.setActiveCluster(null); // TODO: Auto load last cluster?
|
||||||
stage.show();
|
stage.show();
|
||||||
|
|
|
@ -73,8 +73,11 @@ public class MainViewController implements ModelListener {
|
||||||
try {
|
try {
|
||||||
if (password.isEmpty() || password.get().isEmpty()) {
|
if (password.isEmpty() || password.get().isEmpty()) {
|
||||||
cluster = loader.loadUnencrypted(file.toPath());
|
cluster = loader.loadUnencrypted(file.toPath());
|
||||||
|
this.model.setActiveClusterPassword(null);
|
||||||
} else {
|
} 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) {
|
} catch (Exception e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
|
@ -99,12 +102,15 @@ public class MainViewController implements ModelListener {
|
||||||
if (file == null) return;
|
if (file == null) return;
|
||||||
path = file.toPath();
|
path = file.toPath();
|
||||||
}
|
}
|
||||||
var password = this.promptPassword();
|
char[] pw = this.model.getActiveClusterPassword();
|
||||||
|
if (pw == null) {
|
||||||
|
pw = this.promptPassword().orElse("").toCharArray();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (password.isEmpty() || password.get().isEmpty()) {
|
if (pw.length == 0) {
|
||||||
loader.saveUnencrypted(model.getActiveCluster(), path);
|
loader.saveUnencrypted(model.getActiveCluster(), path);
|
||||||
} else {
|
} else {
|
||||||
loader.save(model.getActiveCluster(), path, password.get());
|
loader.save(model.getActiveCluster(), path, pw);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package nl.andrewlalis.crystalkeep.io;
|
package nl.andrewlalis.crystalkeep.io;
|
||||||
|
|
||||||
|
import nl.andrewlalis.crystalkeep.io.serialization.ByteUtils;
|
||||||
import nl.andrewlalis.crystalkeep.io.serialization.ClusterSerializer;
|
import nl.andrewlalis.crystalkeep.io.serialization.ClusterSerializer;
|
||||||
import nl.andrewlalis.crystalkeep.model.Cluster;
|
import nl.andrewlalis.crystalkeep.model.Cluster;
|
||||||
|
|
||||||
|
@ -33,6 +34,7 @@ import java.security.spec.KeySpec;
|
||||||
*/
|
*/
|
||||||
public class ClusterIO {
|
public class ClusterIO {
|
||||||
public static final Path CLUSTER_PATH = Path.of("clusters");
|
public static final Path CLUSTER_PATH = Path.of("clusters");
|
||||||
|
public static final int FILE_VERSION = 1;
|
||||||
|
|
||||||
private final SecureRandom random;
|
private final SecureRandom random;
|
||||||
|
|
||||||
|
@ -53,14 +55,19 @@ public class ClusterIO {
|
||||||
* @throws Exception If the file could not be found, or could not be
|
* @throws Exception If the file could not be found, or could not be
|
||||||
* decrypted and read properly.
|
* 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)) {
|
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();
|
int encryptionFlag = is.read();
|
||||||
if (encryptionFlag == 0) throw new IOException("File is not encrypted.");
|
if (encryptionFlag == 0) throw new IOException("File is not encrypted.");
|
||||||
byte[] salt = is.readNBytes(8);
|
byte[] salt = is.readNBytes(8);
|
||||||
byte[] iv = is.readNBytes(16);
|
byte[] iv = is.readNBytes(16);
|
||||||
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
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)) {
|
try (CipherInputStream cis = new CipherInputStream(is, cipher)) {
|
||||||
return ClusterSerializer.readCluster(cis);
|
return ClusterSerializer.readCluster(cis);
|
||||||
}
|
}
|
||||||
|
@ -76,15 +83,16 @@ public class ClusterIO {
|
||||||
* @throws IOException If an error occurs when writing the file.
|
* @throws IOException If an error occurs when writing the file.
|
||||||
* @throws GeneralSecurityException If we could not obtain a secret key.
|
* @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());
|
Files.createDirectories(path.getParent());
|
||||||
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
||||||
byte[] iv = new byte[16];
|
byte[] iv = new byte[16];
|
||||||
this.random.nextBytes(iv);
|
this.random.nextBytes(iv);
|
||||||
byte[] salt = new byte[8];
|
byte[] salt = new byte[8];
|
||||||
this.random.nextBytes(salt);
|
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)) {
|
try (OutputStream fos = Files.newOutputStream(path)) {
|
||||||
|
ByteUtils.writeInt(fos, FILE_VERSION);
|
||||||
fos.write(1);
|
fos.write(1);
|
||||||
fos.write(salt);
|
fos.write(salt);
|
||||||
fos.write(iv);
|
fos.write(iv);
|
||||||
|
@ -103,6 +111,7 @@ public class ClusterIO {
|
||||||
public void saveUnencrypted(Cluster cluster, Path path) throws IOException {
|
public void saveUnencrypted(Cluster cluster, Path path) throws IOException {
|
||||||
Files.createDirectories(path.getParent());
|
Files.createDirectories(path.getParent());
|
||||||
try (var os = Files.newOutputStream(path)) {
|
try (var os = Files.newOutputStream(path)) {
|
||||||
|
ByteUtils.writeInt(os, FILE_VERSION);
|
||||||
os.write(0);
|
os.write(0);
|
||||||
ClusterSerializer.writeCluster(cluster, os);
|
ClusterSerializer.writeCluster(cluster, os);
|
||||||
}
|
}
|
||||||
|
@ -116,6 +125,7 @@ public class ClusterIO {
|
||||||
*/
|
*/
|
||||||
public Cluster loadUnencrypted(Path path) throws IOException {
|
public Cluster loadUnencrypted(Path path) throws IOException {
|
||||||
try (var is = Files.newInputStream(path)) {
|
try (var is = Files.newInputStream(path)) {
|
||||||
|
int version = ByteUtils.readInt(is);
|
||||||
int encryptionFlag = is.read();
|
int encryptionFlag = is.read();
|
||||||
if (encryptionFlag == 1) throw new IOException("File is encrypted.");
|
if (encryptionFlag == 1) throw new IOException("File is encrypted.");
|
||||||
return ClusterSerializer.readCluster(is);
|
return ClusterSerializer.readCluster(is);
|
||||||
|
|
|
@ -17,6 +17,14 @@ public class ByteUtils {
|
||||||
return ByteBuffer.wrap(bytes).getInt();
|
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 {
|
public static void writeLengthPrefixed(byte[] bytes, OutputStream os) throws IOException {
|
||||||
os.write(toBytes(bytes.length));
|
os.write(toBytes(bytes.length));
|
||||||
os.write(bytes);
|
os.write(bytes);
|
||||||
|
|
|
@ -8,6 +8,7 @@ import java.util.Set;
|
||||||
public class Model {
|
public class Model {
|
||||||
private Cluster activeCluster;
|
private Cluster activeCluster;
|
||||||
private Path activeClusterPath;
|
private Path activeClusterPath;
|
||||||
|
private char[] activeClusterPassword;
|
||||||
|
|
||||||
private final Set<ModelListener> listeners = new HashSet<>();
|
private final Set<ModelListener> listeners = new HashSet<>();
|
||||||
|
|
||||||
|
@ -32,6 +33,14 @@ public class Model {
|
||||||
this.activeClusterPath = activeClusterPath;
|
this.activeClusterPath = activeClusterPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public char[] getActiveClusterPassword() {
|
||||||
|
return activeClusterPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setActiveClusterPassword(char[] activeClusterPassword) {
|
||||||
|
this.activeClusterPassword = activeClusterPassword;
|
||||||
|
}
|
||||||
|
|
||||||
public void notifyListeners() {
|
public void notifyListeners() {
|
||||||
this.listeners.forEach(ModelListener::activeClusterUpdated);
|
this.listeners.forEach(ModelListener::activeClusterUpdated);
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,7 +49,7 @@ public class ClusterIOTest {
|
||||||
Cluster loaded = io.loadUnencrypted(file);
|
Cluster loaded = io.loadUnencrypted(file);
|
||||||
assertEquals(cluster, loaded);
|
assertEquals(cluster, loaded);
|
||||||
// Test attempting to load an encrypted file, which throws an IOException.
|
// 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));
|
assertThrows(IOException.class, () -> io.loadUnencrypted(file));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,14 +59,14 @@ public class ClusterIOTest {
|
||||||
Path file = Files.createTempFile(UUID.randomUUID().toString(), "cts");
|
Path file = Files.createTempFile(UUID.randomUUID().toString(), "cts");
|
||||||
var io = new ClusterIO();
|
var io = new ClusterIO();
|
||||||
// Test normal save and load.
|
// Test normal save and load.
|
||||||
io.save(cluster, file, "test");
|
io.save(cluster, file, "test".toCharArray());
|
||||||
Cluster loaded = io.load(file, "test");
|
Cluster loaded = io.load(file, "test".toCharArray());
|
||||||
assertEquals(cluster, loaded);
|
assertEquals(cluster, loaded);
|
||||||
// Test attempting to load an unencrypted file, which throws an IOException.
|
// Test attempting to load an unencrypted file, which throws an IOException.
|
||||||
io.saveUnencrypted(cluster, file);
|
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.
|
// Test attempting to load an encrypted file with the wrong password. An exception is thrown when reading it.
|
||||||
io.save(cluster, file, "other");
|
io.save(cluster, file, "other".toCharArray());
|
||||||
assertThrows(Exception.class, () -> io.load(file, "not_password"));
|
assertThrows(Exception.class, () -> io.load(file, "not_password".toCharArray()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue