mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2024-12-24 12:46:45 +00:00
wallet encryption with argon2 key derivation
This commit is contained in:
parent
ae01fe9ee6
commit
68aeb5946a
11 changed files with 270 additions and 160 deletions
2
drongo
2
drongo
|
@ -1 +1 @@
|
||||||
Subproject commit e20501d95422bb4ef76002cb7a42c46b856143d9
|
Subproject commit 8ffd22500754b77e420e2a3f887864e89a47e906
|
|
@ -217,9 +217,10 @@ public class AppController implements Initializable {
|
||||||
WalletNameDialog dlg = new WalletNameDialog();
|
WalletNameDialog dlg = new WalletNameDialog();
|
||||||
Optional<String> walletName = dlg.showAndWait();
|
Optional<String> walletName = dlg.showAndWait();
|
||||||
if(walletName.isPresent()) {
|
if(walletName.isPresent()) {
|
||||||
File walletFile = Storage.getStorage().getWalletFile(walletName.get());
|
File walletFile = Storage.getWalletFile(walletName.get());
|
||||||
|
Storage storage = new Storage(walletFile);
|
||||||
Wallet wallet = new Wallet(walletName.get(), PolicyType.SINGLE, ScriptType.P2WPKH);
|
Wallet wallet = new Wallet(walletName.get(), PolicyType.SINGLE, ScriptType.P2WPKH);
|
||||||
Tab tab = addWalletTab(walletFile, null, wallet);
|
Tab tab = addWalletTab(storage, wallet);
|
||||||
tabs.getSelectionModel().select(tab);
|
tabs.getSelectionModel().select(tab);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -228,17 +229,17 @@ public class AppController implements Initializable {
|
||||||
Stage window = new Stage();
|
Stage window = new Stage();
|
||||||
FileChooser fileChooser = new FileChooser();
|
FileChooser fileChooser = new FileChooser();
|
||||||
fileChooser.setTitle("Open Wallet");
|
fileChooser.setTitle("Open Wallet");
|
||||||
fileChooser.setInitialDirectory(Storage.getStorage().getWalletsDir());
|
fileChooser.setInitialDirectory(Storage.getWalletsDir());
|
||||||
|
|
||||||
File file = fileChooser.showOpenDialog(window);
|
File file = fileChooser.showOpenDialog(window);
|
||||||
if(file != null) {
|
if(file != null) {
|
||||||
try {
|
try {
|
||||||
Wallet wallet;
|
Wallet wallet;
|
||||||
String password = null;
|
String password = null;
|
||||||
ECKey encryptionPubKey = WalletForm.NO_PASSWORD_KEY;
|
Storage storage = new Storage(file);
|
||||||
FileType fileType = IOUtils.getFileType(file);
|
FileType fileType = IOUtils.getFileType(file);
|
||||||
if(FileType.JSON.equals(fileType)) {
|
if(FileType.JSON.equals(fileType)) {
|
||||||
wallet = Storage.getStorage().loadWallet(file);
|
wallet = storage.loadWallet();
|
||||||
} else if(FileType.BINARY.equals(fileType)) {
|
} else if(FileType.BINARY.equals(fileType)) {
|
||||||
WalletPasswordDialog dlg = new WalletPasswordDialog(WalletPasswordDialog.PasswordRequirement.LOAD);
|
WalletPasswordDialog dlg = new WalletPasswordDialog(WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||||
Optional<String> optionalPassword = dlg.showAndWait();
|
Optional<String> optionalPassword = dlg.showAndWait();
|
||||||
|
@ -247,53 +248,12 @@ public class AppController implements Initializable {
|
||||||
}
|
}
|
||||||
|
|
||||||
password = optionalPassword.get();
|
password = optionalPassword.get();
|
||||||
ECKey encryptionFullKey = Pbkdf2KeyDeriver.DEFAULT_INSTANCE.deriveECKey(password);
|
wallet = storage.loadWallet(password);
|
||||||
wallet = Storage.getStorage().loadWallet(file, encryptionFullKey);
|
|
||||||
encryptionPubKey = ECKey.fromPublicOnly(encryptionFullKey);
|
|
||||||
} else {
|
} else {
|
||||||
throw new IOException("Unsupported file type");
|
throw new IOException("Unsupported file type");
|
||||||
}
|
}
|
||||||
|
|
||||||
if(wallet.containsSeeds()) {
|
Tab tab = addWalletTab(storage, wallet);
|
||||||
//Derive xpub and master fingerprint from seed, potentially with passphrase
|
|
||||||
Wallet copy = wallet.copy();
|
|
||||||
if(wallet.isEncrypted()) {
|
|
||||||
if(password == null) {
|
|
||||||
throw new IllegalStateException("Wallet seeds are encrypted but wallet is not");
|
|
||||||
}
|
|
||||||
|
|
||||||
copy.decrypt(password);
|
|
||||||
}
|
|
||||||
|
|
||||||
for(Keystore copyKeystore : copy.getKeystores()) {
|
|
||||||
if(copyKeystore.hasSeed()) {
|
|
||||||
if(copyKeystore.getSeed().needsPassphrase()) {
|
|
||||||
KeystorePassphraseDialog passphraseDialog = new KeystorePassphraseDialog(copyKeystore);
|
|
||||||
Optional<String> optionalPassphrase = passphraseDialog.showAndWait();
|
|
||||||
if(optionalPassphrase.isPresent()) {
|
|
||||||
copyKeystore.getSeed().setPassphrase(optionalPassphrase.get());
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
copyKeystore.getSeed().setPassphrase("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for(int i = 0; i < wallet.getKeystores().size(); i++) {
|
|
||||||
Keystore keystore = wallet.getKeystores().get(i);
|
|
||||||
if(keystore.hasSeed()) {
|
|
||||||
Keystore copyKeystore = copy.getKeystores().get(i);
|
|
||||||
Keystore derivedKeystore = Keystore.fromSeed(copyKeystore.getSeed(), copyKeystore.getKeyDerivation().getDerivation());
|
|
||||||
keystore.setKeyDerivation(derivedKeystore.getKeyDerivation());
|
|
||||||
keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey());
|
|
||||||
keystore.getSeed().setPassphrase(copyKeystore.getSeed().getPassphrase());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Tab tab = addWalletTab(file, encryptionPubKey, wallet);
|
|
||||||
tabs.getSelectionModel().select(tab);
|
tabs.getSelectionModel().select(tab);
|
||||||
} catch (InvalidPasswordException e) {
|
} catch (InvalidPasswordException e) {
|
||||||
showErrorDialog("Invalid Password", "The password was invalid.");
|
showErrorDialog("Invalid Password", "The password was invalid.");
|
||||||
|
@ -308,8 +268,9 @@ public class AppController implements Initializable {
|
||||||
Optional<Wallet> optionalWallet = dlg.showAndWait();
|
Optional<Wallet> optionalWallet = dlg.showAndWait();
|
||||||
if(optionalWallet.isPresent()) {
|
if(optionalWallet.isPresent()) {
|
||||||
Wallet wallet = optionalWallet.get();
|
Wallet wallet = optionalWallet.get();
|
||||||
File walletFile = Storage.getStorage().getWalletFile(wallet.getName());
|
File walletFile = Storage.getWalletFile(wallet.getName());
|
||||||
Tab tab = addWalletTab(walletFile, null, wallet);
|
Storage storage = new Storage(walletFile);
|
||||||
|
Tab tab = addWalletTab(storage, wallet);
|
||||||
tabs.getSelectionModel().select(tab);
|
tabs.getSelectionModel().select(tab);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -327,21 +288,21 @@ public class AppController implements Initializable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Tab addWalletTab(File walletFile, ECKey encryptionPubKey, Wallet wallet) {
|
public Tab addWalletTab(Storage storage, Wallet wallet) {
|
||||||
try {
|
try {
|
||||||
String name = walletFile.getName();
|
String name = storage.getWalletFile().getName();
|
||||||
if(name.endsWith(".json")) {
|
if(name.endsWith(".json")) {
|
||||||
name = name.substring(0, name.lastIndexOf('.'));
|
name = name.substring(0, name.lastIndexOf('.'));
|
||||||
}
|
}
|
||||||
Tab tab = new Tab(name);
|
Tab tab = new Tab(name);
|
||||||
TabData tabData = new WalletTabData(TabData.TabType.WALLET, wallet, walletFile);
|
TabData tabData = new WalletTabData(TabData.TabType.WALLET, wallet, storage);
|
||||||
tab.setUserData(tabData);
|
tab.setUserData(tabData);
|
||||||
tab.setContextMenu(getTabContextMenu(tab));
|
tab.setContextMenu(getTabContextMenu(tab));
|
||||||
tab.setClosable(true);
|
tab.setClosable(true);
|
||||||
FXMLLoader walletLoader = new FXMLLoader(getClass().getResource("wallet/wallet.fxml"));
|
FXMLLoader walletLoader = new FXMLLoader(getClass().getResource("wallet/wallet.fxml"));
|
||||||
tab.setContent(walletLoader.load());
|
tab.setContent(walletLoader.load());
|
||||||
WalletController controller = walletLoader.getController();
|
WalletController controller = walletLoader.getController();
|
||||||
WalletForm walletForm = new WalletForm(walletFile, encryptionPubKey, wallet);
|
WalletForm walletForm = new WalletForm(storage, wallet);
|
||||||
controller.setWalletForm(walletForm);
|
controller.setWalletForm(walletForm);
|
||||||
|
|
||||||
tabs.getTabs().add(tab);
|
tabs.getTabs().add(tab);
|
||||||
|
|
|
@ -3,17 +3,16 @@ package com.sparrowwallet.sparrow;
|
||||||
import com.google.common.eventbus.Subscribe;
|
import com.google.common.eventbus.Subscribe;
|
||||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||||
import com.sparrowwallet.sparrow.event.WalletChangedEvent;
|
import com.sparrowwallet.sparrow.event.WalletChangedEvent;
|
||||||
|
import com.sparrowwallet.sparrow.io.Storage;
|
||||||
import java.io.File;
|
|
||||||
|
|
||||||
public class WalletTabData extends TabData {
|
public class WalletTabData extends TabData {
|
||||||
private Wallet wallet;
|
private Wallet wallet;
|
||||||
private final File walletFile;
|
private final Storage storage;
|
||||||
|
|
||||||
public WalletTabData(TabType type, Wallet wallet, File walletFile) {
|
public WalletTabData(TabType type, Wallet wallet, Storage storage) {
|
||||||
super(type);
|
super(type);
|
||||||
this.wallet = wallet;
|
this.wallet = wallet;
|
||||||
this.walletFile = walletFile;
|
this.storage = storage;
|
||||||
|
|
||||||
EventManager.get().register(this);
|
EventManager.get().register(this);
|
||||||
}
|
}
|
||||||
|
@ -22,13 +21,13 @@ public class WalletTabData extends TabData {
|
||||||
return wallet;
|
return wallet;
|
||||||
}
|
}
|
||||||
|
|
||||||
public File getWalletFile() {
|
public Storage getStorage() {
|
||||||
return walletFile;
|
return storage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Subscribe
|
@Subscribe
|
||||||
public void walletChanged(WalletChangedEvent event) {
|
public void walletChanged(WalletChangedEvent event) {
|
||||||
if(event.getWalletFile().equals(walletFile)) {
|
if(event.getWalletFile().equals(storage.getWalletFile())) {
|
||||||
wallet = event.getWallet();
|
wallet = event.getWallet();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,7 +43,7 @@ public class WalletNameDialog extends Dialog<String> {
|
||||||
Platform.runLater( () -> {
|
Platform.runLater( () -> {
|
||||||
validationSupport.registerValidator(name, Validator.combine(
|
validationSupport.registerValidator(name, Validator.combine(
|
||||||
Validator.createEmptyValidator("Wallet name is required"),
|
Validator.createEmptyValidator("Wallet name is required"),
|
||||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Wallet name is not unique", Storage.getStorage().getWalletFile(newValue).exists())
|
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Wallet name is not unique", Storage.getWalletFile(newValue).exists())
|
||||||
));
|
));
|
||||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||||
});
|
});
|
||||||
|
@ -52,7 +52,7 @@ public class WalletNameDialog extends Dialog<String> {
|
||||||
dialogPane.getButtonTypes().addAll(okButtonType);
|
dialogPane.getButtonTypes().addAll(okButtonType);
|
||||||
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
|
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
|
||||||
BooleanBinding isInvalid = Bindings.createBooleanBinding(() ->
|
BooleanBinding isInvalid = Bindings.createBooleanBinding(() ->
|
||||||
name.getText().length() == 0 || Storage.getStorage().getWalletFile(name.getText()).exists(), name.textProperty());
|
name.getText().length() == 0 || Storage.getWalletFile(name.getText()).exists(), name.textProperty());
|
||||||
okButton.disableProperty().bind(isInvalid);
|
okButton.disableProperty().bind(isInvalid);
|
||||||
|
|
||||||
name.setPromptText("Wallet Name");
|
name.setPromptText("Wallet Name");
|
||||||
|
|
|
@ -3,33 +3,45 @@ package com.sparrowwallet.sparrow.io;
|
||||||
import com.google.gson.*;
|
import com.google.gson.*;
|
||||||
import com.sparrowwallet.drongo.ExtendedKey;
|
import com.sparrowwallet.drongo.ExtendedKey;
|
||||||
import com.sparrowwallet.drongo.Utils;
|
import com.sparrowwallet.drongo.Utils;
|
||||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
import com.sparrowwallet.drongo.crypto.*;
|
||||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||||
|
import com.sparrowwallet.drongo.wallet.MnemonicException;
|
||||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||||
|
import com.sparrowwallet.sparrow.control.KeystorePassphraseDialog;
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
import java.lang.reflect.Type;
|
import java.lang.reflect.Type;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.zip.*;
|
import java.util.zip.*;
|
||||||
|
|
||||||
|
import static com.sparrowwallet.drongo.crypto.Argon2KeyDeriver.SPRW1_PARAMETERS;
|
||||||
|
|
||||||
public class Storage {
|
public class Storage {
|
||||||
|
public static final ECKey NO_PASSWORD_KEY = ECKey.fromPublicOnly(ECKey.fromPrivate(Utils.hexToBytes("885e5a09708a167ea356a252387aa7c4893d138d632e296df8fbf5c12798bd28")));
|
||||||
|
|
||||||
public static final String SPARROW_DIR = ".sparrow";
|
public static final String SPARROW_DIR = ".sparrow";
|
||||||
public static final String WALLETS_DIR = "wallets";
|
public static final String WALLETS_DIR = "wallets";
|
||||||
|
public static final String HEADER_MAGIC_1 = "SPRW1";
|
||||||
|
private static final int BINARY_HEADER_LENGTH = 28;
|
||||||
|
|
||||||
private static Storage SINGLETON;
|
private File walletFile;
|
||||||
|
|
||||||
private final Gson gson;
|
private final Gson gson;
|
||||||
|
private AsymmetricKeyDeriver keyDeriver;
|
||||||
|
private ECKey encryptionPubKey;
|
||||||
|
|
||||||
private Storage() {
|
public Storage(File walletFile) {
|
||||||
gson = getGson();
|
this.walletFile = walletFile;
|
||||||
|
this.gson = getGson();
|
||||||
|
this.encryptionPubKey = NO_PASSWORD_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Storage getStorage() {
|
public File getWalletFile() {
|
||||||
if(SINGLETON == null) {
|
return walletFile;
|
||||||
SINGLETON = new Storage();
|
|
||||||
}
|
|
||||||
|
|
||||||
return SINGLETON;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Gson getGson() {
|
public static Gson getGson() {
|
||||||
|
@ -49,73 +61,200 @@ public class Storage {
|
||||||
return gsonBuilder.setPrettyPrinting().disableHtmlEscaping().create();
|
return gsonBuilder.setPrettyPrinting().disableHtmlEscaping().create();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Wallet loadWallet(File file) throws IOException {
|
public Wallet loadWallet() throws IOException, MnemonicException {
|
||||||
Reader reader = new FileReader(file);
|
Reader reader = new FileReader(walletFile);
|
||||||
Wallet wallet = gson.fromJson(reader, Wallet.class);
|
Wallet wallet = gson.fromJson(reader, Wallet.class);
|
||||||
reader.close();
|
reader.close();
|
||||||
|
|
||||||
|
restorePublicKeysFromSeed(wallet, null);
|
||||||
|
|
||||||
return wallet;
|
return wallet;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Wallet loadWallet(File file, ECKey encryptionKey) throws IOException {
|
public Wallet loadWallet(String password) throws IOException, MnemonicException, StorageException {
|
||||||
Reader reader = new InputStreamReader(new InflaterInputStream(new ECIESInputStream(new FileInputStream(file), encryptionKey, getEncryptionMagic())), StandardCharsets.UTF_8);
|
InputStream fileStream = new FileInputStream(walletFile);
|
||||||
|
ECKey encryptionKey = getEncryptionKey(password, fileStream);
|
||||||
|
|
||||||
|
InputStream inputStream = new InflaterInputStream(new ECIESInputStream(fileStream, encryptionKey, getEncryptionMagic()));
|
||||||
|
Reader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
|
||||||
Wallet wallet = gson.fromJson(reader, Wallet.class);
|
Wallet wallet = gson.fromJson(reader, Wallet.class);
|
||||||
reader.close();
|
reader.close();
|
||||||
|
|
||||||
|
Key key = new Key(encryptionKey.getPrivKeyBytes(), keyDeriver.getSalt(), EncryptionType.Deriver.ARGON2);
|
||||||
|
restorePublicKeysFromSeed(wallet, key);
|
||||||
|
|
||||||
|
encryptionPubKey = ECKey.fromPublicOnly(encryptionKey);
|
||||||
return wallet;
|
return wallet;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void storeWallet(File file, Wallet wallet) throws IOException {
|
private void restorePublicKeysFromSeed(Wallet wallet, Key key) throws MnemonicException {
|
||||||
File parent = file.getParentFile();
|
if(wallet.containsSeeds()) {
|
||||||
|
//Derive xpub and master fingerprint from seed, potentially with passphrase
|
||||||
|
Wallet copy = wallet.copy();
|
||||||
|
if(wallet.isEncrypted()) {
|
||||||
|
if(key == null) {
|
||||||
|
throw new IllegalStateException("Wallet was not encrypted, but seed is");
|
||||||
|
}
|
||||||
|
|
||||||
|
copy.decrypt(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
for(Keystore copyKeystore : copy.getKeystores()) {
|
||||||
|
if(copyKeystore.hasSeed()) {
|
||||||
|
if(copyKeystore.getSeed().needsPassphrase()) {
|
||||||
|
KeystorePassphraseDialog passphraseDialog = new KeystorePassphraseDialog(copyKeystore);
|
||||||
|
Optional<String> optionalPassphrase = passphraseDialog.showAndWait();
|
||||||
|
if(optionalPassphrase.isPresent()) {
|
||||||
|
copyKeystore.getSeed().setPassphrase(optionalPassphrase.get());
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
copyKeystore.getSeed().setPassphrase("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for(int i = 0; i < wallet.getKeystores().size(); i++) {
|
||||||
|
Keystore keystore = wallet.getKeystores().get(i);
|
||||||
|
if(keystore.hasSeed()) {
|
||||||
|
Keystore copyKeystore = copy.getKeystores().get(i);
|
||||||
|
Keystore derivedKeystore = Keystore.fromSeed(copyKeystore.getSeed(), copyKeystore.getKeyDerivation().getDerivation());
|
||||||
|
keystore.setKeyDerivation(derivedKeystore.getKeyDerivation());
|
||||||
|
keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey());
|
||||||
|
keystore.getSeed().setPassphrase(copyKeystore.getSeed().getPassphrase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void storeWallet(Wallet wallet) throws IOException {
|
||||||
|
if(encryptionPubKey != null && !NO_PASSWORD_KEY.equals(encryptionPubKey)) {
|
||||||
|
storeWallet(encryptionPubKey, wallet);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
File parent = walletFile.getParentFile();
|
||||||
if(!parent.exists() && !parent.mkdirs()) {
|
if(!parent.exists() && !parent.mkdirs()) {
|
||||||
throw new IOException("Could not create folder " + parent);
|
throw new IOException("Could not create folder " + parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
if(!file.getName().endsWith(".json")) {
|
if(!walletFile.getName().endsWith(".json")) {
|
||||||
File jsonFile = new File(parent, file.getName() + ".json");
|
File jsonFile = new File(parent, walletFile.getName() + ".json");
|
||||||
if(file.exists()) {
|
if(walletFile.exists()) {
|
||||||
if(!file.renameTo(jsonFile)) {
|
if(!walletFile.renameTo(jsonFile)) {
|
||||||
throw new IOException("Could not rename " + file.getName() + " to " + jsonFile.getName());
|
throw new IOException("Could not rename " + walletFile.getName() + " to " + jsonFile.getName());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
file = jsonFile;
|
walletFile = jsonFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
Writer writer = new FileWriter(file);
|
Writer writer = new FileWriter(walletFile);
|
||||||
gson.toJson(wallet, writer);
|
gson.toJson(wallet, writer);
|
||||||
writer.close();
|
writer.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void storeWallet(File file, ECKey encryptionKey, Wallet wallet) throws IOException {
|
private void storeWallet(ECKey encryptionPubKey, Wallet wallet) throws IOException {
|
||||||
File parent = file.getParentFile();
|
File parent = walletFile.getParentFile();
|
||||||
if(!parent.exists() && !parent.mkdirs()) {
|
if(!parent.exists() && !parent.mkdirs()) {
|
||||||
throw new IOException("Could not create folder " + parent);
|
throw new IOException("Could not create folder " + parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
if(file.getName().endsWith(".json")) {
|
if(walletFile.getName().endsWith(".json")) {
|
||||||
File noJsonFile = new File(parent, file.getName().substring(0, file.getName().lastIndexOf('.')));
|
File noJsonFile = new File(parent, walletFile.getName().substring(0, walletFile.getName().lastIndexOf('.')));
|
||||||
if(file.exists()) {
|
if(walletFile.exists()) {
|
||||||
if(!file.renameTo(noJsonFile)) {
|
if(!walletFile.renameTo(noJsonFile)) {
|
||||||
throw new IOException("Could not rename " + file.getName() + " to " + noJsonFile.getName());
|
throw new IOException("Could not rename " + walletFile.getName() + " to " + noJsonFile.getName());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
file = noJsonFile;
|
walletFile = noJsonFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
OutputStreamWriter writer = new OutputStreamWriter(new DeflaterOutputStream(new ECIESOutputStream(new FileOutputStream(file), encryptionKey, getEncryptionMagic())), StandardCharsets.UTF_8);
|
OutputStream outputStream = new FileOutputStream(walletFile);
|
||||||
|
writeBinaryHeader(outputStream);
|
||||||
|
|
||||||
|
OutputStreamWriter writer = new OutputStreamWriter(new DeflaterOutputStream(new ECIESOutputStream(outputStream, encryptionPubKey, getEncryptionMagic())), StandardCharsets.UTF_8);
|
||||||
gson.toJson(wallet, writer);
|
gson.toJson(wallet, writer);
|
||||||
writer.close();
|
writer.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void writeBinaryHeader(OutputStream outputStream) throws IOException {
|
||||||
|
ByteBuffer buf = ByteBuffer.allocate(21);
|
||||||
|
buf.put(HEADER_MAGIC_1.getBytes(StandardCharsets.UTF_8));
|
||||||
|
buf.put(keyDeriver.getSalt());
|
||||||
|
|
||||||
|
byte[] encoded = Base64.getEncoder().encode(buf.array());
|
||||||
|
if(encoded.length != BINARY_HEADER_LENGTH) {
|
||||||
|
throw new IllegalStateException("Header length not " + BINARY_HEADER_LENGTH + " bytes");
|
||||||
|
}
|
||||||
|
outputStream.write(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ECKey getEncryptionPubKey() {
|
||||||
|
return encryptionPubKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEncryptionPubKey(ECKey encryptionPubKey) {
|
||||||
|
this.encryptionPubKey = encryptionPubKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ECKey getEncryptionKey(String password) throws IOException, StorageException {
|
||||||
|
return getEncryptionKey(password, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ECKey getEncryptionKey(String password, InputStream inputStream) throws IOException, StorageException {
|
||||||
|
if(password.equals("")) {
|
||||||
|
return NO_PASSWORD_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getKeyDeriver(inputStream).deriveECKey(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AsymmetricKeyDeriver getKeyDeriver() {
|
||||||
|
return keyDeriver;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setKeyDeriver(AsymmetricKeyDeriver keyDeriver) {
|
||||||
|
this.keyDeriver = keyDeriver;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AsymmetricKeyDeriver getKeyDeriver(InputStream inputStream) throws IOException, StorageException {
|
||||||
|
if(keyDeriver == null) {
|
||||||
|
byte[] salt = new byte[SPRW1_PARAMETERS.saltLength];
|
||||||
|
|
||||||
|
if(inputStream != null) {
|
||||||
|
byte[] header = new byte[BINARY_HEADER_LENGTH];
|
||||||
|
int read = inputStream.read(header);
|
||||||
|
if(read != BINARY_HEADER_LENGTH) {
|
||||||
|
throw new StorageException("Not a Sparrow wallet - invalid header");
|
||||||
|
}
|
||||||
|
byte[] decodedHeader = Base64.getDecoder().decode(header);
|
||||||
|
byte[] magic = Arrays.copyOfRange(decodedHeader, 0, HEADER_MAGIC_1.length());
|
||||||
|
if(!HEADER_MAGIC_1.equals(new String(magic, StandardCharsets.UTF_8))) {
|
||||||
|
throw new StorageException("Not a Sparrow wallet - invalid magic");
|
||||||
|
}
|
||||||
|
salt = Arrays.copyOfRange(decodedHeader, HEADER_MAGIC_1.length(), decodedHeader.length);
|
||||||
|
} else {
|
||||||
|
SecureRandom secureRandom = new SecureRandom();
|
||||||
|
secureRandom.nextBytes(salt);
|
||||||
|
}
|
||||||
|
|
||||||
|
keyDeriver = new Argon2KeyDeriver(salt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyDeriver;
|
||||||
|
}
|
||||||
|
|
||||||
private static byte[] getEncryptionMagic() {
|
private static byte[] getEncryptionMagic() {
|
||||||
return "BIE1".getBytes(StandardCharsets.UTF_8);
|
return "BIE1".getBytes(StandardCharsets.UTF_8);
|
||||||
}
|
}
|
||||||
|
|
||||||
public File getWalletFile(String walletName) {
|
public static File getWalletFile(String walletName) {
|
||||||
|
//TODO: Check for existing file
|
||||||
return new File(getWalletsDir(), walletName);
|
return new File(getWalletsDir(), walletName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public File getWalletsDir() {
|
public static File getWalletsDir() {
|
||||||
File walletsDir = new File(getSparrowDir(), WALLETS_DIR);
|
File walletsDir = new File(getSparrowDir(), WALLETS_DIR);
|
||||||
if(!walletsDir.exists()) {
|
if(!walletsDir.exists()) {
|
||||||
walletsDir.mkdirs();
|
walletsDir.mkdirs();
|
||||||
|
@ -124,11 +263,11 @@ public class Storage {
|
||||||
return walletsDir;
|
return walletsDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
private File getSparrowDir() {
|
private static File getSparrowDir() {
|
||||||
return new File(getHomeDir(), SPARROW_DIR);
|
return new File(getHomeDir(), SPARROW_DIR);
|
||||||
}
|
}
|
||||||
|
|
||||||
private File getHomeDir() {
|
private static File getHomeDir() {
|
||||||
return new File(System.getProperty("user.home"));
|
return new File(System.getProperty("user.home"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.sparrowwallet.sparrow.io;
|
||||||
|
|
||||||
|
public class StorageException extends Exception {
|
||||||
|
public StorageException() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public StorageException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public StorageException(Throwable cause) {
|
||||||
|
super(cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
public StorageException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +1,7 @@
|
||||||
package com.sparrowwallet.sparrow.wallet;
|
package com.sparrowwallet.sparrow.wallet;
|
||||||
|
|
||||||
import com.google.common.eventbus.Subscribe;
|
import com.google.common.eventbus.Subscribe;
|
||||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
import com.sparrowwallet.drongo.crypto.*;
|
||||||
import com.sparrowwallet.drongo.crypto.Pbkdf2KeyDeriver;
|
|
||||||
import com.sparrowwallet.drongo.policy.Policy;
|
import com.sparrowwallet.drongo.policy.Policy;
|
||||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||||
|
@ -16,7 +15,7 @@ import com.sparrowwallet.sparrow.control.CopyableLabel;
|
||||||
import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
|
import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
|
||||||
import com.sparrowwallet.sparrow.event.SettingsChangedEvent;
|
import com.sparrowwallet.sparrow.event.SettingsChangedEvent;
|
||||||
import com.sparrowwallet.sparrow.event.WalletChangedEvent;
|
import com.sparrowwallet.sparrow.event.WalletChangedEvent;
|
||||||
import javafx.application.Platform;
|
import com.sparrowwallet.sparrow.io.Storage;
|
||||||
import javafx.beans.property.SimpleIntegerProperty;
|
import javafx.beans.property.SimpleIntegerProperty;
|
||||||
import javafx.collections.FXCollections;
|
import javafx.collections.FXCollections;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
|
@ -159,9 +158,9 @@ public class SettingsController extends WalletFormController implements Initiali
|
||||||
|
|
||||||
apply.setOnAction(event -> {
|
apply.setOnAction(event -> {
|
||||||
try {
|
try {
|
||||||
Optional<ECKey> optionalPubKey = requestEncryption(walletForm.getEncryptionPubKey());
|
Optional<ECKey> optionalPubKey = requestEncryption(walletForm.getStorage().getEncryptionPubKey());
|
||||||
if(optionalPubKey.isPresent()) {
|
if(optionalPubKey.isPresent()) {
|
||||||
walletForm.setEncryptionPubKey(optionalPubKey.get());
|
walletForm.getStorage().setEncryptionPubKey(optionalPubKey.get());
|
||||||
walletForm.save();
|
walletForm.save();
|
||||||
revert.setDisable(true);
|
revert.setDisable(true);
|
||||||
apply.setDisable(true);
|
apply.setDisable(true);
|
||||||
|
@ -260,7 +259,7 @@ public class SettingsController extends WalletFormController implements Initiali
|
||||||
WalletPasswordDialog.PasswordRequirement requirement;
|
WalletPasswordDialog.PasswordRequirement requirement;
|
||||||
if(existingPubKey == null) {
|
if(existingPubKey == null) {
|
||||||
requirement = WalletPasswordDialog.PasswordRequirement.UPDATE_NEW;
|
requirement = WalletPasswordDialog.PasswordRequirement.UPDATE_NEW;
|
||||||
} else if(WalletForm.NO_PASSWORD_KEY.equals(existingPubKey)) {
|
} else if(Storage.NO_PASSWORD_KEY.equals(existingPubKey)) {
|
||||||
requirement = WalletPasswordDialog.PasswordRequirement.UPDATE_EMPTY;
|
requirement = WalletPasswordDialog.PasswordRequirement.UPDATE_EMPTY;
|
||||||
} else {
|
} else {
|
||||||
requirement = WalletPasswordDialog.PasswordRequirement.UPDATE_SET;
|
requirement = WalletPasswordDialog.PasswordRequirement.UPDATE_SET;
|
||||||
|
@ -270,19 +269,24 @@ public class SettingsController extends WalletFormController implements Initiali
|
||||||
Optional<String> password = dlg.showAndWait();
|
Optional<String> password = dlg.showAndWait();
|
||||||
if(password.isPresent()) {
|
if(password.isPresent()) {
|
||||||
if(password.get().isEmpty()) {
|
if(password.get().isEmpty()) {
|
||||||
return Optional.of(WalletForm.NO_PASSWORD_KEY);
|
return Optional.of(Storage.NO_PASSWORD_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
ECKey encryptionFullKey = Pbkdf2KeyDeriver.DEFAULT_INSTANCE.deriveECKey(password.get());
|
try {
|
||||||
ECKey encryptionPubKey = ECKey.fromPublicOnly(encryptionFullKey);
|
ECKey encryptionFullKey = walletForm.getStorage().getEncryptionKey(password.get());
|
||||||
|
ECKey encryptionPubKey = ECKey.fromPublicOnly(encryptionFullKey);
|
||||||
|
|
||||||
if(existingPubKey != null && !WalletForm.NO_PASSWORD_KEY.equals(existingPubKey) && !existingPubKey.equals(encryptionPubKey)) {
|
if(existingPubKey != null && !Storage.NO_PASSWORD_KEY.equals(existingPubKey) && !existingPubKey.equals(encryptionPubKey)) {
|
||||||
AppController.showErrorDialog("Incorrect Password", "The password was incorrect.");
|
AppController.showErrorDialog("Incorrect Password", "The password was incorrect.");
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
Key key = new Key(encryptionFullKey.getPrivKeyBytes(), walletForm.getStorage().getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
|
||||||
|
walletForm.getWallet().encrypt(key);
|
||||||
|
return Optional.of(encryptionPubKey);
|
||||||
|
} catch (Exception e) {
|
||||||
|
AppController.showErrorDialog("Wallet File Invalid", e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
walletForm.getWallet().encrypt(password.get());
|
|
||||||
return Optional.of(encryptionPubKey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
|
|
|
@ -9,16 +9,12 @@ import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
||||||
public class WalletForm {
|
public class WalletForm {
|
||||||
public static final ECKey NO_PASSWORD_KEY = ECKey.fromPublicOnly(Pbkdf2KeyDeriver.DEFAULT_INSTANCE.deriveECKey(""));
|
private final Storage storage;
|
||||||
|
|
||||||
private final File walletFile;
|
|
||||||
private ECKey encryptionPubKey;
|
|
||||||
private Wallet oldWallet;
|
private Wallet oldWallet;
|
||||||
private Wallet wallet;
|
private Wallet wallet;
|
||||||
|
|
||||||
public WalletForm(File walletFile, ECKey encryptionPubKey, Wallet currentWallet) {
|
public WalletForm(Storage storage, Wallet currentWallet) {
|
||||||
this.walletFile = walletFile;
|
this.storage = storage;
|
||||||
this.encryptionPubKey = encryptionPubKey;
|
|
||||||
this.oldWallet = currentWallet;
|
this.oldWallet = currentWallet;
|
||||||
this.wallet = currentWallet.copy();
|
this.wallet = currentWallet.copy();
|
||||||
}
|
}
|
||||||
|
@ -27,16 +23,12 @@ public class WalletForm {
|
||||||
return wallet;
|
return wallet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Storage getStorage() {
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
public File getWalletFile() {
|
public File getWalletFile() {
|
||||||
return walletFile;
|
return storage.getWalletFile();
|
||||||
}
|
|
||||||
|
|
||||||
public ECKey getEncryptionPubKey() {
|
|
||||||
return encryptionPubKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setEncryptionPubKey(ECKey encryptionPubKey) {
|
|
||||||
this.encryptionPubKey = encryptionPubKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void revert() {
|
public void revert() {
|
||||||
|
@ -44,12 +36,7 @@ public class WalletForm {
|
||||||
}
|
}
|
||||||
|
|
||||||
public void save() throws IOException {
|
public void save() throws IOException {
|
||||||
if(encryptionPubKey.equals(NO_PASSWORD_KEY)) {
|
storage.storeWallet(wallet);
|
||||||
Storage.getStorage().storeWallet(walletFile, wallet);
|
|
||||||
} else {
|
|
||||||
Storage.getStorage().storeWallet(walletFile, encryptionPubKey, wallet);
|
|
||||||
}
|
|
||||||
|
|
||||||
oldWallet = wallet.copy();
|
oldWallet = wallet.copy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,45 +15,46 @@ import java.io.*;
|
||||||
|
|
||||||
public class StorageTest extends IoTest {
|
public class StorageTest extends IoTest {
|
||||||
@Test
|
@Test
|
||||||
public void loadWallet() throws IOException {
|
public void loadWallet() throws IOException, MnemonicException, StorageException {
|
||||||
ECKey decryptionKey = Pbkdf2KeyDeriver.DEFAULT_INSTANCE.deriveECKey("pass");
|
Storage storage = new Storage(getFile("sparrow-single-wallet"));
|
||||||
Wallet wallet = Storage.getStorage().loadWallet(getFile("sparrow-single-wallet"), decryptionKey);
|
Wallet wallet = storage.loadWallet("pass");
|
||||||
Assert.assertTrue(wallet.isValid());
|
Assert.assertTrue(wallet.isValid());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void loadSeedWallet() throws IOException, MnemonicException {
|
public void loadSeedWallet() throws IOException, MnemonicException, StorageException {
|
||||||
ECKey decryptionKey = Pbkdf2KeyDeriver.DEFAULT_INSTANCE.deriveECKey("pass");
|
Storage storage = new Storage(getFile("sparrow-single-seed-wallet"));
|
||||||
|
Wallet wallet = storage.loadWallet("pass");
|
||||||
Wallet wallet = Storage.getStorage().loadWallet(getFile("sparrow-single-seed-wallet"), decryptionKey);
|
|
||||||
Assert.assertTrue(wallet.isValid());
|
Assert.assertTrue(wallet.isValid());
|
||||||
|
|
||||||
Assert.assertEquals("testa1", wallet.getName());
|
Assert.assertEquals("testd2", wallet.getName());
|
||||||
Assert.assertEquals(PolicyType.SINGLE, wallet.getPolicyType());
|
Assert.assertEquals(PolicyType.SINGLE, wallet.getPolicyType());
|
||||||
Assert.assertEquals(ScriptType.P2WPKH, wallet.getScriptType());
|
Assert.assertEquals(ScriptType.P2WPKH, wallet.getScriptType());
|
||||||
Assert.assertEquals(1, wallet.getDefaultPolicy().getNumSignaturesRequired());
|
Assert.assertEquals(1, wallet.getDefaultPolicy().getNumSignaturesRequired());
|
||||||
Assert.assertEquals("pkh(60bcd3a7)", wallet.getDefaultPolicy().getMiniscript().getScript());
|
Assert.assertEquals("pkh(60bcd3a7)", wallet.getDefaultPolicy().getMiniscript().getScript());
|
||||||
Assert.assertEquals("60bcd3a7", wallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
|
Assert.assertEquals("60bcd3a7", wallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
|
||||||
Assert.assertEquals("m/84'/0'/0'", wallet.getKeystores().get(0).getKeyDerivation().getDerivationPath());
|
Assert.assertEquals("m/84'/0'/3'", wallet.getKeystores().get(0).getKeyDerivation().getDerivationPath());
|
||||||
Assert.assertEquals("xpub6BrhGFTWPd3DQaGP7p5zTQkE5nqVbaRs23HNae8jAoNJYS2NGa9Sgpeqv1dS5ygwD4sQfwqLCk5qXRK45FTgnqHRcrPnts3Qgh78BZrnoMn", wallet.getKeystores().get(0).getExtendedPublicKey().toString());
|
Assert.assertEquals("xpub6BrhGFTWPd3DXo8s2BPxHHzCmBCyj8QvamcEUaq8EDwnwXpvvcU9LzpJqENHcqHkqwTn2vPhynGVoEqj3PAB3NxnYZrvCsSfoCniJKaggdy", wallet.getKeystores().get(0).getExtendedPublicKey().toString());
|
||||||
Assert.assertEquals("a48767d6b58732a0cad17ed93e23022ec603a177e75461f2aed994713fbbe532b61f6c0758a8aedcf9b2b8102c01c6f3e3e212ca06f13644d4ac8dad66556e164b7eaf79d0b42eadecee8b735e97fc0a", Utils.bytesToHex(wallet.getKeystores().get(0).getSeed().getEncryptedData().getEncryptedBytes()));
|
Assert.assertEquals("af6ebd81714c301c3a71fe11a7a9c99ccef4b33d4b36582220767bfa92768a2aa040f88b015b2465f8075a8b9dbf892a7d6e6c49932109f2cbc05ba0bd7f355fbcc34c237f71be5fb4dd7f8184e44cb0", Utils.bytesToHex(wallet.getKeystores().get(0).getSeed().getEncryptedData().getEncryptedBytes()));
|
||||||
Assert.assertNull(wallet.getKeystores().get(0).getSeed().getSeedBytes());
|
Assert.assertNull(wallet.getKeystores().get(0).getSeed().getMnemonicCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void saveWallet() throws IOException {
|
public void saveWallet() throws IOException, MnemonicException, StorageException {
|
||||||
ECKey decryptionKey = Pbkdf2KeyDeriver.DEFAULT_INSTANCE.deriveECKey("pass");
|
Storage storage = new Storage(getFile("sparrow-single-wallet"));
|
||||||
Wallet wallet = Storage.getStorage().loadWallet(getFile("sparrow-single-wallet"), decryptionKey);
|
Wallet wallet = storage.loadWallet("pass");
|
||||||
Assert.assertTrue(wallet.isValid());
|
Assert.assertTrue(wallet.isValid());
|
||||||
|
|
||||||
ECKey encyptionKey = ECKey.fromPublicOnly(decryptionKey);
|
|
||||||
File tempWallet = File.createTempFile("sparrow", "tmp");
|
File tempWallet = File.createTempFile("sparrow", "tmp");
|
||||||
tempWallet.deleteOnExit();
|
tempWallet.deleteOnExit();
|
||||||
|
|
||||||
ByteArrayOutputStream dummyFileOutputStream = new ByteArrayOutputStream();
|
Storage tempStorage = new Storage(tempWallet);
|
||||||
Storage.getStorage().storeWallet(tempWallet, encyptionKey, wallet);
|
tempStorage.setKeyDeriver(storage.getKeyDeriver());
|
||||||
|
tempStorage.setEncryptionPubKey(storage.getEncryptionPubKey());
|
||||||
|
tempStorage.storeWallet(wallet);
|
||||||
|
|
||||||
wallet = Storage.getStorage().loadWallet(tempWallet, decryptionKey);
|
Storage temp2Storage = new Storage(tempWallet);
|
||||||
|
wallet = temp2Storage.loadWallet("pass");
|
||||||
Assert.assertTrue(wallet.isValid());
|
Assert.assertTrue(wallet.isValid());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
QklFMQOXQWixo1F4kqJjn/7UeGDmbpNdMGPUP+vzRWdUE2vOcHfejUklKAzq1cTpK0mMEOOHCZ+yg0LxF29KMFQpoSpRTl6GRjnmpGIu3HPuu7bjbL6GvZQXFkvmK+9zsrllxZl6sYMjP4zoHJH6JgL4qGFpnM9n/mtOGYAuOw7zj4fm10teHLLgJLpkZ4ze0n/t8QhbyPPkeEoNmw/gt1PrwNDneKtALCNNdGopEq0QWD15OCEY7fSIfu0K1VO9oMZpOHs78p335Ka7bRRjuq+RWvZLz/X5hb9zVlFIu+KLW/preykMbfg4UiPcVHfc9wSLsmqZe9btYa3yxsem9xRW7J4gXMr7uqMTw/dlrK6XNg0wBXH6cuBed3M6Nu72Hz0OU+M64HEUnsGRYLgz3XcsZAU7+jeUQp6D8sVjH7GsPFIdZl0wzhWNHsgQGLoGXnWyvatsPsklW9BQ5U1d0PxeLxWppwj42Y7YA0+O3BrN7lUmD6xATNt9/xwVotPIllXT84r/OpzFbULzSBZ0uwwV+4tdCOa7FHPUed5gXZCPk6lhPHkz2N6Ehm3WGOQBQsoTfObefYUjHhB+a6Rpggm7QgkLhBEtgy7sPCV39fnRYM52uOwDUhNB8K/h/RadkMX51eXtbx23+LU+jM8zpi4T6yLM1Rq8H9YZ+rJXUIZPOHwX2ytnXkwHcYanThhxvQa55v3vbC5X/JFVSA+yik1CejXctjv9I++5mCCJtGpk/USAWzIb3nhKURPi/a3sg7y/n47s79AxvzvLndlY96UIaI1QVaNoaoJtt1+cBLzrXGlc4hChIqloZN4GJ4F1KabDuyXwagGvsd14zTH6ELAprmJkrd0l9JNTEBrJJbRqFEZj7CwrREyUkxGaiskca+ZyHm4LuwKo/6m5dKgiqyeMUB6WdZFXDERSp9ldf8n+o6OSxolEq4wWxM2uGdbHZTBsORq0JyIS4CQcfiC4UQoXJnQpGAfKWNsc1jS/x0BlV/9n2rhGxC0LRQZ2YbtLwfwd138=
|
U1BSVzED7oX1HJm5jfGejCdFrag8QklFMQJ5dd4sdqdFohYxy0Vnk3jdhUqPPRf6iAC2PPNDo67JfH6VpVDKR8PC1kcKsIYnKE05gA1e+EfgPB/yd1z3xCu8UcwRqFdFimPJQaa28YOFKFeHdwlul+xI7U7wNKB2QZYGe2HZ/SaFO1ccMwHrZ85AxHhCI/6Qfk52heYE0qv3dbechg84J73qmx95Rl1FiMt4THZp8fKN827QyMz4ReC/uixhXerDQm6nKDb5KaNh5/aZSY8Qw8jIIqtveDkwZl1F7tA3H54Yxa4Ugz/Mcr4A3/rEaCrtMGS89XUDhaQU4GznKyK4DM4A/jw7CWnPHreSHZ79FL1/xfHDnNPAKpKDOfNqoGIL/QzgegwZLFQTQj5z81fFw370pAgh17h0diaJ+Z3TQR++/olPdNZYMU0Rl+xCjwABXUPB2AcdiIcUniGWKgXiUTW3B+nttb3m1WV/HCm/knQd42sGsFf5ZbjweqRzObvfMSkGaTKkB6ickw2VIxJSjiYTNWF3MVunUbFaqrk94OGnR10leg58iXzDvoX0zhkVdFZOxX4VjCKYCQQNng1pUj9MGpov1q7BXAiS1/ABkgvsD3tBZllV2Eg+P78UeuYpAjBNZnEjvydZOP8b86Yey12SnsUZCmnIeeOvDnqi603XdjNojQVltncIgAxlp3Bmj3tRSxUM49akj0hBsZASW3V1JC/P6cScpgv7e5/DyXIAFz7+yGzJ1cYMwUtVMOQMMJSc9y2nSMrEqFNJR+U8d9kfp8KYwst6GsJLUKBdDQMI/20O0PZSM0pWMJFE9iPK/YNDmjC7F0p8pw==
|
|
@ -1 +1 @@
|
||||||
QklFMQNI/quQo9N7RtbygK+yhlrMNxkSnXtlaC9Ia5trm7AufOtbKhGqrtv5bQ/YcRVVaj/eKhO7LWTGbC6EWFYbIle/tpTyQB5XdceCCWmbUDwyob+thVpMLLrVe9PQD+EH6GM2cWGFUZNMHdYM2N/EaLU4Z2nnDz9pLzg1jpOtU9n3D1IeivULxfkupsd0AqxkpkXJlc0y7udh2qzXk/BPffYkEN0NexspO2+I1+o81g1IcVRXNV7LR8o/woKRM4MPBhUNVOy2F5JyvKnsteBKpEpKa4AyHmhGRtIdyKIZK4+osIU9Ig+b/AItDj9OG354gpL7oiU65s7rF8UsJpDLtxIyONUL6becqsNNem0rTbHQ0PI1uoWHmQj8dUl8sqhdIwC13Hhnx0+M5ICrqs3gk5tkUyiCDA7684jrWLGRjUzUXRPmNJsWPqlnCD2+MY93dduMwbJqV1USrOZDXsMd9LuGAV+UqEDMuBRjwXDxXQldrIBp9QKYac1mKFvj9UOJr062T2gwGsSyKY2R6oCiGJPkOZjRoQ0HHwJukFYJgoRRI34Hnh49LUfJybv+VEfqz9VJZhWnDhCgcFZ9r1BwY4CZ
|
U1BSVzEeO0vCCaJ0C+yv91xHOivxQklFMQM8Z+yJUyrrWWhzlBrAe2gLlAA7z7KlPMo7DYp5IRas2U0n7w9P2qz13wqmENZ2ilPmLwGrNpcFD8/re34I3oSudN5mjs+Z9xqx7xAHRrtXIiiLoKZS9p+v44AKQxBBrNgvj6seH2n2mJ+TUYVhp0s5IKzeFAPewfMzVKnmt7VWUzzug+uPUoyS44RapDqSM1u1GtkA4+fSGTdE8GF0mG4Y0KvKi5ZtLgaFxe/c/7bX4XlQdbnzzYpFxklUJQYkeWn9N3OmC8kJo97IGGGCUOd6bIwZtTj9KGG/whrgcID12yB5L7uvVMSpFLv6Qs27+La/L8UuPSpnO+gr5r4K1OtG9kxQhksp+8Ap0yzPOJzOMGLYXv7pR+edO5Q5PTQ4K6dN6zdYgsTY6HA36N6qgNQLM5RwzXh1WINp3pI/FnEKeffGIrDPAHueKDHUd/Zw6rhXDbDn28oK+zf8lR57Rx78JFF7JczB0jYJreIE0mPwN2fn0iB7JQwUGt6XM5hsvHK1n/JxO9OELXYyMaB/t4ihEdiw6HosncdhTwkKLDXb11wwj845vjN4Q8pGq/1YOuuEYRe0eiaGQFXTaw9QYWPO
|
Loading…
Reference in a new issue