diff --git a/wallettemplate/src/main/java/wallettemplate/Controller.java b/wallettemplate/src/main/java/wallettemplate/Controller.java index 9624b667..b13db51a 100644 --- a/wallettemplate/src/main/java/wallettemplate/Controller.java +++ b/wallettemplate/src/main/java/wallettemplate/Controller.java @@ -1,6 +1,9 @@ package wallettemplate; -import com.google.bitcoin.core.*; +import com.google.bitcoin.core.AbstractWalletEventListener; +import com.google.bitcoin.core.Coin; +import com.google.bitcoin.core.DownloadListener; +import com.google.bitcoin.core.Wallet; import javafx.animation.*; import javafx.application.Platform; import javafx.event.ActionEvent; @@ -47,7 +50,8 @@ public class Controller { } public void settingsClicked(ActionEvent event) { - Main.instance.overlayUI("wallet_settings.fxml"); + Main.OverlayUI screen = Main.instance.overlayUI("wallet_settings.fxml"); + screen.controller.initialize(null); } public class ProgressBarUpdater extends DownloadListener { @@ -104,6 +108,7 @@ public class Controller { public void onWalletChanged(Wallet wallet) { checkGuiThread(); refreshBalanceLabel(); + // TODO: Refresh clickable address here. } } diff --git a/wallettemplate/src/main/java/wallettemplate/Main.java b/wallettemplate/src/main/java/wallettemplate/Main.java index 121ae84a..50a048b1 100644 --- a/wallettemplate/src/main/java/wallettemplate/Main.java +++ b/wallettemplate/src/main/java/wallettemplate/Main.java @@ -28,7 +28,7 @@ import static wallettemplate.utils.GuiUtils.*; public class Main extends Application { public static String APP_NAME = "WalletTemplate"; - public static NetworkParameters params = MainNetParams.get(); + public static NetworkParameters params = RegTestParams.get(); public static WalletAppKit bitcoin; public static Main instance; @@ -90,7 +90,8 @@ public class Main extends Application { // Don't make the user wait for confirmations for now, as the intention is they're sending it // their own money! bitcoin.wallet().allowSpendingUnconfirmedTransactions(); - bitcoin.peerGroup().setMaxConnections(11); + if (params != RegTestParams.get()) + bitcoin.peerGroup().setMaxConnections(11); bitcoin.peerGroup().setBloomFilterFalsePositiveRate(0.00001); Platform.runLater(controller::onBitcoinSetup); } @@ -115,6 +116,8 @@ public class Main extends Application { bitcoin.restoreWalletFromSeed(seed); } + private Node stopClickPane = new Pane(); + public class OverlayUI { public Node ui; public T controller; @@ -125,26 +128,53 @@ public class Main extends Application { } public void show() { - blurOut(mainUI); - uiStack.getChildren().add(ui); - fadeIn(ui); + checkGuiThread(); + if (currentOverlay == null) { + uiStack.getChildren().add(stopClickPane); + uiStack.getChildren().add(ui); + blurOut(mainUI); + //darken(mainUI); + fadeIn(ui); + zoomIn(ui); + } else { + // Do a quick transition between the current overlay and the next. + // Bug here: we don't pay attention to changes in outsideClickDismisses. + explodeOut(currentOverlay.ui); + fadeOutAndRemove(uiStack, currentOverlay.ui); + uiStack.getChildren().add(ui); + ui.setOpacity(0.0); + fadeIn(ui, 100); + zoomIn(ui, 100); + } + currentOverlay = this; + } + + public void outsideClickDismisses() { + stopClickPane.setOnMouseClicked((ev) -> done()); } public void done() { checkGuiThread(); - fadeOutAndRemove(ui, uiStack); + if (ui == null) return; // In the middle of being dismissed and got an extra click. + explodeOut(ui); + fadeOutAndRemove(uiStack, ui, stopClickPane); blurIn(mainUI); + //undark(mainUI); this.ui = null; this.controller = null; + currentOverlay = null; } } + @Nullable + private OverlayUI currentOverlay; + public OverlayUI overlayUI(Node node, T controller) { checkGuiThread(); OverlayUI pair = new OverlayUI(node, controller); - // Auto-magically set the overlayUi member, if it's there. + // Auto-magically set the overlayUI member, if it's there. try { - controller.getClass().getDeclaredField("overlayUi").set(controller, pair); + controller.getClass().getField("overlayUI").set(controller, pair); } catch (IllegalAccessException | NoSuchFieldException ignored) { } pair.show(); @@ -156,15 +186,17 @@ public class Main extends Application { try { checkGuiThread(); // Load the UI from disk. - URL location = getClass().getResource(name); + URL location = GuiUtils.getResource(name); FXMLLoader loader = new FXMLLoader(location); Pane ui = loader.load(); T controller = loader.getController(); OverlayUI pair = new OverlayUI(ui, controller); - // Auto-magically set the overlayUi member, if it's there. + // Auto-magically set the overlayUI member, if it's there. try { - controller.getClass().getDeclaredField("overlayUi").set(controller, pair); + if (controller != null) + controller.getClass().getField("overlayUI").set(controller, pair); } catch (IllegalAccessException | NoSuchFieldException ignored) { + ignored.printStackTrace(); } pair.show(); return pair; diff --git a/wallettemplate/src/main/java/wallettemplate/SendMoneyController.java b/wallettemplate/src/main/java/wallettemplate/SendMoneyController.java index 5573e066..daea552c 100644 --- a/wallettemplate/src/main/java/wallettemplate/SendMoneyController.java +++ b/wallettemplate/src/main/java/wallettemplate/SendMoneyController.java @@ -3,15 +3,15 @@ package wallettemplate; import com.google.bitcoin.core.*; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; -import javafx.application.Platform; import javafx.event.ActionEvent; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.TextField; +import org.spongycastle.crypto.params.KeyParameter; import wallettemplate.controls.BitcoinAddressValidator; -import static wallettemplate.utils.GuiUtils.crashAlert; -import static wallettemplate.utils.GuiUtils.informationalAlert; +import static com.google.common.base.Preconditions.checkState; +import static wallettemplate.utils.GuiUtils.*; public class SendMoneyController { public Button sendBtn; @@ -19,28 +19,33 @@ public class SendMoneyController { public TextField address; public Label titleLabel; - public Main.OverlayUI overlayUi; + public Main.OverlayUI overlayUI; private Wallet.SendResult sendResult; + private KeyParameter aesKey; // Called by FXMLLoader public void initialize() { + checkState(!Main.bitcoin.wallet().getBalance().isZero()); new BitcoinAddressValidator(Main.params, address, sendBtn); } public void cancel(ActionEvent event) { - overlayUi.done(); + overlayUI.done(); } public void send(ActionEvent event) { + // Address exception cannot happen as we validated it beforehand. try { Address destination = new Address(Main.params, address.getText()); Wallet.SendRequest req = Wallet.SendRequest.emptyWallet(destination); + req.aesKey = aesKey; sendResult = Main.bitcoin.wallet().sendCoins(req); Futures.addCallback(sendResult.broadcastComplete, new FutureCallback() { @Override public void onSuccess(Transaction result) { - Platform.runLater(overlayUi::done); + checkGuiThread(); + overlayUI.done(); } @Override @@ -56,16 +61,32 @@ public class SendMoneyController { sendBtn.setDisable(true); address.setDisable(true); updateTitleForBroadcast(); - } catch (AddressFormatException e) { - // Cannot happen because we already validated it when the text field changed. - throw new RuntimeException(e); } catch (InsufficientMoneyException e) { informationalAlert("Could not empty the wallet", "You may have too little money left in the wallet to make a transaction."); - overlayUi.done(); + overlayUI.done(); + } catch (ECKey.KeyIsEncryptedException e) { + askForPasswordAndRetry(); + } catch (AddressFormatException e) { + // Cannot happen because we already validated it when the text field changed. + throw new RuntimeException(e); } } + private void askForPasswordAndRetry() { + Main.OverlayUI pwd = Main.instance.overlayUI("wallet_password.fxml"); + final String addressStr = address.getText(); + pwd.controller.aesKeyProperty().addListener((observable, old, cur) -> { + // We only get here if the user found the right password. If they don't or they cancel, we end up back on + // the main UI screen. By now the send money screen is history so we must recreate it. + checkGuiThread(); + Main.OverlayUI screen = Main.instance.overlayUI("send_money.fxml"); + screen.controller.aesKey = cur; + screen.controller.address.setText(addressStr); + screen.controller.send(null); + }); + } + private void updateTitleForBroadcast() { final int peers = sendResult.tx.getConfidence().numBroadcastPeers(); titleLabel.setText(String.format("Broadcasting ... seen by %d peers", peers)); diff --git a/wallettemplate/src/main/java/wallettemplate/WalletPasswordController.java b/wallettemplate/src/main/java/wallettemplate/WalletPasswordController.java new file mode 100644 index 00000000..987cec36 --- /dev/null +++ b/wallettemplate/src/main/java/wallettemplate/WalletPasswordController.java @@ -0,0 +1,106 @@ +package wallettemplate; + +import com.google.bitcoin.crypto.KeyCrypterScrypt; +import com.google.common.primitives.Longs; +import com.google.protobuf.ByteString; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.event.ActionEvent; +import javafx.fxml.FXML; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.image.ImageView; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.spongycastle.crypto.params.KeyParameter; +import wallettemplate.utils.KeyDerivationTasks; + +import java.time.Duration; + +import static com.google.common.base.Preconditions.checkNotNull; +import static wallettemplate.utils.GuiUtils.*; + +/** + * User interface for entering a password on demand, e.g. to send money. Also used when encrypting a wallet. Shows a + * progress meter as we scrypt the password. + */ +public class WalletPasswordController { + private static final Logger log = LoggerFactory.getLogger(WalletPasswordController.class); + + @FXML HBox buttonsBox; + @FXML PasswordField pass1; + @FXML ImageView padlockImage; + @FXML ProgressIndicator progressMeter; + @FXML GridPane widgetGrid; + @FXML Label explanationLabel; + + public Main.OverlayUI overlayUI; + + private SimpleObjectProperty aesKey = new SimpleObjectProperty<>(); + + public void initialize() { + progressMeter.setOpacity(0); + } + + @FXML void confirmClicked(ActionEvent event) { + String password = pass1.getText(); + if (password.isEmpty() || password.length() < 4) { + informationalAlert("Bad password", "The password you entered is empty or too short."); + return; + } + + final KeyCrypterScrypt keyCrypter = (KeyCrypterScrypt) Main.bitcoin.wallet().getKeyCrypter(); + checkNotNull(keyCrypter); // We should never arrive at this GUI if the wallet isn't actually encrypted. + KeyDerivationTasks tasks = new KeyDerivationTasks(keyCrypter, password, getTargetTime()) { + @Override + protected void onFinish(KeyParameter aesKey) { + super.onFinish(aesKey); + checkGuiThread(); + if (Main.bitcoin.wallet().checkAESKey(aesKey)) { + WalletPasswordController.this.aesKey.set(aesKey); + } else { + log.warn("User entered incorrect password"); + fadeOut(progressMeter); + fadeIn(widgetGrid); + fadeIn(explanationLabel); + fadeIn(buttonsBox); + informationalAlert("Wrong password", + "Please try entering your password again, carefully checking for typos or spelling errors."); + } + } + }; + progressMeter.progressProperty().bind(tasks.progress); + tasks.start(); + + fadeIn(progressMeter); + fadeOut(widgetGrid); + fadeOut(explanationLabel); + fadeOut(buttonsBox); + } + + public void cancelClicked(ActionEvent event) { + overlayUI.done(); + } + + public ReadOnlyObjectProperty aesKeyProperty() { + return aesKey; + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + public static final String TAG = WalletPasswordController.class.getName() + ".target-time"; + + // Writes the given time to the wallet as a tag so we can find it again in this class. + public static void setTargetTime(Duration targetTime) { + ByteString bytes = ByteString.copyFrom(Longs.toByteArray(targetTime.toMillis())); + Main.bitcoin.wallet().setTag(TAG, bytes); + } + + // Reads target time or throws if not set yet (should never happen). + public static Duration getTargetTime() throws IllegalArgumentException { + return Duration.ofMillis(Longs.fromByteArray(Main.bitcoin.wallet().getTag(TAG).toByteArray())); + } +} diff --git a/wallettemplate/src/main/java/wallettemplate/WalletSetPasswordController.java b/wallettemplate/src/main/java/wallettemplate/WalletSetPasswordController.java new file mode 100644 index 00000000..2fd97ce9 --- /dev/null +++ b/wallettemplate/src/main/java/wallettemplate/WalletSetPasswordController.java @@ -0,0 +1,110 @@ +package wallettemplate; + +import com.google.bitcoin.crypto.KeyCrypterScrypt; +import javafx.event.ActionEvent; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.image.ImageView; +import javafx.scene.layout.GridPane; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.spongycastle.crypto.params.KeyParameter; +import wallettemplate.utils.KeyDerivationTasks; + +import java.time.Duration; + +import static wallettemplate.utils.GuiUtils.*; + +public class WalletSetPasswordController { + private static final Logger log = LoggerFactory.getLogger(WalletSetPasswordController.class); + public PasswordField pass1, pass2; + + public ImageView padlockImage; + public ProgressIndicator progressMeter; + public GridPane widgetGrid; + public Button closeButton; + public Label explanationLabel; + + public Main.OverlayUI overlayUI; + + public void initialize() { + padlockImage.setOpacity(0); + progressMeter.setOpacity(0); + } + + public void setPasswordClicked(ActionEvent event) { + if (!pass1.getText().equals(pass2.getText())) { + informationalAlert("Passwords do not match", "Try re-typing your chosen passwords."); + return; + } + String password = pass1.getText(); + // This is kind of arbitrary and we could do much more to help people pick strong passwords. + if (password.length() < 4) { + informationalAlert("Password too short", "You need to pick a password at least five characters or longer."); + return; + } + + fadeIn(progressMeter); + fadeIn(padlockImage); + fadeOut(widgetGrid); + fadeOut(explanationLabel); + fadeOut(closeButton); + + // Figure out how fast this computer can scrypt. We do it on the UI thread because the delay should be small + // and so we don't really care about blocking here. + IdealPasswordParameters params = new IdealPasswordParameters(password); + KeyCrypterScrypt scrypt = new KeyCrypterScrypt(params.realIterations); + // Write the target time to the wallet so we can make the progress bar work when entering the password. + WalletPasswordController.setTargetTime(params.realTargetTime); + + // Deriving the actual key runs on a background thread. + KeyDerivationTasks tasks = new KeyDerivationTasks(scrypt, password, params.realTargetTime) { + @Override + protected void onFinish(KeyParameter aesKey) { + // The actual encryption part doesn't take very long as most private keys are derived on demand. + Main.bitcoin.wallet().encrypt(scrypt, aesKey); + fadeIn(explanationLabel); + fadeIn(widgetGrid); + fadeIn(closeButton); + fadeOut(progressMeter); + fadeOut(padlockImage); + } + }; + progressMeter.progressProperty().bind(tasks.progress); + tasks.start(); + } + + public void closeClicked(ActionEvent event) { + overlayUI.done(); + } + + private static class IdealPasswordParameters { + public final int realIterations; + public final Duration realTargetTime; + + public IdealPasswordParameters(String password) { + final int targetTimeMsec = 2000; + + int iterations = 16384; + KeyCrypterScrypt scrypt = new KeyCrypterScrypt(iterations); + long now = System.currentTimeMillis(); + scrypt.deriveKey(password); + long time = System.currentTimeMillis() - now; + log.info("Initial iterations took {} msec", time); + + // N can only be a power of two, so we keep shifting both iterations and doubling time taken + // until we are in sorta the right general area. + while (time < targetTimeMsec) { + iterations <<= 1; + time *= 2; + } + + realIterations = iterations; + // Fudge it by +10% to ensure our progress meter is always a bit behind the real encryption. Plus + // without this it seems the real scrypting always takes a bit longer than we estimated for some reason. + realTargetTime = Duration.ofMillis((long) (time * 1.1)); + } + } +} diff --git a/wallettemplate/src/main/java/wallettemplate/WalletSettingsController.java b/wallettemplate/src/main/java/wallettemplate/WalletSettingsController.java index 38d07df8..38199b89 100644 --- a/wallettemplate/src/main/java/wallettemplate/WalletSettingsController.java +++ b/wallettemplate/src/main/java/wallettemplate/WalletSettingsController.java @@ -14,14 +14,19 @@ import javafx.scene.control.DatePicker; import javafx.scene.control.TextArea; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.spongycastle.crypto.params.KeyParameter; import wallettemplate.utils.TextFieldValidator; +import javax.annotation.Nullable; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; import java.time.ZoneOffset; +import java.util.List; +import static com.google.common.base.Preconditions.checkNotNull; import static javafx.beans.binding.Bindings.*; +import static wallettemplate.utils.GuiUtils.checkGuiThread; import static wallettemplate.utils.GuiUtils.informationalAlert; import static wallettemplate.utils.WTUtils.didThrow; import static wallettemplate.utils.WTUtils.unchecked; @@ -29,15 +34,31 @@ import static wallettemplate.utils.WTUtils.unchecked; public class WalletSettingsController { private static final Logger log = LoggerFactory.getLogger(WalletSettingsController.class); + @FXML Button passwordButton; @FXML DatePicker datePicker; @FXML TextArea wordsArea; @FXML Button restoreButton; - public Main.OverlayUI overlayUi; + public Main.OverlayUI overlayUI; - // Called by FXMLLoader - public void initialize() { + private KeyParameter aesKey; + + // Note: NOT called by FXMLLoader! + public void initialize(@Nullable KeyParameter aesKey) { DeterministicSeed seed = Main.bitcoin.wallet().getKeyChainSeed(); + if (aesKey == null) { + if (seed.isEncrypted()) { + log.info("Wallet is encrypted, requesting password first."); + // Delay execution of this until after we've finished initialising this screen. + Platform.runLater(() -> askForPasswordAndRetry()); + return; + } + } else { + this.aesKey = aesKey; + seed = seed.decrypt(checkNotNull(Main.bitcoin.wallet().getKeyCrypter()), "", aesKey); + // Now we can display the wallet seed as appropriate. + passwordButton.setText("Remove password"); + } // Set the date picker to show the birthday of this wallet. Instant creationTime = Instant.ofEpochSecond(seed.getCreationTimeSeconds()); @@ -45,7 +66,9 @@ public class WalletSettingsController { datePicker.setValue(origDate); // Set the mnemonic seed words. - String origWords = Joiner.on(" ").join(seed.getMnemonicCode()); + final List mnemonicCode = seed.getMnemonicCode(); + checkNotNull(mnemonicCode); // Already checked for encryption. + String origWords = Joiner.on(" ").join(mnemonicCode); wordsArea.setText(origWords); // Validate words as they are being typed. @@ -92,8 +115,19 @@ public class WalletSettingsController { }); } + private void askForPasswordAndRetry() { + Main.OverlayUI pwd = Main.instance.overlayUI("wallet_password.fxml"); + pwd.controller.aesKeyProperty().addListener((observable, old, cur) -> { + // We only get here if the user found the right password. If they don't or they cancel, we end up back on + // the main UI screen. + checkGuiThread(); + Main.OverlayUI screen = Main.instance.overlayUI("wallet_settings.fxml"); + screen.controller.initialize(cur); + }); + } + public void closeClicked(ActionEvent event) { - overlayUi.done(); + overlayUI.done(); } public void restoreClicked(ActionEvent event) { @@ -106,10 +140,16 @@ public class WalletSettingsController { return; } + if (aesKey != null) { + // This is weak. We should encrypt the new seed here. + informationalAlert("Wallet is encrypted", + "After restore, the wallet will no longer be encrypted and you must set a new password."); + } + log.info("Attempting wallet restore using seed '{}' from date {}", wordsArea.getText(), datePicker.getValue()); informationalAlert("Wallet restore in progress", "Your wallet will now be resynced from the Bitcoin network. This can take a long time for old wallets."); - overlayUi.done(); + overlayUI.done(); Main.instance.controller.restoreFromSeedAnimation(); long birthday = datePicker.getValue().atStartOfDay().toEpochSecond(ZoneOffset.UTC); @@ -124,4 +164,15 @@ public class WalletSettingsController { }, Platform::runLater); Main.bitcoin.stopAsync(); } + + + public void passwordButtonClicked(ActionEvent event) { + if (aesKey == null) { + Main.instance.overlayUI("wallet_set_password.fxml"); + } else { + Main.bitcoin.wallet().decrypt(aesKey); + informationalAlert("Wallet decrypted", "A password will no longer be required to send money or edit settings."); + passwordButton.setText("Set password"); + } + } } diff --git a/wallettemplate/src/main/java/wallettemplate/utils/GuiUtils.java b/wallettemplate/src/main/java/wallettemplate/utils/GuiUtils.java index 1d6728db..c4728615 100644 --- a/wallettemplate/src/main/java/wallettemplate/utils/GuiUtils.java +++ b/wallettemplate/src/main/java/wallettemplate/utils/GuiUtils.java @@ -11,11 +11,14 @@ import javafx.scene.layout.Pane; import javafx.stage.Modality; import javafx.stage.Stage; import javafx.util.Duration; +import wallettemplate.Controller; import java.io.IOException; +import java.net.URL; import java.util.function.BiConsumer; import static com.google.common.base.Preconditions.checkState; +import static wallettemplate.utils.WTUtils.unchecked; public class GuiUtils { public static void runAlert(BiConsumer setup) { @@ -66,13 +69,21 @@ public class GuiUtils { Platform.runLater(r); } - private static final int UI_ANIMATION_TIME_MSEC = 350; + private static final int UI_ANIMATION_TIME_MSEC = 600; - public static void fadeIn(Node ui) { + public static Animation fadeIn(Node ui) { + return fadeIn(ui, 0); + } + + public static Animation fadeIn(Node ui, int delayMillis) { + ui.setCache(true); FadeTransition ft = new FadeTransition(Duration.millis(UI_ANIMATION_TIME_MSEC), ui); ft.setFromValue(0.0); ft.setToValue(1.0); + ft.setOnFinished(ev -> ui.setCache(false)); + ft.setDelay(Duration.millis(delayMillis)); ft.play(); + return ft; } public static Animation fadeOut(Node ui) { @@ -83,12 +94,22 @@ public class GuiUtils { return ft; } - public static Animation fadeOutAndRemove(Node ui, Pane parentPane) { - Animation animation = fadeOut(ui); - animation.setOnFinished(actionEvent -> parentPane.getChildren().remove(ui)); + public static Animation fadeOutAndRemove(Pane parentPane, Node... nodes) { + Animation animation = fadeOut(nodes[0]); + animation.setOnFinished(actionEvent -> parentPane.getChildren().removeAll(nodes)); return animation; } + public static Animation fadeOutAndRemove(Duration duration, Pane parentPane, Node... nodes) { + nodes[0].setCache(true); + FadeTransition ft = new FadeTransition(duration, nodes[0]); + ft.setFromValue(nodes[0].getOpacity()); + ft.setToValue(0.0); + ft.setOnFinished(actionEvent -> parentPane.getChildren().removeAll(nodes)); + ft.play(); + return ft; + } + public static void blurOut(Node node) { GaussianBlur blur = new GaussianBlur(0.0); node.setEffect(blur); @@ -109,6 +130,40 @@ public class GuiUtils { timeline.play(); } + public static ScaleTransition zoomIn(Node node) { + return zoomIn(node, 0); + } + + public static ScaleTransition zoomIn(Node node, int delayMillis) { + return scaleFromTo(node, 0.95, 1.0, delayMillis); + } + + public static ScaleTransition explodeOut(Node node) { + return scaleFromTo(node, 1.0, 1.05, 0); + } + + private static ScaleTransition scaleFromTo(Node node, double from, double to, int delayMillis) { + ScaleTransition scale = new ScaleTransition(Duration.millis(UI_ANIMATION_TIME_MSEC / 2), node); + scale.setFromX(from); + scale.setFromY(from); + scale.setToX(to); + scale.setToY(to); + scale.setDelay(Duration.millis(delayMillis)); + scale.play(); + return scale; + } + + /** + * A useful helper for development purposes. Used as a switch for loading files from local disk, allowing live + * editing whilst the app runs without rebuilds. + */ + public static URL getResource(String name) { + if (false) + return unchecked(() -> new URL("file:///your/path/here/src/main/wallettemplate/" + name)); + else + return Controller.class.getResource(name); + } + public static void checkGuiThread() { checkState(Platform.isFxApplicationThread()); } diff --git a/wallettemplate/src/main/java/wallettemplate/utils/KeyDerivationTasks.java b/wallettemplate/src/main/java/wallettemplate/utils/KeyDerivationTasks.java new file mode 100644 index 00000000..19e4ee99 --- /dev/null +++ b/wallettemplate/src/main/java/wallettemplate/utils/KeyDerivationTasks.java @@ -0,0 +1,80 @@ +package wallettemplate.utils; + +import com.google.bitcoin.crypto.KeyCrypterScrypt; +import com.google.common.util.concurrent.Uninterruptibles; +import javafx.beans.property.ReadOnlyDoubleProperty; +import javafx.concurrent.Task; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.spongycastle.crypto.params.KeyParameter; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import static wallettemplate.utils.GuiUtils.checkGuiThread; + +/** + * Background tasks for pumping a progress meter and deriving an AES key using scrypt. + */ +public class KeyDerivationTasks { + private static final Logger log = LoggerFactory.getLogger(KeyDerivationTasks.class); + + public final Task keyDerivationTask; + public final ReadOnlyDoubleProperty progress; + + private final Task progressTask; + + public KeyDerivationTasks(KeyCrypterScrypt scrypt, String password, Duration targetTime) { + keyDerivationTask = new Task() { + @Override + protected KeyParameter call() throws Exception { + try { + return scrypt.deriveKey(password); + } catch (Throwable e) { + e.printStackTrace(); + throw e; + } finally { + log.info("Key derivation done"); + } + } + }; + + // And the fake progress meter ... + progressTask = new Task() { + private KeyParameter aesKey; + + @Override + protected Void call() throws Exception { + long startTime = System.currentTimeMillis(); + long curTime; + long targetTimeMillis = targetTime.toMillis(); + while ((curTime = System.currentTimeMillis()) < startTime + targetTimeMillis) { + double progress = (curTime - startTime) / (double) targetTimeMillis; + updateProgress(progress, 1.0); + + // 60fps would require 16msec sleep here. + Uninterruptibles.sleepUninterruptibly(20, TimeUnit.MILLISECONDS); + } + // Wait for the encryption thread before switching back to main UI. + updateProgress(1.0, 1.0); + aesKey = keyDerivationTask.get(); + return null; + } + + @Override + protected void succeeded() { + checkGuiThread(); + onFinish(aesKey); + } + }; + progress = progressTask.progressProperty(); + } + + public void start() { + new Thread(keyDerivationTask, "Key derivation").start(); + new Thread(progressTask, "Progress ticker").start(); + } + + protected void onFinish(KeyParameter aesKey) { + } +} diff --git a/wallettemplate/src/main/resources/wallettemplate/wallet.css b/wallettemplate/src/main/resources/wallettemplate/wallet.css index 194d988e..9f46bff0 100644 --- a/wallettemplate/src/main/resources/wallettemplate/wallet.css +++ b/wallettemplate/src/main/resources/wallettemplate/wallet.css @@ -13,4 +13,4 @@ .root-pane { -fx-background-color: white; -} \ No newline at end of file +} diff --git a/wallettemplate/src/main/resources/wallettemplate/wallet_password.fxml b/wallettemplate/src/main/resources/wallettemplate/wallet_password.fxml new file mode 100644 index 00000000..81badd6a --- /dev/null +++ b/wallettemplate/src/main/resources/wallettemplate/wallet_password.fxml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +