Added version number to file format.

This commit is contained in:
Andrew Lalis 2021-06-01 09:50:12 +02:00
parent 3c6c165bc6
commit 50535d4bc0
7 changed files with 61 additions and 14 deletions

10
cluster_format.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import java.util.Set;
public class Model {
private Cluster activeCluster;
private Path activeClusterPath;
private char[] activeClusterPassword;
private final Set<ModelListener> 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);
}

View File

@ -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()));
}
}