mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2026-07-31 03:56:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53d1f196e4 | ||
|
|
64b8da14a6 | ||
|
|
529cc3d6e7 | ||
|
|
e13fe897e1 | ||
|
|
dac1a61601 | ||
|
|
227c7ae637 | ||
|
|
e11675b9a8 | ||
|
|
146e6cbdc4 | ||
|
|
e4fa0c9356 | ||
|
|
30719c5a7f | ||
|
|
a120d08ea1 | ||
|
|
b51bd90261 | ||
|
|
a765105d34 | ||
|
|
2cd7d0bdf3 | ||
|
|
c6c24028d9 | ||
|
|
efb81555bd | ||
|
|
b11afdf2bb | ||
|
|
779a55c494 | ||
|
|
b06d858794 | ||
|
|
306c025cbf | ||
|
|
b145bddf07 | ||
|
|
edee72d97d | ||
|
|
b87650a740 | ||
|
|
0c9bbc2550 | ||
|
|
088f8e302c | ||
|
|
4309216ad7 | ||
|
|
dffaf806cd | ||
|
|
448067db54 | ||
|
|
6b498c28c2 | ||
|
|
4724dc7700 | ||
|
|
883558fd9c | ||
|
|
9d576bc45d | ||
|
|
49d807f31b | ||
|
|
0c2ee6690f | ||
|
|
85c81adc09 | ||
|
|
04de83706e | ||
|
|
ab99f1d392 | ||
|
|
53857389b7 | ||
|
|
98576d40c6 | ||
|
|
96f7d4bdb3 | ||
|
|
0c67962730 | ||
|
|
0515d8056b | ||
|
|
cf15760d34 | ||
|
|
f28f15df66 | ||
|
|
34900d2909 | ||
|
|
430f2ee320 | ||
|
|
74160ba35f | ||
|
|
59d85cdd05 | ||
|
|
0406421918 | ||
|
|
4ddd09fe22 | ||
|
|
a49edb7c45 | ||
|
|
5275cab436 | ||
|
|
4dcd463d92 | ||
|
|
f900f6dcb2 | ||
|
|
b0ab6c9d42 | ||
|
|
b8a703add2 | ||
|
|
4185a8dced | ||
|
|
a608197038 | ||
|
|
b4630043eb | ||
|
|
6452bf8184 | ||
|
|
71cb5852b3 | ||
|
|
8efee4eaae | ||
|
|
ac044c6f71 | ||
|
|
d2d45e5432 | ||
|
|
c16997eacc | ||
|
|
21543de007 | ||
|
|
6f772cb193 | ||
|
|
a4634f6a3d | ||
|
|
a794dafdf9 | ||
|
|
9fb19ebe8b | ||
|
|
46a39225bd | ||
|
|
a0f7e2e65d | ||
|
|
39cb946e08 | ||
|
|
0894407b0b | ||
|
|
c76acb8d82 | ||
|
|
16e73ebc32 | ||
|
|
3a5fa69fb6 | ||
|
|
4774830ce4 | ||
|
|
2f62a9e9c8 | ||
|
|
75bcfe2253 | ||
|
|
bedf1399ea | ||
|
|
58575793ea | ||
|
|
6c9b580d4f | ||
|
|
31909b7a15 | ||
|
|
092267339a | ||
|
|
0974918cff | ||
|
|
0f4c36b3c2 | ||
|
|
e1fe35fb74 | ||
|
|
d37fd00c4b | ||
|
|
5f54f86df7 |
@@ -10,48 +10,62 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-2022, ubuntu-22.04, ubuntu-22.04-arm, macos-13, macos-14]
|
||||
os: [windows-2022, ubuntu-22.04, ubuntu-22.04-arm, macos-15-intel, macos-14]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
- name: Set up JDK 22.0.2
|
||||
submodules: recursive
|
||||
- name: Clear Java tool-cache for reproducibility
|
||||
shell: bash
|
||||
run: rm -rf "$RUNNER_TOOL_CACHE"/Java_*
|
||||
- name: Set up JDK 25.0.2
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '22.0.2'
|
||||
java-version: '25.0.2'
|
||||
- name: Show Build Versions
|
||||
run: ./gradlew -v
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew jpackage
|
||||
- name: Package zip distribution
|
||||
if: ${{ runner.os == 'Windows' || runner.os == 'macOS' }}
|
||||
- name: Codesign, package and notarize macOS distribution
|
||||
if: ${{ runner.os == 'macOS' }}
|
||||
uses: sparrowwallet/github-actions/codesign-macos@v1
|
||||
with:
|
||||
app-name: Sparrow
|
||||
certificate: ${{ secrets.MACOS_CERTIFICATE }}
|
||||
certificate-password: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
|
||||
apple-id: ${{ secrets.MACOS_NOTARIZATION_APPLE_ID }}
|
||||
team-id: ${{ secrets.MACOS_NOTARIZATION_TEAM_ID }}
|
||||
notarization-password: ${{ secrets.MACOS_NOTARIZATION_PASSWORD }}
|
||||
- name: Package Windows zip distribution
|
||||
if: ${{ runner.os == 'Windows' }}
|
||||
run: ./gradlew packageZipDistribution
|
||||
- name: Package tar distribution
|
||||
- name: Package Linux tar distribution
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: ./gradlew packageTarDistribution
|
||||
- name: Repackage deb distribution
|
||||
- name: Repackage Linux deb distribution
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: ./repackage.sh
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Sparrow Build - ${{ runner.os }} ${{ runner.arch }}
|
||||
path: |
|
||||
build/jpackage/*
|
||||
!build/jpackage/Sparrow/
|
||||
!build/jpackage/Sparrow.app/
|
||||
- name: Headless build with Gradle
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: ./gradlew -Djava.awt.headless=true clean jpackage
|
||||
- name: Package headless tar distribution
|
||||
- name: Package Linux headless tar distribution
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: ./gradlew -Djava.awt.headless=true packageTarDistribution
|
||||
- name: Repackage headless deb distribution
|
||||
- name: Repackage Linux headless deb distribution
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: ./repackage.sh
|
||||
- name: Upload Headless Artifact
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Sparrow Build - ${{ runner.os }} ${{ runner.arch }} Headless
|
||||
path: |
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Enable auto-env through the sdkman_auto_env config
|
||||
# Add key=value pairs of SDKs to use below
|
||||
java=25.0.2-tem
|
||||
@@ -16,14 +16,14 @@ or for those without SSH credentials:
|
||||
|
||||
`git clone --recursive https://github.com/sparrowwallet/sparrow.git`
|
||||
|
||||
In order to build, Sparrow requires Java 22 or higher to be installed.
|
||||
The release binaries are built with [Eclipse Temurin 22.0.2+9](https://github.com/adoptium/temurin22-binaries/releases/tag/jdk-22.0.2%2B9).
|
||||
In order to build, Sparrow requires Java 25 or higher to be installed.
|
||||
The release binaries are built with [Eclipse Temurin 25.0.2+10](https://github.com/adoptium/temurin25-binaries/releases/tag/jdk-25.0.2%2B10).
|
||||
If you are using [SDKMAN](https://sdkman.io/), you can use `sdk env install` to ensure you have the correct version.
|
||||
|
||||
Other packages may also be necessary to build depending on the platform. On Debian/Ubuntu systems:
|
||||
|
||||
`sudo apt install -y rpm fakeroot binutils`
|
||||
|
||||
|
||||
The Sparrow binaries can be built from source using
|
||||
|
||||
`./gradlew jpackage`
|
||||
@@ -44,7 +44,7 @@ If you prefer to run Sparrow directly from source, it can be launched from withi
|
||||
|
||||
`./sparrow`
|
||||
|
||||
Java 22 or higher must be installed.
|
||||
Java 25 or higher must be installed.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
+31
-34
@@ -1,9 +1,9 @@
|
||||
plugins {
|
||||
id 'application'
|
||||
id 'org-openjfx-javafxplugin'
|
||||
id 'org.beryx.jlink' version '3.1.3'
|
||||
id 'org.gradlex.extra-java-module-info' version '1.13'
|
||||
id 'io.matthewnelson.kmp.tor.resource-filterjar' version '408.16.3'
|
||||
id 'org.beryx.jlink' version '3.2.1'
|
||||
id 'org.gradlex.extra-java-module-info' version '1.13.1'
|
||||
id 'io.matthewnelson.kmp.tor.resource-filterjar' version '408.21.0'
|
||||
}
|
||||
|
||||
def os = org.gradle.internal.os.OperatingSystem.current()
|
||||
@@ -20,7 +20,7 @@ if(System.getProperty("os.arch") == "aarch64") {
|
||||
def headless = "true".equals(System.getProperty("java.awt.headless"))
|
||||
|
||||
group = 'com.sparrowwallet'
|
||||
version = '2.3.0'
|
||||
version = '2.4.1'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
@@ -32,7 +32,7 @@ tasks.withType(AbstractArchiveTask).configureEach {
|
||||
}
|
||||
|
||||
javafx {
|
||||
version = headless ? "18" : "23.0.2"
|
||||
version = headless ? "18" : "25.0.2"
|
||||
modules = [ 'javafx.controls', 'javafx.fxml', 'javafx.swing', 'javafx.graphics' ]
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ dependencies {
|
||||
exclude group: 'org.slf4j'
|
||||
}
|
||||
implementation('org.flywaydb:flyway-core:9.22.3')
|
||||
implementation('org.fxmisc.richtext:richtextfx:0.11.6')
|
||||
implementation('org.fxmisc.richtext:richtextfx:0.11.7')
|
||||
implementation('no.tornado:tornadofx-controls:1.0.4')
|
||||
implementation('com.google.zxing:javase:3.4.0') {
|
||||
exclude group: 'com.beust', module: 'jcommander'
|
||||
@@ -73,13 +73,15 @@ dependencies {
|
||||
implementation('com.fasterxml.jackson.core:jackson-databind:2.17.2')
|
||||
implementation('com.sparrowwallet:hummingbird:1.7.4')
|
||||
implementation('co.nstant.in:cbor:0.9')
|
||||
implementation('org.openpnp:openpnp-capture-java:0.0.28-6')
|
||||
implementation("io.matthewnelson.kmp-tor:runtime:2.2.1")
|
||||
implementation("io.matthewnelson.kmp-tor:resource-exec-tor-gpl:408.16.3")
|
||||
implementation('org.openpnp:openpnp-capture-java:0.0.30-1')
|
||||
implementation("io.matthewnelson.kmp-tor:runtime:2.5.0")
|
||||
implementation("io.matthewnelson.kmp-tor:resource-exec-tor-gpl:408.21.0")
|
||||
implementation('org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.10.1') {
|
||||
exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-common'
|
||||
}
|
||||
implementation('de.codecentric.centerdevice:centerdevice-nsmenufx:2.1.7')
|
||||
implementation('de.jangassen:nsmenufx:3.1.0') {
|
||||
exclude group: 'net.java.dev.jna', module: 'jna'
|
||||
}
|
||||
implementation('org.controlsfx:controlsfx:11.1.0' ) {
|
||||
exclude group: 'org.openjfx', module: 'javafx-base'
|
||||
exclude group: 'org.openjfx', module: 'javafx-graphics'
|
||||
@@ -108,7 +110,7 @@ dependencies {
|
||||
implementation('com.github.hervegirod:fxsvgimage:1.1')
|
||||
implementation('com.sparrowwallet:toucan:0.9.0')
|
||||
implementation('com.jcraft:jzlib:1.1.3')
|
||||
implementation('io.github.doblon8:jzbar:0.0.1')
|
||||
implementation('io.github.doblon8:jzbar:0.3.0')
|
||||
testImplementation('org.junit.jupiter:junit-jupiter-api:5.10.0')
|
||||
testRuntimeOnly('org.junit.jupiter:junit-jupiter-engine:5.10.0')
|
||||
testRuntimeOnly('org.junit.platform:junit-platform-launcher')
|
||||
@@ -133,7 +135,7 @@ processResources {
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
jvmArgs = ["--add-opens=java.base/java.io=ALL-UNNAMED", "--add-opens=java.base/java.io=com.google.gson", "--add-reads=org.flywaydb.core=java.desktop"]
|
||||
jvmArgs = ["--add-opens=java.base/java.io=ALL-UNNAMED", "--enable-native-access=ALL-UNNAMED"]
|
||||
}
|
||||
|
||||
application {
|
||||
@@ -156,11 +158,6 @@ application {
|
||||
"--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow",
|
||||
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow",
|
||||
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=javafx.fxml",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.tk=centerdevice.nsmenufx",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.tk.quantum=centerdevice.nsmenufx",
|
||||
"--add-opens=javafx.graphics/com.sun.glass.ui=centerdevice.nsmenufx",
|
||||
"--add-opens=javafx.controls/com.sun.javafx.scene.control=centerdevice.nsmenufx",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.menu=centerdevice.nsmenufx",
|
||||
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
|
||||
"--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow",
|
||||
@@ -171,8 +168,7 @@ application {
|
||||
"--add-reads=org.flywaydb.core=java.desktop"]
|
||||
|
||||
if(os.macOsX) {
|
||||
applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow",
|
||||
"--add-opens=javafx.graphics/com.sun.glass.ui.mac=centerdevice.nsmenufx"]
|
||||
applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow"]
|
||||
}
|
||||
if(headless) {
|
||||
applicationDefaultJvmArgs += ["-Dglass.platform=Monocle", "-Dmonocle.platform=Headless", "-Dprism.order=sw"]
|
||||
@@ -189,6 +185,7 @@ jlink {
|
||||
requires 'jdk.crypto.cryptoki'
|
||||
requires 'java.management'
|
||||
requires 'io.leangen.geantyref'
|
||||
requires 'static jdk.jfr'
|
||||
uses 'org.eclipse.jetty.http.HttpFieldPreEncoder'
|
||||
}
|
||||
|
||||
@@ -211,11 +208,6 @@ jlink {
|
||||
"--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow",
|
||||
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow",
|
||||
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=javafx.fxml",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.tk=centerdevice.nsmenufx",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.tk.quantum=centerdevice.nsmenufx",
|
||||
"--add-opens=javafx.graphics/com.sun.glass.ui=centerdevice.nsmenufx",
|
||||
"--add-opens=javafx.controls/com.sun.javafx.scene.control=centerdevice.nsmenufx",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.menu=centerdevice.nsmenufx",
|
||||
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
|
||||
"--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
|
||||
@@ -242,7 +234,7 @@ jlink {
|
||||
jvmArgs += ["-Djavax.accessibility.assistive_technologies", "-Djavax.accessibility.screen_magnifier_present=false"]
|
||||
}
|
||||
if(os.macOsX) {
|
||||
jvmArgs += ["-Dprism.lcdtext=false", "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module", "--add-opens=javafx.graphics/com.sun.glass.ui.mac=centerdevice.nsmenufx"]
|
||||
jvmArgs += ["-Dprism.lcdtext=false", "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module"]
|
||||
}
|
||||
if(headless) {
|
||||
jvmArgs += ["-Dglass.platform=Monocle", "-Dmonocle.platform=Headless", "-Dprism.order=sw"]
|
||||
@@ -255,7 +247,7 @@ jlink {
|
||||
appVersion = "${version}"
|
||||
skipInstaller = os.macOsX || properties.skipInstallers
|
||||
imageOptions = []
|
||||
installerOptions = ['--file-associations', 'src/main/deploy/psbt.properties', '--file-associations', 'src/main/deploy/txn.properties', '--file-associations', 'src/main/deploy/asc.properties', '--file-associations', 'src/main/deploy/bitcoin.properties', '--file-associations', 'src/main/deploy/auth47.properties', '--file-associations', 'src/main/deploy/lightning.properties', '--license-file', 'LICENSE']
|
||||
installerOptions = ['--file-associations', 'src/main/deploy/psbt.properties', '--file-associations', 'src/main/deploy/txn.properties', '--file-associations', 'src/main/deploy/asc.properties', '--license-file', 'LICENSE']
|
||||
if(os.windows) {
|
||||
installerOptions += ['--win-per-user-install', '--win-dir-chooser', '--win-menu', '--win-menu-group', 'Sparrow', '--win-shortcut', '--resource-dir', 'src/main/deploy/package/windows/']
|
||||
imageOptions += ['--icon', 'src/main/deploy/package/windows/sparrow.ico']
|
||||
@@ -274,7 +266,7 @@ jlink {
|
||||
}
|
||||
if(os.macOsX) {
|
||||
installerOptions += ['--mac-sign', '--mac-signing-key-user-name', 'Craig Raw (UPLVMSK9D7)']
|
||||
imageOptions += ['--icon', 'src/main/deploy/package/osx/sparrow.icns', '--resource-dir', 'src/main/deploy/package/osx/']
|
||||
imageOptions += ['--icon', 'src/main/deploy/package/macos/sparrow.icns', '--resource-dir', 'src/main/deploy/package/macos/']
|
||||
installerType = "dmg"
|
||||
}
|
||||
}
|
||||
@@ -288,13 +280,17 @@ jlink {
|
||||
if(os.linux) {
|
||||
tasks.jlink.finalizedBy('addUserWritePermission', 'copyUdevRules')
|
||||
tasks.jpackageImage.finalizedBy('prepareResourceDir')
|
||||
if(!headless) {
|
||||
tasks.jpackage.dependsOn('copyMimeInfo')
|
||||
}
|
||||
} else {
|
||||
tasks.jlink.finalizedBy('addUserWritePermission')
|
||||
}
|
||||
|
||||
tasks.register('addUserWritePermission', Exec) {
|
||||
if(os.windows) {
|
||||
commandLine 'icacls', "$buildDir\\image\\legal", '/grant', 'Users:(OI)(CI)F', '/T'
|
||||
def usersGroup = '*S-1-5-32-545' // Windows "Users" group SID (language-independent)
|
||||
commandLine 'icacls', "$buildDir\\image\\legal", '/grant', "${usersGroup}:(OI)(CI)F", '/T'
|
||||
} else {
|
||||
commandLine 'chmod', '-R', 'u+w', "$buildDir/image/legal"
|
||||
}
|
||||
@@ -322,6 +318,13 @@ tasks.register('prepareResourceDir', Copy) {
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('copyMimeInfo', Copy) {
|
||||
mustRunAfter tasks.jpackageImage
|
||||
from('src/main/deploy/package/linux')
|
||||
into(layout.buildDirectory.dir('jpackage/Sparrow/lib'))
|
||||
include('sparrowwallet-Sparrow-MimeInfo.xml')
|
||||
}
|
||||
|
||||
static def getDirectorySize(File directory) {
|
||||
long size = 0
|
||||
if(directory.isFile()) {
|
||||
@@ -394,12 +397,6 @@ extraJavaModuleInfo {
|
||||
requires('java.desktop')
|
||||
requires('com.sun.jna')
|
||||
}
|
||||
module('de.codecentric.centerdevice:centerdevice-nsmenufx', 'centerdevice.nsmenufx') {
|
||||
exports('de.codecentric.centerdevice')
|
||||
requires('javafx.base')
|
||||
requires('javafx.controls')
|
||||
requires('javafx.graphics')
|
||||
}
|
||||
module('net.sourceforge.javacsv:javacsv', 'net.sourceforge.javacsv') {
|
||||
exports('com.csvreader')
|
||||
}
|
||||
|
||||
+20
-47
@@ -12,63 +12,36 @@ Work on resolving both of these issues is ongoing.
|
||||
### Install Java
|
||||
|
||||
Because Sparrow bundles a Java runtime in the release binaries, it is essential to have the same version of Java installed when creating the release.
|
||||
For v1.6.6 to v1.9.1, this was Eclipse Temurin 18.0.1+10. For v2.0.0 and later, Eclipse Temurin 22.0.2+9 is used.
|
||||
For v1.6.6 to v1.9.1, this was Eclipse Temurin 18.0.1+10. For v2.0.0 to v2.3.1, this was Eclipse Temurin 22.0.2+9. For v2.4.0 and later, Eclipse Temurin 25.0.2+10 is used.
|
||||
|
||||
Note: Do not install Java using a system package manager (e.g. apt, dnf, rpm).
|
||||
Linux packages replace the JDK's bundled `cacerts` file with a symlink to the system CA certificates, which differ from those in the release tarballs and will produce a non-reproducible build.
|
||||
|
||||
#### Java from Adoptium github repo
|
||||
|
||||
It is available for all supported platforms from [Eclipse Temurin 22.0.2+9](https://github.com/adoptium/temurin22-binaries/releases/tag/jdk-22.0.2%2B9).
|
||||
It is available for all supported platforms from [Eclipse Temurin 25.0.2+10](https://github.com/adoptium/temurin25-binaries/releases/tag/jdk-25.0.2%2B10).
|
||||
|
||||
For reference, the downloads are as follows:
|
||||
- [Linux x64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_x64_linux_hotspot_22.0.2_9.tar.gz)
|
||||
- [Linux aarch64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_aarch64_linux_hotspot_22.0.2_9.tar.gz)
|
||||
- [MacOS x64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_x64_mac_hotspot_22.0.2_9.tar.gz)
|
||||
- [MacOS aarch64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_aarch64_mac_hotspot_22.0.2_9.tar.gz)
|
||||
- [Windows x64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_x64_windows_hotspot_22.0.2_9.zip)
|
||||
- [Linux x64](https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.2%2B10/OpenJDK25U-jdk_x64_linux_hotspot_25.0.2_10.tar.gz)
|
||||
- [Linux aarch64](https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.2%2B10/OpenJDK25U-jdk_aarch64_linux_hotspot_25.0.2_10.tar.gz)
|
||||
- [MacOS x64](https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.2%2B10/OpenJDK25U-jdk_x64_mac_hotspot_25.0.2_10.tar.gz)
|
||||
- [MacOS aarch64](https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.2%2B10/OpenJDK25U-jdk_aarch64_mac_hotspot_25.0.2_10.tar.gz)
|
||||
- [Windows x64](https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.2%2B10/OpenJDK25U-jdk_x64_windows_hotspot_25.0.2_10.zip)
|
||||
|
||||
#### Java from Adoptium deb repo
|
||||
|
||||
It is also possible to install via a package manager on *nix systems. For example, on Debian/Ubuntu systems:
|
||||
|
||||
- Install dependencies:
|
||||
```sh
|
||||
sudo apt-get install -y wget curl apt-transport-https gnupg
|
||||
On Linux, extract the tarball and set `JAVA_HOME` to use it for the build:
|
||||
```shell
|
||||
tar -xzf OpenJDK25U-jdk_x64_linux_hotspot_25.0.2_10.tar.gz
|
||||
export JAVA_HOME=$PWD/jdk-25.0.2+10
|
||||
export PATH=$JAVA_HOME/bin:$PATH
|
||||
```
|
||||
|
||||
Download Adoptium public PGP key:
|
||||
```sh
|
||||
curl --tlsv1.2 --proto =https --location -o adoptium.asc https://packages.adoptium.net/artifactory/api/gpg/key/public
|
||||
```
|
||||
#### Java from SDKMAN
|
||||
|
||||
Check if key fingerprint matches: `3B04D753C9050D9A5D343F39843C48A565F8F04B`:
|
||||
```
|
||||
gpg --import --import-options show-only adoptium.asc
|
||||
```
|
||||
If key doesn't match, do not proceed.
|
||||
|
||||
Add Adoptium PGP key to a the keyring shared folder:
|
||||
```sh
|
||||
sudo cp adoptium.asc /usr/share/keyrings/
|
||||
```
|
||||
|
||||
Add Adoptium debian repository:
|
||||
```sh
|
||||
echo "deb [signed-by=/usr/share/keyrings/adoptium.asc] https://packages.adoptium.net/artifactory/deb $(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main" | sudo tee /etc/apt/sources.list.d/adoptium.list
|
||||
```
|
||||
|
||||
Update cache, install the desired temurin version and configure java to be linked to this same version:
|
||||
```
|
||||
sudo apt update -y
|
||||
sudo apt-get install -y temurin-22-jdk=22.0.2+9
|
||||
sudo update-alternatives --config java
|
||||
```
|
||||
|
||||
#### Java from SDK
|
||||
|
||||
A alternative option for all platforms is to use the [sdkman.io](https://sdkman.io/) package manager ([Git Bash for Windows](https://git-scm.com/download/win) is a good choice on that platform).
|
||||
An alternative option for all platforms is to use the [sdkman.io](https://sdkman.io/) package manager ([Git Bash for Windows](https://git-scm.com/download/win) is a good choice on that platform).
|
||||
See the installation [instructions here](https://sdkman.io/install).
|
||||
Once installed, run
|
||||
```shell
|
||||
sdk install java 22.0.2-tem
|
||||
sdk install java 25.0.2-tem
|
||||
```
|
||||
|
||||
### Other requirements
|
||||
@@ -83,7 +56,7 @@ sudo apt install -y rpm fakeroot binutils
|
||||
First, assign a temporary variable in your shell for the specific release you want to build. For the current one specify:
|
||||
|
||||
```shell
|
||||
GIT_TAG="2.2.3"
|
||||
GIT_TAG="2.4.0"
|
||||
```
|
||||
|
||||
The project can then be initially cloned as follows:
|
||||
@@ -101,7 +74,7 @@ git checkout "${GIT_TAG}"
|
||||
```
|
||||
|
||||
Note - there is an additional step if you updated rather than initially cloned your repo at `GIT_TAG`.
|
||||
This is due to the [drongo submodule](https://github.com/sparrowwallet/drongo/tree/master) which needs to be checked out to the commit state it had at the time of the release.
|
||||
This is due to the Git submodules which need to be checked out to the commit state they had at the time of the release.
|
||||
Only then your build will be comparable to the provided one in the release section of Github.
|
||||
To checkout the submodule to the correct commit for `GIT_TAG`, additionally run:
|
||||
|
||||
|
||||
+1
-1
Submodule drongo updated: 2ced4c1996...0a500ea002
+1
-1
Submodule lark updated: c16389fdea...b074099a38
@@ -1,2 +0,0 @@
|
||||
mime-type=x-scheme-handler/auth47
|
||||
description=Auth47 Authentication URI
|
||||
@@ -1,2 +0,0 @@
|
||||
mime-type=x-scheme-handler/bitcoin
|
||||
description=Bitcoin Scheme URI
|
||||
@@ -1,2 +0,0 @@
|
||||
mime-type=x-scheme-handler/lightning
|
||||
description=LNURL URI
|
||||
@@ -26,7 +26,7 @@ case "$1" in
|
||||
xdg-mime install /opt/sparrowwallet/lib/sparrowwallet-Sparrow-MimeInfo.xml
|
||||
install -D -m 644 /opt/sparrowwallet/lib/runtime/conf/udev/*.rules /etc/udev/rules.d
|
||||
if ! getent group plugdev > /dev/null; then
|
||||
groupadd plugdev
|
||||
groupadd -r plugdev
|
||||
fi
|
||||
if ! groups "${SUDO_USER:-$(whoami)}" | grep -q plugdev; then
|
||||
usermod -aG plugdev "${SUDO_USER:-$(whoami)}"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
|
||||
<mime-type type="application/psbt">
|
||||
<comment>Partially Signed Bitcoin Transaction</comment>
|
||||
<glob pattern="*.psbt"/>
|
||||
</mime-type>
|
||||
<mime-type type="application/bitcoin-transaction">
|
||||
<comment>Bitcoin Transaction</comment>
|
||||
<glob pattern="*.txn"/>
|
||||
</mime-type>
|
||||
<mime-type type="application/pgp-signature">
|
||||
<comment>ASCII Armored File</comment>
|
||||
<glob pattern="*.asc"/>
|
||||
</mime-type>
|
||||
<mime-type type="x-scheme-handler/bitcoin">
|
||||
<comment>Bitcoin Scheme URI</comment>
|
||||
</mime-type>
|
||||
<mime-type type="x-scheme-handler/auth47">
|
||||
<comment>Auth47 Authentication URI</comment>
|
||||
</mime-type>
|
||||
<mime-type type="x-scheme-handler/lightning">
|
||||
<comment>LNURL URI</comment>
|
||||
</mime-type>
|
||||
</mime-info>
|
||||
@@ -82,7 +82,7 @@ xdg-desktop-menu install /opt/sparrowwallet/lib/sparrowwallet-Sparrow.desktop
|
||||
xdg-mime install /opt/sparrowwallet/lib/sparrowwallet-Sparrow-MimeInfo.xml
|
||||
install -D -m 644 /opt/sparrowwallet/lib/runtime/conf/udev/*.rules /etc/udev/rules.d
|
||||
if ! getent group plugdev > /dev/null; then
|
||||
groupadd plugdev
|
||||
groupadd -r plugdev
|
||||
fi
|
||||
if ! groups "${SUDO_USER:-$(whoami)}" | grep -q plugdev; then
|
||||
usermod -aG plugdev "${SUDO_USER:-$(whoami)}"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.3.0</string>
|
||||
<string>2.4.1</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
|
||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
@@ -31,7 +31,7 @@ import com.sparrowwallet.sparrow.transaction.TransactionView;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import com.sparrowwallet.sparrow.wallet.WalletController;
|
||||
import com.sparrowwallet.sparrow.wallet.WalletForm;
|
||||
import de.codecentric.centerdevice.MenuToolkit;
|
||||
import de.jangassen.MenuToolkit;
|
||||
import javafx.animation.*;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
@@ -50,12 +50,14 @@ import javafx.geometry.Side;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Menu;
|
||||
import javafx.scene.control.MenuItem;
|
||||
import javafx.scene.input.*;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.stage.*;
|
||||
import javafx.stage.Window;
|
||||
import javafx.util.Duration;
|
||||
import org.controlsfx.control.Notifications;
|
||||
import org.controlsfx.control.StatusBar;
|
||||
@@ -70,6 +72,7 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.text.ParseException;
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static com.sparrowwallet.sparrow.AppServices.*;
|
||||
@@ -87,6 +90,9 @@ public class AppController implements Initializable {
|
||||
public static final String TRYING_ANOTHER_SERVER_MESSAGE = "trying another server...";
|
||||
public static final String JPACKAGE_APP_PATH = "jpackage.app-path";
|
||||
|
||||
@FXML
|
||||
private VBox rootBox;
|
||||
|
||||
@FXML
|
||||
private MenuItem saveTransaction;
|
||||
|
||||
@@ -135,6 +141,13 @@ public class AppController implements Initializable {
|
||||
@FXML
|
||||
private ToggleGroup unitFormat;
|
||||
|
||||
@FXML
|
||||
private CheckMenuItem chunkAddresses;
|
||||
|
||||
@FXML
|
||||
private CheckMenuItem hideEmptyUsedAddresses;
|
||||
private static final BooleanProperty hideEmptyUsedAddressesProperty = new SimpleBooleanProperty();
|
||||
|
||||
@FXML
|
||||
private ToggleGroup theme;
|
||||
|
||||
@@ -143,8 +156,7 @@ public class AppController implements Initializable {
|
||||
private static final BooleanProperty openWalletsInNewWindowsProperty = new SimpleBooleanProperty();
|
||||
|
||||
@FXML
|
||||
private CheckMenuItem hideEmptyUsedAddresses;
|
||||
private static final BooleanProperty hideEmptyUsedAddressesProperty = new SimpleBooleanProperty();
|
||||
private CheckMenuItem hideAmounts;
|
||||
|
||||
@FXML
|
||||
private CheckMenuItem useHdCameraResolution;
|
||||
@@ -367,6 +379,13 @@ public class AppController implements Initializable {
|
||||
Optional<Toggle> selectedFormatToggle = unitFormat.getToggles().stream().filter(toggle -> selectedFormat.equals(toggle.getUserData())).findFirst();
|
||||
selectedFormatToggle.ifPresent(toggle -> unitFormat.selectToggle(toggle));
|
||||
|
||||
chunkAddresses.setSelected(Config.get().isChunkAddresses());
|
||||
if(Config.get().isChunkAddresses()) {
|
||||
rootBox.getStyleClass().add("chunk-addresses");
|
||||
}
|
||||
hideEmptyUsedAddressesProperty.set(Config.get().isHideEmptyUsedAddresses());
|
||||
hideEmptyUsedAddresses.selectedProperty().bindBidirectional(hideEmptyUsedAddressesProperty);
|
||||
|
||||
Theme configTheme = Config.get().getTheme();
|
||||
if(configTheme == null) {
|
||||
configTheme = Theme.LIGHT;
|
||||
@@ -379,8 +398,7 @@ public class AppController implements Initializable {
|
||||
|
||||
openWalletsInNewWindowsProperty.set(Config.get().isOpenWalletsInNewWindows());
|
||||
openWalletsInNewWindows.selectedProperty().bindBidirectional(openWalletsInNewWindowsProperty);
|
||||
hideEmptyUsedAddressesProperty.set(Config.get().isHideEmptyUsedAddresses());
|
||||
hideEmptyUsedAddresses.selectedProperty().bindBidirectional(hideEmptyUsedAddressesProperty);
|
||||
hideAmounts.setSelected(Config.get().isHideAmounts());
|
||||
useHdCameraResolutionProperty.set(Config.get().getWebcamResolution() == null || Config.get().getWebcamResolution().isWidescreenAspect());
|
||||
useHdCameraResolution.selectedProperty().bindBidirectional(useHdCameraResolutionProperty);
|
||||
mirrorCameraImageProperty.set(Config.get().isMirrorCapture());
|
||||
@@ -463,7 +481,7 @@ public class AppController implements Initializable {
|
||||
settings, new SeparatorMenuItem(),
|
||||
tk.createHideMenuItem(SparrowWallet.APP_NAME), tk.createHideOthersMenuItem(), tk.createUnhideAllMenuItem(), new SeparatorMenuItem(),
|
||||
tk.createQuitMenuItem(SparrowWallet.APP_NAME));
|
||||
Platform.runLater(() -> tk.setApplicationMenu(defaultApplicationMenu));
|
||||
tk.setApplicationMenu(defaultApplicationMenu);
|
||||
|
||||
fileMenu.getItems().removeIf(item -> item.getStyleClass().contains("osxHide"));
|
||||
toolsMenu.getItems().removeIf(item -> item.getStyleClass().contains("osxHide"));
|
||||
@@ -576,7 +594,7 @@ public class AppController implements Initializable {
|
||||
sudo install -m 644 /opt/sparrowwallet/lib/runtime/conf/udev/*.rules /etc/udev/rules.d
|
||||
sudo udevadm control --reload
|
||||
sudo udevadm trigger
|
||||
sudo groupadd -f plugdev
|
||||
sudo groupadd -f -r plugdev
|
||||
sudo usermod -aG plugdev `whoami`
|
||||
""";
|
||||
String home = System.getProperty(JPACKAGE_APP_PATH);
|
||||
@@ -764,7 +782,8 @@ public class AppController implements Initializable {
|
||||
byte[] txBytes = transaction.bitcoinSerialize();
|
||||
UR ur = UR.fromBytes(txBytes);
|
||||
BBQR bbqr = new BBQR(BBQRType.TXN, txBytes);
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, false, false);
|
||||
String raw = Utils.bytesToHex(txBytes);
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(ur, bbqr, raw, false, false, QREncoding.UR);
|
||||
qrDisplayDialog.initOwner(rootStack.getScene().getWindow());
|
||||
qrDisplayDialog.showAndWait();
|
||||
} catch(Exception e) {
|
||||
@@ -866,7 +885,7 @@ public class AppController implements Initializable {
|
||||
byte[] psbtBytes = transactionTabData.getPsbt().getForExport().serialize();
|
||||
CryptoPSBT cryptoPSBT = new CryptoPSBT(psbtBytes);
|
||||
BBQR bbqr = new BBQR(BBQRType.PSBT, psbtBytes);
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR(), bbqr, false, true, false);
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR(), bbqr, false, true, QREncoding.UR);
|
||||
qrDisplayDialog.initOwner(rootStack.getScene().getWindow());
|
||||
qrDisplayDialog.show();
|
||||
}
|
||||
@@ -938,12 +957,28 @@ public class AppController implements Initializable {
|
||||
EventManager.get().post(new OpenWalletsNewWindowsStatusEvent(item.isSelected()));
|
||||
}
|
||||
|
||||
public void chunkAddresses(ActionEvent event) {
|
||||
CheckMenuItem item = (CheckMenuItem)event.getSource();
|
||||
Config.get().setChunkAddresses(item.isSelected());
|
||||
if(item.isSelected() && !rootBox.getStyleClass().contains("chunk-addresses")) {
|
||||
rootBox.getStyleClass().add("chunk-addresses");
|
||||
} else {
|
||||
rootBox.getStyleClass().remove("chunk-addresses");
|
||||
}
|
||||
}
|
||||
|
||||
public void hideEmptyUsedAddresses(ActionEvent event) {
|
||||
CheckMenuItem item = (CheckMenuItem)event.getSource();
|
||||
Config.get().setHideEmptyUsedAddresses(item.isSelected());
|
||||
EventManager.get().post(new HideEmptyUsedAddressesStatusEvent(item.isSelected()));
|
||||
}
|
||||
|
||||
public void hideAmounts(ActionEvent event) {
|
||||
CheckMenuItem item = (CheckMenuItem)event.getSource();
|
||||
Config.get().setHideAmounts(item.isSelected());
|
||||
EventManager.get().post(new HideAmountsStatusEvent(item.isSelected()));
|
||||
}
|
||||
|
||||
public void useHdCameraResolution(ActionEvent event) {
|
||||
CheckMenuItem item = (CheckMenuItem)event.getSource();
|
||||
if(Config.get().getWebcamResolution().isStandardAspect() && item.isSelected()) {
|
||||
@@ -1105,12 +1140,37 @@ public class AppController implements Initializable {
|
||||
AppServices.moveToActiveWindowScreen(window, 800, 450);
|
||||
List<File> files = fileChooser.showOpenMultipleDialog(window);
|
||||
if(files != null) {
|
||||
configureWalletsDir(files);
|
||||
for(File file : files) {
|
||||
openWalletFile(file, forceSameWindow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void configureWalletsDir(List<File> files) {
|
||||
List<File> parentDirs = files.stream().map(File::getParentFile).distinct().collect(Collectors.toList());
|
||||
if(parentDirs.size() == 1 && !Boolean.FALSE.equals(Config.get().getSuggestChangeWalletsDir())) {
|
||||
File selectedDir = parentDirs.getFirst();
|
||||
boolean sameDir;
|
||||
try {
|
||||
sameDir = Files.isSameFile(selectedDir.toPath(), Storage.getWalletsDir().toPath());
|
||||
} catch(IOException e) {
|
||||
sameDir = selectedDir.toPath().normalize().equals(Storage.getWalletsDir().toPath().normalize());
|
||||
}
|
||||
if(!sameDir) {
|
||||
ConfirmationAlert alert = new ConfirmationAlert("Change wallets directory?",
|
||||
"Do you want to configure Sparrow to use " + selectedDir + " as the default wallets directory?", ButtonType.NO, ButtonType.YES);
|
||||
Optional<ButtonType> optType = alert.showAndWait();
|
||||
if(optType.isPresent() && optType.get() == ButtonType.YES) {
|
||||
Config.get().setWalletsDir(selectedDir);
|
||||
Config.get().setSuggestChangeWalletsDir(null);
|
||||
} else if(alert.isDontAskAgain()) {
|
||||
Config.get().setSuggestChangeWalletsDir(Boolean.FALSE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void openWalletFile(File file, boolean forceSameWindow) {
|
||||
try {
|
||||
Storage storage = new Storage(file);
|
||||
@@ -1203,22 +1263,24 @@ public class AppController implements Initializable {
|
||||
List<WalletForm> selectedWalletForms = getSelectedWalletForms();
|
||||
WalletImportDialog dlg = new WalletImportDialog(selectedWalletForms);
|
||||
dlg.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<Wallet> optionalWallet = dlg.showAndWait();
|
||||
if(optionalWallet.isPresent()) {
|
||||
Wallet wallet = optionalWallet.get();
|
||||
Optional<List<Wallet>> optionalWallets = dlg.showAndWait();
|
||||
if(optionalWallets.isPresent()) {
|
||||
List<Wallet> wallets = optionalWallets.get();
|
||||
|
||||
List<WalletTabData> walletTabData = getOpenWalletTabData();
|
||||
List<ExtendedKey> xpubs = wallet.getKeystores().stream().map(Keystore::getExtendedPublicKey).collect(Collectors.toList());
|
||||
Optional<WalletForm> optNewWalletForm = walletTabData.stream()
|
||||
.map(WalletTabData::getWalletForm)
|
||||
.filter(wf -> wf.getSettingsWalletForm() != null && wf.getSettingsWalletForm().getWallet().getPolicyType() == PolicyType.MULTI &&
|
||||
wf.getSettingsWalletForm().getWallet().getScriptType() == wallet.getScriptType() && !wf.getSettingsWalletForm().getWallet().isValid() &&
|
||||
wf.getSettingsWalletForm().getWallet().getKeystores().stream().map(Keystore::getExtendedPublicKey).anyMatch(xpubs::contains)).findFirst();
|
||||
if(optNewWalletForm.isPresent()) {
|
||||
EventManager.get().post(new ExistingWalletImportedEvent(optNewWalletForm.get().getWalletId(), wallet));
|
||||
selectTab(optNewWalletForm.get().getWallet());
|
||||
} else if(selectedWalletForms.isEmpty() || wallet != selectedWalletForms.get(0).getWallet()) {
|
||||
addImportedWallet(wallet);
|
||||
for(Wallet wallet : wallets) {
|
||||
List<WalletTabData> walletTabData = getOpenWalletTabData();
|
||||
List<ExtendedKey> xpubs = wallet.getKeystores().stream().map(Keystore::getExtendedPublicKey).collect(Collectors.toList());
|
||||
Optional<WalletForm> optNewWalletForm = walletTabData.stream()
|
||||
.map(WalletTabData::getWalletForm)
|
||||
.filter(wf -> wf.getSettingsWalletForm() != null && wf.getSettingsWalletForm().getWallet().getPolicyType() == PolicyType.MULTI &&
|
||||
wf.getSettingsWalletForm().getWallet().getScriptType() == wallet.getScriptType() && !wf.getSettingsWalletForm().getWallet().isValid() &&
|
||||
wf.getSettingsWalletForm().getWallet().getKeystores().stream().map(Keystore::getExtendedPublicKey).anyMatch(xpubs::contains)).findFirst();
|
||||
if(optNewWalletForm.isPresent()) {
|
||||
EventManager.get().post(new ExistingWalletImportedEvent(optNewWalletForm.get().getWalletId(), wallet));
|
||||
selectTab(optNewWalletForm.get().getWallet());
|
||||
} else if(selectedWalletForms.isEmpty() || wallet != selectedWalletForms.get(0).getWallet()) {
|
||||
addImportedWallet(wallet);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1898,11 +1960,6 @@ public class AppController implements Initializable {
|
||||
}
|
||||
|
||||
private void addTransactionTab(String name, File file, PSBT psbt) {
|
||||
//Convert to PSBTv0 first
|
||||
if(psbt.getVersion() != null && psbt.getVersion() >= 2) {
|
||||
psbt.convertVersion(0);
|
||||
}
|
||||
|
||||
//Add any missing previous outputs if available in open wallets
|
||||
for(PSBTInput psbtInput : psbt.getPsbtInputs()) {
|
||||
if(psbtInput.getUtxo() == null) {
|
||||
@@ -1978,39 +2035,13 @@ public class AppController implements Initializable {
|
||||
private void addTransactionTab(String name, File file, Transaction transaction, PSBT psbt, BlockTransaction blockTransaction, TransactionView initialView, Integer initialIndex) {
|
||||
for(Tab tab : tabs.getTabs()) {
|
||||
TabData tabData = (TabData)tab.getUserData();
|
||||
if(tabData instanceof TransactionTabData) {
|
||||
TransactionTabData transactionTabData = (TransactionTabData)tabData;
|
||||
if(tabData instanceof TransactionTabData transactionTabData) {
|
||||
if(isExistingTransaction(transactionTabData, transaction, psbt, getTabName(tab))) {
|
||||
handleTransactionMerge(transactionTabData, psbt, name, tab);
|
||||
return;
|
||||
}
|
||||
|
||||
//If an exact match bytewise of an existing tab, return that tab
|
||||
if(Arrays.equals(transactionTabData.getTransaction().bitcoinSerialize(), transaction.bitcoinSerialize())) {
|
||||
if(transactionTabData.getPsbt() != null && psbt != null && !transactionTabData.getPsbt().isFinalized()) {
|
||||
if(!psbt.isFinalized()) {
|
||||
//As per BIP174, combine PSBTs with matching transactions so long as they are not yet finalized
|
||||
transactionTabData.getPsbt().combine(psbt);
|
||||
if(name != null && !name.isEmpty()) {
|
||||
((Label)tab.getGraphic()).setText(name);
|
||||
}
|
||||
|
||||
EventManager.get().post(new PSBTCombinedEvent(transactionTabData.getPsbt()));
|
||||
} else {
|
||||
//If the new PSBT is finalized, copy the finalized fields to the existing unfinalized PSBT
|
||||
for(int i = 0; i < transactionTabData.getPsbt().getPsbtInputs().size(); i++) {
|
||||
PSBTInput existingInput = transactionTabData.getPsbt().getPsbtInputs().get(i);
|
||||
PSBTInput finalizedInput = psbt.getPsbtInputs().get(i);
|
||||
existingInput.setFinalScriptSig(finalizedInput.getFinalScriptSig());
|
||||
existingInput.setFinalScriptWitness(finalizedInput.getFinalScriptWitness());
|
||||
existingInput.clearNonFinalFields();
|
||||
}
|
||||
|
||||
if(name != null && !name.isEmpty()) {
|
||||
((Label)tab.getGraphic()).setText(name);
|
||||
}
|
||||
|
||||
EventManager.get().post(new PSBTFinalizedEvent(transactionTabData.getPsbt()));
|
||||
}
|
||||
}
|
||||
|
||||
tabs.getSelectionModel().select(tab);
|
||||
if(transactionTabData.getPsbt() != null && transactionTabData.getPsbt().possibleUnverifiableSilentPaymentsTransaction(transaction) && !openUnverifiableTransaction(getTabName(tab))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -2077,6 +2108,69 @@ public class AppController implements Initializable {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isExistingTransaction(TransactionTabData transactionTabData, Transaction transaction, PSBT psbt, String tabName) {
|
||||
PSBT currentPsbt = transactionTabData.getPsbt();
|
||||
Transaction currentTransaction = transactionTabData.getTransaction();
|
||||
|
||||
if(currentPsbt != null && psbt != null && currentPsbt.matches(psbt)) {
|
||||
return true;
|
||||
} else if(currentTransaction.getTxId().equals(transaction.getTxId())) {
|
||||
if(currentTransaction.getWTxId().equals(transaction.getWTxId())) {
|
||||
return true;
|
||||
} else if(currentPsbt == null) {
|
||||
AppServices.showWarningDialog("Suspicious Transaction",
|
||||
"This transaction has the same txid as the transaction in tab " + tabName + ", but contains different witnesses. It will be opened in a separate tab.");
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void handleTransactionMerge(TransactionTabData transactionTabData, PSBT psbt, String name, Tab tab) {
|
||||
PSBT currentPsbt = transactionTabData.getPsbt();
|
||||
|
||||
if(currentPsbt != null && psbt != null && !currentPsbt.isFinalized()) {
|
||||
if(!psbt.isFinalized()) {
|
||||
//As per BIP174, combine PSBTs with matching transactions so long as they are not yet finalized
|
||||
try {
|
||||
currentPsbt.verifyCombinedSignatures(psbt);
|
||||
currentPsbt.combine(psbt);
|
||||
setTabName(tab, name);
|
||||
EventManager.get().post(new PSBTCombinedEvent(currentPsbt));
|
||||
} catch(PSBTSignatureException e) {
|
||||
AppServices.showErrorDialog("Invalid PSBT", e.getMessage());
|
||||
}
|
||||
} else {
|
||||
//If the new PSBT is finalized, copy the finalized fields to the existing unfinalized PSBT
|
||||
currentPsbt.copyFinalizedFields(psbt);
|
||||
setTabName(tab, name);
|
||||
EventManager.get().post(new PSBTFinalizedEvent(currentPsbt));
|
||||
}
|
||||
}
|
||||
|
||||
tabs.getSelectionModel().select(tab);
|
||||
}
|
||||
|
||||
private boolean openUnverifiableTransaction(String tabName) {
|
||||
Optional<ButtonType> result = AppServices.showWarningDialog(
|
||||
"Unverifiable Silent Payments Transaction",
|
||||
"This transaction contains an unverifiable silent payments output.\n\n" +
|
||||
"The tab " + tabName + " contains a similar transaction spending to a silent payments address, " +
|
||||
"but this transaction does not contain enough information to determine if the recipient address is correct.\n\n" +
|
||||
"Open the transaction in another tab?", ButtonType.YES, ButtonType.NO);
|
||||
return result.isPresent() && result.get() == ButtonType.YES;
|
||||
}
|
||||
|
||||
private String getTabName(Tab tab) {
|
||||
return ((Label)tab.getGraphic()).getText();
|
||||
}
|
||||
|
||||
private void setTabName(Tab tab, String name){
|
||||
if(name != null && !name.isEmpty()) {
|
||||
((Label)tab.getGraphic()).setText(name);
|
||||
}
|
||||
}
|
||||
|
||||
private ContextMenu getTabContextMenu(Tab tab) {
|
||||
ContextMenu contextMenu = new ContextMenu();
|
||||
|
||||
@@ -2093,23 +2187,33 @@ public class AppController implements Initializable {
|
||||
}
|
||||
|
||||
MenuItem moveRight = new MenuItem("Move Right");
|
||||
moveRight.setAccelerator(new KeyCodeCombination(KeyCode.RIGHT, KeyCombination.CONTROL_DOWN, KeyCombination.SHIFT_DOWN));
|
||||
moveRight.setOnAction(event -> {
|
||||
int index = tabs.getTabs().indexOf(tab);
|
||||
int currentIndex = tabs.getSelectionModel().getSelectedIndex();
|
||||
if(currentIndex + 1 >= tabs.getTabs().size()) {
|
||||
return;
|
||||
}
|
||||
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
|
||||
tabs.getTabs().removeListener(tabsChangeListener);
|
||||
tabs.getTabs().remove(tab);
|
||||
tabs.getTabs().add(index + 1, tab);
|
||||
tabs.getTabs().remove(selectedTab);
|
||||
tabs.getTabs().add(currentIndex + 1, selectedTab);
|
||||
tabs.getTabs().addListener(tabsChangeListener);
|
||||
tabs.getSelectionModel().select(tab);
|
||||
tabs.getSelectionModel().select(selectedTab);
|
||||
EventManager.get().post(new RequestOpenWalletsEvent()); //Rearrange recent files list
|
||||
});
|
||||
MenuItem moveLeft = new MenuItem("Move Left");
|
||||
moveLeft.setAccelerator(new KeyCodeCombination(KeyCode.LEFT, KeyCombination.CONTROL_DOWN, KeyCombination.SHIFT_DOWN));
|
||||
moveLeft.setOnAction(event -> {
|
||||
int index = tabs.getTabs().indexOf(tab);
|
||||
int currentIndex = tabs.getSelectionModel().getSelectedIndex();
|
||||
if(currentIndex == 0) {
|
||||
return;
|
||||
}
|
||||
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
|
||||
tabs.getTabs().removeListener(tabsChangeListener);
|
||||
tabs.getTabs().remove(tab);
|
||||
tabs.getTabs().add(index - 1, tab);
|
||||
tabs.getTabs().remove(selectedTab);
|
||||
tabs.getTabs().add(currentIndex - 1, selectedTab);
|
||||
tabs.getTabs().addListener(tabsChangeListener);
|
||||
tabs.getSelectionModel().select(tab);
|
||||
tabs.getSelectionModel().select(selectedTab);
|
||||
EventManager.get().post(new RequestOpenWalletsEvent()); //Rearrange recent files list
|
||||
});
|
||||
contextMenu.getItems().addAll(moveRight, moveLeft);
|
||||
@@ -2542,7 +2646,7 @@ public class AppController implements Initializable {
|
||||
if(event instanceof TransactionTabSelectedEvent) {
|
||||
TransactionTabSelectedEvent txTabEvent = (TransactionTabSelectedEvent)event;
|
||||
TransactionTabData transactionTabData = txTabEvent.getTransactionTabData();
|
||||
if(transactionTabData.getPsbt() == null || transactionTabData.getPsbt().getTransaction() != transactionTabData.getTransaction()) {
|
||||
if(transactionTabData.getPsbt() == null || !transactionTabData.getPsbt().getTransaction().getTxId().equals(transactionTabData.getTransaction().getTxId())) {
|
||||
saveTransaction.setVisible(true);
|
||||
saveTransaction.setDisable(false);
|
||||
} else {
|
||||
@@ -3111,6 +3215,11 @@ public class AppController implements Initializable {
|
||||
hideEmptyUsedAddresses.setSelected(event.isHideEmptyUsedAddresses());
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void hideAmountsStatusChanged(HideAmountsStatusEvent event) {
|
||||
hideAmounts.setSelected(event.isHideAmounts());
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void requestOpenWallets(RequestOpenWalletsEvent event) {
|
||||
EventManager.get().post(new OpenWalletsEvent(tabs.getScene().getWindow(), getOpenWalletTabData()));
|
||||
|
||||
@@ -887,8 +887,13 @@ public class AppServices {
|
||||
Stage stage = (Stage)window;
|
||||
stage.getIcons().add(getWindowIcon());
|
||||
|
||||
if(stage.getScene() != null && Config.get().getTheme() == Theme.DARK) {
|
||||
stage.getScene().getStylesheets().add(AppServices.class.getResource("darktheme.css").toExternalForm());
|
||||
if(stage.getScene() != null) {
|
||||
if(Config.get().getTheme() == Theme.DARK) {
|
||||
stage.getScene().getStylesheets().add(AppServices.class.getResource("darktheme.css").toExternalForm());
|
||||
}
|
||||
if(Config.get().isChunkAddresses()) {
|
||||
stage.getScene().getRoot().getStyleClass().add("chunk-addresses");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptChunk;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
@@ -72,8 +73,7 @@ public abstract class BaseController {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("[");
|
||||
builder.append(keystore.getKeyDerivation().getMasterFingerprint());
|
||||
builder.append("/");
|
||||
builder.append(keystore.getKeyDerivation().getDerivationPath().replaceFirst("^m?/", ""));
|
||||
builder.append(KeyDerivation.writePath(KeyDerivation.parsePath(keystore.getKeyDerivation().getDerivationPath())).substring(1));
|
||||
builder.append("]");
|
||||
builder.append(keystore.getExtendedPublicKey().toString());
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import java.util.*;
|
||||
public class SparrowWallet {
|
||||
public static final String APP_ID = "sparrow";
|
||||
public static final String APP_NAME = "Sparrow";
|
||||
public static final String APP_VERSION = "2.3.0";
|
||||
public static final String APP_VERSION = "2.4.1";
|
||||
public static final String APP_VERSION_SUFFIX = "";
|
||||
public static final String APP_HOME_PROPERTY = "sparrow.home";
|
||||
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
|
||||
|
||||
@@ -22,6 +22,7 @@ public class AddressLabel extends IdLabel {
|
||||
|
||||
public AddressLabel(String text) {
|
||||
super(text);
|
||||
setSkin(new AddressTextFieldSkin(this));
|
||||
addressProperty().addListener((observable, oldValue, newValue) -> {
|
||||
setAddressAsText(newValue);
|
||||
contextMenu.copyHex.setText("Copy " + newValue.getOutputScriptDataType());
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ContentDisplay;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.skin.LabelSkin;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextFlow;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class AddressLabelSkin extends LabelSkin {
|
||||
public static final int CHUNK_SIZE = 4;
|
||||
public static final Pattern CHUNK_PATTERN = Pattern.compile("(?<=\\G.{" + CHUNK_SIZE + "})");
|
||||
|
||||
private final TextFlow displayFlow;
|
||||
private final ChangeListener<String> textListener;
|
||||
private final ChangeListener<Font> fontListener;
|
||||
|
||||
public AddressLabelSkin(Label control) {
|
||||
super(control);
|
||||
|
||||
displayFlow = new TextFlow();
|
||||
displayFlow.setManaged(false);
|
||||
displayFlow.setMouseTransparent(true);
|
||||
|
||||
getChildren().addFirst(displayFlow);
|
||||
|
||||
textListener = (_, _, newText) -> updateDisplay(newText);
|
||||
fontListener = (_, _, _) -> updateDisplay(control.getText());
|
||||
control.textProperty().addListener(textListener);
|
||||
control.fontProperty().addListener(fontListener);
|
||||
updateDisplay(control.getText());
|
||||
|
||||
control.setStyle("-fx-text-fill: transparent;");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
getSkinnable().textProperty().removeListener(textListener);
|
||||
getSkinnable().fontProperty().removeListener(fontListener);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
private void updateDisplay(String text) {
|
||||
displayFlow.getChildren().clear();
|
||||
if(text == null || text.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<AddressSpan> addresses = findAddresses(text);
|
||||
|
||||
int pos = 0;
|
||||
for(AddressSpan span : addresses) {
|
||||
if(span.start > pos) {
|
||||
Text normalText = createText(text.substring(pos, span.start), false);
|
||||
displayFlow.getChildren().add(normalText);
|
||||
}
|
||||
|
||||
addChunkedAddress(text.substring(span.start, span.end));
|
||||
pos = span.end;
|
||||
}
|
||||
|
||||
if(pos < text.length()) {
|
||||
Text normalText = createText(text.substring(pos), false);
|
||||
displayFlow.getChildren().add(normalText);
|
||||
}
|
||||
}
|
||||
|
||||
private void addChunkedAddress(String address) {
|
||||
String[] chunks = CHUNK_PATTERN.split(address);
|
||||
for(int i = 0; i < chunks.length; i++) {
|
||||
Text chunk = createText(chunks[i], i % 2 != 0);
|
||||
displayFlow.getChildren().add(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
private Text createText(String content, boolean alternate) {
|
||||
Text text = new Text(content);
|
||||
text.setFont(getSkinnable().getFont());
|
||||
text.getStyleClass().add("address-chunk");
|
||||
if(alternate) {
|
||||
text.getStyleClass().add("alternate");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private List<AddressSpan> findAddresses(String text) {
|
||||
List<AddressSpan> spans = new ArrayList<>();
|
||||
|
||||
Pattern wordBoundary = Pattern.compile("\\S+");
|
||||
Matcher matcher = wordBoundary.matcher(text);
|
||||
|
||||
while(matcher.find()) {
|
||||
String candidate = matcher.group();
|
||||
if(isValidAddress(candidate)) {
|
||||
spans.add(new AddressSpan(matcher.start(), matcher.end()));
|
||||
}
|
||||
}
|
||||
|
||||
return spans;
|
||||
}
|
||||
|
||||
private boolean isValidAddress(String candidate) {
|
||||
Network network = Network.get();
|
||||
return network.hasP2PKHAddressPrefix(candidate) || network.hasP2SHAddressPrefix(candidate) ||
|
||||
candidate.startsWith(network.getBech32AddressHRP()) || candidate.startsWith(network.getSilentPaymentsAddressHrp());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateChildren() {
|
||||
super.updateChildren();
|
||||
if(displayFlow != null && !getChildren().contains(displayFlow)) {
|
||||
getChildren().addFirst(displayFlow);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void layoutChildren(double x, double y, double w, double h) {
|
||||
super.layoutChildren(x, y, w, h);
|
||||
|
||||
// Position TextFlow to align with the label's text area
|
||||
Label label = getSkinnable();
|
||||
Insets padding = label.getPadding();
|
||||
|
||||
Node graphic = label.getGraphic();
|
||||
double graphicOffset = 0;
|
||||
if(graphic != null && label.getContentDisplay() == ContentDisplay.LEFT) {
|
||||
graphicOffset = graphic.getLayoutBounds().getWidth() + label.getGraphicTextGap();
|
||||
}
|
||||
|
||||
displayFlow.resizeRelocate(
|
||||
x + padding.getLeft() + graphicOffset,
|
||||
y + padding.getTop(),
|
||||
w - padding.getLeft() - padding.getRight() - graphicOffset,
|
||||
h - padding.getTop() - padding.getBottom()
|
||||
);
|
||||
}
|
||||
|
||||
private record AddressSpan(int start, int end) {}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.drongo.protocol.Base58;
|
||||
import com.sparrowwallet.drongo.protocol.Bech32;
|
||||
import impl.org.controlsfx.skin.CustomTextFieldSkin;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.shape.Path;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextFlow;
|
||||
import org.controlsfx.control.textfield.CustomTextField;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class AddressTextFieldSkin extends CustomTextFieldSkin {
|
||||
private static final boolean[] BASE58_OK = buildOkTable(new String(Base58.ALPHABET));
|
||||
private static final boolean[] BECH32_DATA_OK = buildOkTable(Bech32.CHARSET);
|
||||
|
||||
private final TextFlow displayFlow;
|
||||
private final Rectangle clip;
|
||||
private final ChangeListener<String> textListener;
|
||||
private final ChangeListener<Font> fontListener;
|
||||
|
||||
public AddressTextFieldSkin(TextField control) {
|
||||
super(control);
|
||||
|
||||
displayFlow = new TextFlow();
|
||||
displayFlow.setManaged(false);
|
||||
displayFlow.setMouseTransparent(true);
|
||||
|
||||
clip = new Rectangle();
|
||||
displayFlow.setClip(clip);
|
||||
|
||||
getChildren().addFirst(displayFlow);
|
||||
|
||||
textListener = (_, _, newText) -> updateDisplay(newText);
|
||||
fontListener = (_, _, _) -> updateDisplay(control.getText());
|
||||
control.textProperty().addListener(textListener);
|
||||
control.fontProperty().addListener(fontListener);
|
||||
updateDisplay(control.getText());
|
||||
|
||||
control.setStyle("-fx-text-fill: transparent;");
|
||||
|
||||
// Unbind caret color since it's normally bound to textFill
|
||||
unbindCaretColor(getChildren());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
getSkinnable().textProperty().removeListener(textListener);
|
||||
getSkinnable().fontProperty().removeListener(fontListener);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
private void unbindCaretColor(javafx.collections.ObservableList<Node> children) {
|
||||
for(Node node : children) {
|
||||
if(node instanceof Path path && path.getStroke() != null) {
|
||||
path.fillProperty().unbind();
|
||||
path.strokeProperty().unbind();
|
||||
path.getStyleClass().add("address-field-caret");
|
||||
} else if(node instanceof javafx.scene.Parent parent) {
|
||||
unbindCaretColor(parent.getChildrenUnmodifiable());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectProperty<Node> leftProperty() {
|
||||
if(getSkinnable() instanceof CustomTextField customTextField) {
|
||||
return customTextField.leftProperty();
|
||||
}
|
||||
|
||||
return new SimpleObjectProperty<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectProperty<Node> rightProperty() {
|
||||
if(getSkinnable() instanceof CustomTextField customTextField) {
|
||||
return customTextField.rightProperty();
|
||||
}
|
||||
|
||||
return new SimpleObjectProperty<>();
|
||||
}
|
||||
|
||||
private void updateDisplay(String text) {
|
||||
displayFlow.getChildren().clear();
|
||||
if(text == null || text.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<AddressSpan> addresses = findAddresses(text);
|
||||
|
||||
int pos = 0;
|
||||
for(AddressSpan span : addresses) {
|
||||
if(span.start > pos) {
|
||||
Text normalText = createText(text.substring(pos, span.start), false);
|
||||
displayFlow.getChildren().add(normalText);
|
||||
}
|
||||
|
||||
addChunkedAddress(text.substring(span.start, span.end));
|
||||
pos = span.end;
|
||||
}
|
||||
|
||||
if(pos < text.length()) {
|
||||
Text normalText = createText(text.substring(pos), false);
|
||||
displayFlow.getChildren().add(normalText);
|
||||
}
|
||||
}
|
||||
|
||||
private void addChunkedAddress(String address) {
|
||||
String[] chunks = AddressLabelSkin.CHUNK_PATTERN.split(address);
|
||||
for(int i = 0; i < chunks.length; i++) {
|
||||
Text chunk = createText(chunks[i], i % 2 != 0);
|
||||
displayFlow.getChildren().add(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
private Text createText(String content, boolean alternate) {
|
||||
Text text = new Text(content);
|
||||
text.setFont(getSkinnable().getFont());
|
||||
text.getStyleClass().add("address-chunk");
|
||||
if(alternate) {
|
||||
text.getStyleClass().add("alternate");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private List<AddressSpan> findAddresses(String text) {
|
||||
List<AddressSpan> spans = new ArrayList<>();
|
||||
|
||||
Pattern wordBoundary = Pattern.compile("\\S+");
|
||||
Matcher matcher = wordBoundary.matcher(text);
|
||||
|
||||
while(matcher.find()) {
|
||||
String candidate = matcher.group();
|
||||
if(isValidAddress(candidate)) {
|
||||
spans.add(new AddressSpan(matcher.start(), matcher.end()));
|
||||
}
|
||||
}
|
||||
|
||||
return spans;
|
||||
}
|
||||
|
||||
private boolean isValidAddress(String candidate) {
|
||||
if(candidate == null || candidate.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Network network = Network.get();
|
||||
|
||||
// Base58 (legacy) partial: must start with a legacy prefix and contain only base58 chars.
|
||||
if(network.hasP2PKHAddressPrefix(candidate) || network.hasP2SHAddressPrefix(candidate)) {
|
||||
return containsOnlyAscii(candidate, BASE58_OK);
|
||||
}
|
||||
|
||||
String lower = candidate.toLowerCase(Locale.ROOT);
|
||||
|
||||
// Bech32 (segwit v0/v1) partial: starts with HRP, then optional '1', then bech32 data charset.
|
||||
if(lower.startsWith(network.getBech32AddressHRP())) {
|
||||
return isBech32LikePartial(lower);
|
||||
}
|
||||
|
||||
// Silent payments partial (bech32-like): starts with its HRP, then optional '1', then bech32 data charset.
|
||||
if(lower.startsWith(network.getSilentPaymentsAddressHrp())) {
|
||||
return isBech32LikePartial(lower);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isBech32LikePartial(String lower) {
|
||||
int sep = lower.indexOf(Bech32.BECH32_SEPARATOR);
|
||||
|
||||
if(sep < 0) {
|
||||
return containsOnlyHrpChars(lower);
|
||||
}
|
||||
|
||||
String hrp = lower.substring(0, sep);
|
||||
String dataPart = lower.substring(sep + 1);
|
||||
|
||||
if(hrp.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return containsOnlyHrpChars(hrp) && containsOnlyAscii(dataPart, BECH32_DATA_OK);
|
||||
}
|
||||
|
||||
private static boolean containsOnlyHrpChars(String s) {
|
||||
for(int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
boolean ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9');
|
||||
if(!ok) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean[] buildOkTable(String allowed) {
|
||||
boolean[] ok = new boolean[128];
|
||||
for(int i = 0; i < allowed.length(); i++) {
|
||||
char c = allowed.charAt(i);
|
||||
if(c < ok.length) {
|
||||
ok[c] = true;
|
||||
} else {
|
||||
throw new IllegalArgumentException("Non-ASCII allowed char: " + c);
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
private static boolean containsOnlyAscii(String s, boolean[] ok) {
|
||||
for(int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
if(c >= ok.length || !ok[c]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void layoutChildren(double x, double y, double w, double h) {
|
||||
super.layoutChildren(x, y, w, h);
|
||||
|
||||
Insets padding = getSkinnable().getPadding();
|
||||
|
||||
double leftWidth = 0;
|
||||
double rightWidth = 0;
|
||||
if(getSkinnable() instanceof CustomTextField customTextField) {
|
||||
Node left = customTextField.getLeft();
|
||||
Node right = customTextField.getRight();
|
||||
if(left != null) {
|
||||
leftWidth = left.getLayoutBounds().getWidth();
|
||||
if(left instanceof Region leftRegion) {
|
||||
leftWidth += leftRegion.getPadding().getLeft() + leftRegion.getPadding().getRight() + 1;
|
||||
}
|
||||
}
|
||||
if(right != null) {
|
||||
rightWidth = right.getLayoutBounds().getWidth();
|
||||
if(right instanceof Region rightRegion) {
|
||||
rightWidth += rightRegion.getPadding().getLeft() + rightRegion.getPadding().getRight();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
double availableWidth = w - padding.getLeft() - padding.getRight() - leftWidth - rightWidth;
|
||||
clip.setWidth(availableWidth);
|
||||
clip.setHeight(h);
|
||||
|
||||
double topOffset = getSkinnable().getBaselineOffset() - displayFlow.getBaselineOffset();
|
||||
|
||||
displayFlow.resizeRelocate(
|
||||
padding.getLeft() + leftWidth,
|
||||
topOffset,
|
||||
displayFlow.prefWidth(-1),
|
||||
h - padding.getTop() - padding.getBottom()
|
||||
);
|
||||
}
|
||||
|
||||
private record AddressSpan(int start, int end) {}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.address.InvalidAddressException;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextFlow;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class AddressTooltipSkin implements Skin<Tooltip> {
|
||||
private final Tooltip tooltip;
|
||||
private final TextFlow textFlow;
|
||||
private final ChangeListener<String> textListener;
|
||||
|
||||
public AddressTooltipSkin(Tooltip tooltip) {
|
||||
this.tooltip = tooltip;
|
||||
|
||||
textFlow = new TextFlow();
|
||||
textFlow.getStyleClass().addAll(tooltip.getStyleClass());
|
||||
|
||||
textListener = (_, _, newText) -> updateDisplay(newText);
|
||||
tooltip.textProperty().addListener(textListener);
|
||||
updateDisplay(tooltip.getText());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Tooltip getSkinnable() {
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Node getNode() {
|
||||
return textFlow;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
tooltip.textProperty().removeListener(textListener);
|
||||
}
|
||||
|
||||
private void updateDisplay(String text) {
|
||||
textFlow.getChildren().clear();
|
||||
if(text == null || text.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<AddressSpan> addresses = findAddresses(text);
|
||||
|
||||
int pos = 0;
|
||||
for(AddressSpan span : addresses) {
|
||||
if(span.start > pos) {
|
||||
textFlow.getChildren().add(createText(text.substring(pos, span.start), false));
|
||||
}
|
||||
addChunkedAddress(text.substring(span.start, span.end));
|
||||
pos = span.end;
|
||||
}
|
||||
|
||||
if(pos < text.length()) {
|
||||
textFlow.getChildren().add(createText(text.substring(pos), false));
|
||||
}
|
||||
}
|
||||
|
||||
private void addChunkedAddress(String address) {
|
||||
String[] chunks = AddressLabelSkin.CHUNK_PATTERN.split(address);
|
||||
for(int i = 0; i < chunks.length; i++) {
|
||||
textFlow.getChildren().add(createText(chunks[i], i % 2 != 0));
|
||||
}
|
||||
}
|
||||
|
||||
private Text createText(String content, boolean alternate) {
|
||||
Text text = new Text(content);
|
||||
text.getStyleClass().add("address-chunk");
|
||||
if(alternate) {
|
||||
text.getStyleClass().add("alternate");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private List<AddressSpan> findAddresses(String text) {
|
||||
List<AddressSpan> spans = new ArrayList<>();
|
||||
|
||||
Pattern wordBoundary = Pattern.compile("\\S+");
|
||||
Matcher matcher = wordBoundary.matcher(text);
|
||||
|
||||
while(matcher.find()) {
|
||||
String candidate = matcher.group();
|
||||
if(isValidAddress(candidate)) {
|
||||
spans.add(new AddressSpan(matcher.start(), matcher.end()));
|
||||
}
|
||||
}
|
||||
|
||||
return spans;
|
||||
}
|
||||
|
||||
private boolean isValidAddress(String candidate) {
|
||||
try {
|
||||
Address.fromString(candidate);
|
||||
return true;
|
||||
} catch(InvalidAddressException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private record AddressSpan(int start, int end) {}
|
||||
}
|
||||
@@ -30,7 +30,11 @@ public class AddressTreeTable extends CoinTreeTable {
|
||||
addressCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Entry> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue());
|
||||
});
|
||||
addressCol.setCellFactory(p -> new EntryCell());
|
||||
addressCol.setCellFactory(p -> {
|
||||
EntryCell entryCell = new EntryCell();
|
||||
entryCell.setSkin(new AddressTreeTableCellSkin<>(entryCell));
|
||||
return entryCell;
|
||||
});
|
||||
addressCol.setSortable(false);
|
||||
getColumns().add(addressCol);
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.control.TreeTableCell;
|
||||
import javafx.scene.control.skin.TreeTableCellSkin;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextFlow;
|
||||
|
||||
public class AddressTreeTableCellSkin<S, T> extends TreeTableCellSkin<S, T> {
|
||||
private final TextFlow displayFlow;
|
||||
private final ChangeListener<String> textListener;
|
||||
private final Text ellipsisText;
|
||||
private String currentDisplayedText;
|
||||
|
||||
public AddressTreeTableCellSkin(TreeTableCell<S, T> cell) {
|
||||
super(cell);
|
||||
|
||||
displayFlow = new TextFlow();
|
||||
displayFlow.setManaged(false);
|
||||
displayFlow.setMouseTransparent(true);
|
||||
displayFlow.setMinWidth(Region.USE_PREF_SIZE);
|
||||
getChildren().add(displayFlow);
|
||||
|
||||
ellipsisText = new Text("...");
|
||||
ellipsisText.fontProperty().bind(cell.fontProperty());
|
||||
ellipsisText.getStyleClass().add("address-chunk");
|
||||
|
||||
textListener = (_, _, newText) -> updateDisplay(newText);
|
||||
cell.textProperty().addListener(textListener);
|
||||
updateDisplay(cell.getText());
|
||||
|
||||
cell.setStyle("-fx-text-fill: transparent;");
|
||||
}
|
||||
|
||||
private void updateDisplay(String text) {
|
||||
currentDisplayedText = text;
|
||||
buildDisplay(text, false);
|
||||
}
|
||||
|
||||
private void buildDisplay(String text, boolean truncated) {
|
||||
displayFlow.getChildren().clear();
|
||||
|
||||
if(text == null || text.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(getSkinnable().getStyleClass().contains("address-cell")) {
|
||||
String[] chunks = AddressLabelSkin.CHUNK_PATTERN.split(text);
|
||||
for(int i = 0; i < chunks.length; i++) {
|
||||
displayFlow.getChildren().add(createText(chunks[i], i % 2 != 0));
|
||||
}
|
||||
} else {
|
||||
Text normalText = createText(text, false);
|
||||
displayFlow.getChildren().add(normalText);
|
||||
}
|
||||
|
||||
if(truncated) {
|
||||
displayFlow.getChildren().add(ellipsisText);
|
||||
}
|
||||
}
|
||||
|
||||
private Text createText(String content, boolean alternate) {
|
||||
Text text = new Text(content);
|
||||
text.fontProperty().bind(getSkinnable().fontProperty());
|
||||
text.getStyleClass().add("address-chunk");
|
||||
if(alternate) {
|
||||
text.getStyleClass().add("alternate");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void layoutChildren(double x, double y, double w, double h) {
|
||||
super.layoutChildren(x, y, w, h);
|
||||
|
||||
TreeTableCell<S, T> cell = getSkinnable();
|
||||
Insets padding = cell.getPadding();
|
||||
|
||||
double leftOffset = 0;
|
||||
double topOffset = y + padding.getTop();
|
||||
|
||||
Text labeledText = (Text)getChildren().stream().filter(n -> n instanceof Text).findFirst().orElse(null);
|
||||
if(labeledText != null) {
|
||||
leftOffset = labeledText.getLayoutX();
|
||||
topOffset = labeledText.getLayoutY() - labeledText.getBaselineOffset();
|
||||
|
||||
String fullText = cell.getText();
|
||||
String displayedText = labeledText.getText();
|
||||
|
||||
if(fullText != null && displayedText != null && !fullText.equals(displayedText)) {
|
||||
String ellipsis = cell.getEllipsisString();
|
||||
if(displayedText.endsWith(ellipsis)) {
|
||||
String truncatedText = displayedText.substring(0, displayedText.length() - ellipsis.length());
|
||||
if(!truncatedText.equals(currentDisplayedText)) {
|
||||
currentDisplayedText = truncatedText;
|
||||
buildDisplay(truncatedText, true);
|
||||
}
|
||||
}
|
||||
} else if(fullText != null && !fullText.equals(currentDisplayedText)) {
|
||||
currentDisplayedText = fullText;
|
||||
buildDisplay(fullText, false);
|
||||
}
|
||||
}
|
||||
|
||||
displayFlow.resizeRelocate(
|
||||
leftOffset,
|
||||
topOffset,
|
||||
w - padding.getLeft() - padding.getRight(),
|
||||
h - padding.getTop() - padding.getBottom()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateChildren() {
|
||||
super.updateChildren();
|
||||
if(displayFlow != null && !getChildren().contains(displayFlow)) {
|
||||
getChildren().add(displayFlow);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
getSkinnable().textProperty().removeListener(textListener);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -128,4 +128,15 @@ public class BalanceChart extends LineChart<Number, Number> {
|
||||
NumberAxis yaxis = (NumberAxis)getYAxis();
|
||||
yaxis.setTickLabelFormatter(new CoinAxisFormatter(yaxis, format, unit));
|
||||
}
|
||||
|
||||
public void refreshAxisLabels() {
|
||||
NumberAxis yaxis = (NumberAxis)getYAxis();
|
||||
// Force the axis to redraw by invalidating the upper and lower bounds
|
||||
yaxis.setAutoRanging(false);
|
||||
double lower = yaxis.getLowerBound();
|
||||
double upper = yaxis.getUpperBound();
|
||||
yaxis.setLowerBound(lower);
|
||||
yaxis.setUpperBound(upper);
|
||||
yaxis.setAutoRanging(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.control;
|
||||
import com.google.common.base.Throwables;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
@@ -47,10 +48,18 @@ public class CardImportPane extends TitledDescriptionPane {
|
||||
protected Button importButton;
|
||||
private final SimpleStringProperty pin = new SimpleStringProperty("");
|
||||
|
||||
public CardImportPane(Wallet wallet, KeystoreCardImport importer, KeyDerivation requiredDerivation) {
|
||||
public CardImportPane(Wallet wallet, KeystoreCardImport importer, KeyDerivation defaultDerivation, KeyDerivation requiredDerivation) {
|
||||
super(importer.getName(), "Place card on reader", importer.getKeystoreImportDescription(getAccount(wallet, requiredDerivation)), importer.getWalletModel());
|
||||
this.importer = importer;
|
||||
this.derivation = requiredDerivation == null ? wallet.getScriptType().getDefaultDerivation() : requiredDerivation.getDerivation();
|
||||
this.derivation = requiredDerivation == null ? getDefaultDerivation(wallet, defaultDerivation) : requiredDerivation.getDerivation();
|
||||
}
|
||||
|
||||
private static List<ChildNumber> getDefaultDerivation(Wallet wallet, KeyDerivation defaultDerivation) {
|
||||
if(defaultDerivation != null && !defaultDerivation.getDerivation().isEmpty()) {
|
||||
return defaultDerivation.getDerivation();
|
||||
}
|
||||
|
||||
return wallet == null || wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
|
||||
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
|
||||
import com.sparrowwallet.sparrow.io.ImportException;
|
||||
import com.sparrowwallet.sparrow.io.KeystoreCodexImport;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ContentDisplay;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.MenuItem;
|
||||
import javafx.scene.control.SplitMenuButton;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.AnchorPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.controlsfx.validation.ValidationResult;
|
||||
import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.Validator;
|
||||
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class CodexKeystoreImportPane extends TitledDescriptionPane {
|
||||
protected final Wallet wallet;
|
||||
private final KeystoreCodexImport importer;
|
||||
private final KeyDerivation defaultDerivation;
|
||||
|
||||
private SplitMenuButton importButton;
|
||||
|
||||
private Button enterCodexButton;
|
||||
private Button calculateButton;
|
||||
|
||||
protected Label validLabel;
|
||||
protected Label invalidLabel;
|
||||
|
||||
protected final SimpleStringProperty secretShareProperty = new SimpleStringProperty("");
|
||||
|
||||
public CodexKeystoreImportPane(Wallet wallet, KeystoreCodexImport importer, KeyDerivation defaultDerivation) {
|
||||
super(importer.getName(), "Enter secret share", importer.getKeystoreImportDescription(), importer.getWalletModel());
|
||||
this.wallet = wallet;
|
||||
this.importer = importer;
|
||||
this.defaultDerivation = defaultDerivation;
|
||||
|
||||
createImportButton();
|
||||
buttonBox.getChildren().add(importButton);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Control createButton() {
|
||||
enterCodexButton = new Button("Enter Secret Share");
|
||||
enterCodexButton.managedProperty().bind(enterCodexButton.visibleProperty());
|
||||
enterCodexButton.setOnAction(event -> {
|
||||
enterCodex();
|
||||
});
|
||||
|
||||
return enterCodexButton;
|
||||
}
|
||||
|
||||
private void enterCodex() {
|
||||
setDescription("Enter secret share");
|
||||
showHideLink.setVisible(false);
|
||||
setContent(getSecretShareEntry());
|
||||
setExpanded(true);
|
||||
}
|
||||
|
||||
private void importKeystore(List<ChildNumber> derivation) {
|
||||
importButton.setDisable(true);
|
||||
try {
|
||||
Keystore keystore = importer.getKeystore(derivation, secretShareProperty.get());
|
||||
EventManager.get().post(new KeystoreImportEvent(keystore));
|
||||
} catch(ImportException e) {
|
||||
String errorMessage = e.getMessage();
|
||||
if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
|
||||
errorMessage = e.getCause().getMessage();
|
||||
}
|
||||
setError("Import Error", errorMessage);
|
||||
importButton.setDisable(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void createImportButton() {
|
||||
importButton = new SplitMenuButton();
|
||||
importButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
importButton.setText("Import Keystore");
|
||||
setDefaultButton(importButton);
|
||||
importButton.setOnAction(event -> {
|
||||
importButton.setDisable(true);
|
||||
importKeystore(getDefaultDerivation());
|
||||
});
|
||||
String[] accounts = new String[]{"Import Default Account #0", "Import Account #1", "Import Account #2", "Import Account #3", "Import Account #4", "Import Account #5", "Import Account #6", "Import Account #7", "Import Account #8", "Import Account #9"};
|
||||
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
|
||||
for(int i = 0; i < scriptAccountsLength; i++) {
|
||||
MenuItem item = new MenuItem(accounts[i]);
|
||||
final List<ChildNumber> derivation = wallet.getScriptType().getDefaultDerivation(i);
|
||||
item.setOnAction(event -> {
|
||||
importButton.setDisable(true);
|
||||
importKeystore(derivation);
|
||||
});
|
||||
importButton.getItems().add(item);
|
||||
}
|
||||
|
||||
importButton.managedProperty().bind(importButton.visibleProperty());
|
||||
importButton.setVisible(false);
|
||||
}
|
||||
|
||||
private List<ChildNumber> getDefaultDerivation() {
|
||||
return defaultDerivation == null || defaultDerivation.getDerivation().isEmpty() ? wallet.getScriptType().getDefaultDerivation() : defaultDerivation.getDerivation();
|
||||
}
|
||||
|
||||
private void onInputChange(boolean empty, boolean validChecksum) {
|
||||
if(!empty) {
|
||||
try {
|
||||
importer.getKeystore(ScriptType.P2WPKH.getDefaultDerivation(), secretShareProperty.get());
|
||||
validChecksum = true;
|
||||
} catch(ImportException e) {
|
||||
invalidLabel.setText("Invalid checksum");
|
||||
invalidLabel.setTooltip(null);
|
||||
}
|
||||
}
|
||||
|
||||
calculateButton.setDisable(!validChecksum);
|
||||
validLabel.setVisible(validChecksum);
|
||||
invalidLabel.setVisible(!validChecksum && !empty);
|
||||
}
|
||||
|
||||
private Node getSecretShareEntry() {
|
||||
VBox vBox = new VBox(20);
|
||||
vBox.setPadding(new Insets(10, 30, 10, 30));
|
||||
|
||||
HBox shareEntry = new HBox(10);
|
||||
shareEntry.setAlignment(Pos.CENTER_LEFT);
|
||||
Label shareLabel = new Label("Secret:");
|
||||
TextField shareField = new TextField();
|
||||
HBox.setHgrow(shareField, Priority.ALWAYS);
|
||||
shareField.setPromptText("ms...");
|
||||
shareField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
secretShareProperty.set(newValue);
|
||||
});
|
||||
shareEntry.getChildren().addAll(shareLabel, shareField);
|
||||
vBox.getChildren().add(shareEntry);
|
||||
|
||||
AnchorPane buttonPane = new AnchorPane();
|
||||
|
||||
validLabel = new Label("Valid checksum", GlyphUtils.getSuccessGlyph());
|
||||
validLabel.setContentDisplay(ContentDisplay.LEFT);
|
||||
validLabel.setGraphicTextGap(5.0);
|
||||
validLabel.managedProperty().bind(validLabel.visibleProperty());
|
||||
validLabel.setVisible(false);
|
||||
buttonPane.getChildren().add(validLabel);
|
||||
AnchorPane.setTopAnchor(validLabel, 5.0);
|
||||
AnchorPane.setLeftAnchor(validLabel, 0.0);
|
||||
|
||||
invalidLabel = new Label("Invalid checksum", GlyphUtils.getInvalidGlyph());
|
||||
invalidLabel.setContentDisplay(ContentDisplay.LEFT);
|
||||
invalidLabel.setGraphicTextGap(5.0);
|
||||
invalidLabel.managedProperty().bind(invalidLabel.visibleProperty());
|
||||
invalidLabel.setVisible(false);
|
||||
buttonPane.getChildren().add(invalidLabel);
|
||||
AnchorPane.setTopAnchor(invalidLabel, 5.0);
|
||||
AnchorPane.setLeftAnchor(invalidLabel, 0.0);
|
||||
|
||||
secretShareProperty.addListener((ChangeListener<String>) (c, oldval, newval) -> {
|
||||
boolean empty = secretShareProperty.isEmpty().get();
|
||||
boolean validChecksum = false;
|
||||
onInputChange(empty, validChecksum);
|
||||
});
|
||||
|
||||
HBox rightBox = new HBox();
|
||||
rightBox.setSpacing(10);
|
||||
|
||||
calculateButton = new Button("Create Keystore");
|
||||
calculateButton.setDisable(true);
|
||||
calculateButton.setDefaultButton(true);
|
||||
calculateButton.managedProperty().bind(calculateButton.visibleProperty());
|
||||
calculateButton.setTooltip(new Tooltip("Create the keystore from the provided secret share"));
|
||||
calculateButton.setOnAction(event -> {
|
||||
setExpanded(true);
|
||||
enterCodexButton.setVisible(false);
|
||||
importButton.setVisible(true);
|
||||
importButton.setDisable(false);
|
||||
setDescription("Ready to import");
|
||||
showHideLink.setText("Show Derivation...");
|
||||
showHideLink.setVisible(false);
|
||||
setContent(getDerivationEntry(getDefaultDerivation()));
|
||||
});
|
||||
|
||||
rightBox.getChildren().add(calculateButton);
|
||||
|
||||
buttonPane.getChildren().add(rightBox);
|
||||
AnchorPane.setRightAnchor(rightBox, 0.0);
|
||||
|
||||
vBox.getChildren().add(buttonPane);
|
||||
|
||||
Platform.runLater(shareField::requestFocus);
|
||||
|
||||
return vBox;
|
||||
}
|
||||
|
||||
private Node getDerivationEntry(List<ChildNumber> derivation) {
|
||||
TextField derivationField = new TextField();
|
||||
derivationField.setPromptText("Derivation path");
|
||||
derivationField.setText(KeyDerivation.writePath(derivation));
|
||||
HBox.setHgrow(derivationField, Priority.ALWAYS);
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
validationSupport.registerValidator(derivationField, Validator.combine(
|
||||
Validator.createEmptyValidator("Derivation is required"),
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Invalid derivation", !KeyDerivation.isValid(newValue))
|
||||
));
|
||||
|
||||
Button importDerivationButton = new Button("Import Custom Derivation Keystore");
|
||||
importDerivationButton.setDisable(true);
|
||||
importDerivationButton.setOnAction(event -> {
|
||||
showHideLink.setVisible(true);
|
||||
setExpanded(false);
|
||||
List<ChildNumber> importDerivation = KeyDerivation.parsePath(derivationField.getText());
|
||||
importKeystore(importDerivation);
|
||||
});
|
||||
|
||||
derivationField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
importButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue) || !KeyDerivation.parsePath(newValue).equals(derivation));
|
||||
importDerivationButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue) || KeyDerivation.parsePath(newValue).equals(derivation));
|
||||
});
|
||||
|
||||
HBox contentBox = new HBox();
|
||||
contentBox.setAlignment(Pos.TOP_RIGHT);
|
||||
contentBox.setSpacing(20);
|
||||
contentBox.getChildren().add(derivationField);
|
||||
contentBox.getChildren().add(importDerivationButton);
|
||||
contentBox.setPadding(new Insets(10, 30, 10, 30));
|
||||
contentBox.setPrefHeight(60);
|
||||
|
||||
return contentBox;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import com.sparrowwallet.sparrow.UnitFormat;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import javafx.scene.chart.NumberAxis;
|
||||
import javafx.util.StringConverter;
|
||||
|
||||
@@ -18,6 +19,10 @@ final class CoinAxisFormatter extends StringConverter<Number> {
|
||||
|
||||
@Override
|
||||
public String toString(Number object) {
|
||||
if(Config.get().isHideAmounts()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
Double value = bitcoinUnit.getValue(object.longValue());
|
||||
return new CoinTextFormatter(unitFormat).getCoinFormat().format(value);
|
||||
}
|
||||
|
||||
@@ -58,16 +58,22 @@ class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsList
|
||||
DecimalFormat decimalFormat = (amount.longValue() == 0L ? format.getBtcFormat() : format.getTableBtcFormat());
|
||||
final String btcValue = decimalFormat.format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
|
||||
|
||||
if(unit.equals(BitcoinUnit.BTC)) {
|
||||
tooltip.setValue(satsValue + " " + BitcoinUnit.SATOSHIS.getLabel());
|
||||
setText(btcValue);
|
||||
if(Config.get().isHideAmounts()) {
|
||||
setText(CoinLabel.HIDDEN_AMOUNT_TEXT);
|
||||
setTooltip(null);
|
||||
setContextMenu(null);
|
||||
} else {
|
||||
tooltip.setValue(btcValue + " " + BitcoinUnit.BTC.getLabel());
|
||||
setText(satsValue);
|
||||
if(unit.equals(BitcoinUnit.BTC)) {
|
||||
tooltip.setValue(satsValue + " " + BitcoinUnit.SATOSHIS.getLabel());
|
||||
setText(btcValue);
|
||||
} else {
|
||||
tooltip.setValue(btcValue + " " + BitcoinUnit.BTC.getLabel());
|
||||
setText(satsValue);
|
||||
}
|
||||
setTooltip(tooltip);
|
||||
contextMenu.updateAmount(amount);
|
||||
setContextMenu(contextMenu);
|
||||
}
|
||||
setTooltip(tooltip);
|
||||
contextMenu.updateAmount(amount);
|
||||
setContextMenu(contextMenu);
|
||||
|
||||
if(entry instanceof TransactionEntry transactionEntry) {
|
||||
tooltip.showConfirmations(transactionEntry.confirmationsProperty(), transactionEntry.isCoinbase());
|
||||
@@ -86,14 +92,16 @@ class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsList
|
||||
}
|
||||
} else if(entry instanceof UtxoEntry) {
|
||||
setGraphic(null);
|
||||
} else if(entry instanceof HashIndexEntry) {
|
||||
} else if(entry instanceof HashIndexEntry hashIndexEntry) {
|
||||
tooltip.hideConfirmations();
|
||||
|
||||
Region node = new Region();
|
||||
node.setPrefWidth(10);
|
||||
setGraphic(node);
|
||||
setContentDisplay(ContentDisplay.RIGHT);
|
||||
|
||||
if(((HashIndexEntry) entry).getType() == HashIndexEntry.Type.INPUT) {
|
||||
satsValue = "-" + satsValue;
|
||||
if(hashIndexEntry.getType() == HashIndexEntry.Type.INPUT && !Config.get().isHideAmounts()) {
|
||||
setText("-" + getText());
|
||||
}
|
||||
} else {
|
||||
setGraphic(null);
|
||||
@@ -148,6 +156,14 @@ class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsList
|
||||
setTooltipText();
|
||||
}
|
||||
|
||||
public void hideConfirmations() {
|
||||
showConfirmations = false;
|
||||
isCoinbase = false;
|
||||
confirmationsProperty.unbind();
|
||||
|
||||
setTooltipText();
|
||||
}
|
||||
|
||||
private void setTooltipText() {
|
||||
setText(value + (showConfirmations ? " (" + getConfirmationsDescription() + ")" : ""));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.ClipboardContent;
|
||||
|
||||
public class CoinLabel extends Label {
|
||||
public static final String HIDDEN_AMOUNT_TEXT = "\u2022\u2022\u2022\u2022\u2022";
|
||||
|
||||
private final LongProperty valueProperty = new SimpleLongProperty(-1);
|
||||
private final Tooltip tooltip;
|
||||
private final CoinContextMenu contextMenu;
|
||||
@@ -49,6 +51,13 @@ public class CoinLabel extends Label {
|
||||
}
|
||||
|
||||
private void setValueAsText(Long value, BitcoinUnit bitcoinUnit) {
|
||||
if(Config.get().isHideAmounts()) {
|
||||
setText(HIDDEN_AMOUNT_TEXT);
|
||||
setTooltip(null);
|
||||
setContextMenu(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setTooltip(tooltip);
|
||||
setContextMenu(contextMenu);
|
||||
|
||||
|
||||
@@ -10,9 +10,7 @@ import javafx.scene.control.MenuItem;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.ClipboardContent;
|
||||
import javafx.event.EventHandler;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
|
||||
public class CopyableCoinLabel extends CopyableLabel {
|
||||
private final LongProperty valueProperty = new SimpleLongProperty(-1);
|
||||
@@ -72,6 +70,13 @@ public class CopyableCoinLabel extends CopyableLabel {
|
||||
}
|
||||
|
||||
private void setValueAsText(Long value, UnitFormat unitFormat, BitcoinUnit bitcoinUnit) {
|
||||
if(Config.get().isHideAmounts()) {
|
||||
setText(CoinLabel.HIDDEN_AMOUNT_TEXT);
|
||||
setTooltip(null);
|
||||
setContextMenu(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setTooltip(tooltip);
|
||||
setContextMenu(contextMenu);
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import javafx.scene.control.*;
|
||||
import javafx.scene.control.Button;
|
||||
|
||||
public class DescriptorQRDisplayDialog extends QRDisplayDialog {
|
||||
public DescriptorQRDisplayDialog(String walletName, String outputDescriptor, UR ur, BBQR bbqr, boolean selectBbqrButton) {
|
||||
super(ur, bbqr, false, false, selectBbqrButton);
|
||||
public DescriptorQRDisplayDialog(String walletName, String outputDescriptor, UR ur, BBQR bbqr, QREncoding encoding) {
|
||||
super(ur, bbqr, false, false, encoding);
|
||||
|
||||
DialogPane dialogPane = getDialogPane();
|
||||
final ButtonType pdfButtonType = new javafx.scene.control.ButtonType("Save PDF...", ButtonBar.ButtonData.HELP_2);
|
||||
@@ -20,7 +20,7 @@ public class DescriptorQRDisplayDialog extends QRDisplayDialog {
|
||||
pdfButton.setGraphicTextGap(5);
|
||||
pdfButton.setGraphic(getGlyph(FontAwesome5.Glyph.FILE_PDF));
|
||||
pdfButton.addEventFilter(ActionEvent.ACTION, event -> {
|
||||
PdfUtils.saveOutputDescriptor(walletName, outputDescriptor, ur, isUseBbqrEncoding() ? bbqr : null);
|
||||
PdfUtils.saveOutputDescriptor(walletName, outputDescriptor, ur, getEncoding() == QREncoding.BBQR ? bbqr : null);
|
||||
event.consume();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.sparrowwallet.sparrow.event.*;
|
||||
import com.sparrowwallet.sparrow.io.*;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.net.ElectrumServer;
|
||||
import com.sparrowwallet.sparrow.net.ServerType;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
@@ -51,7 +52,8 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
private final Wallet wallet;
|
||||
private final PSBT psbt;
|
||||
private final OutputDescriptor outputDescriptor;
|
||||
private final KeyDerivation keyDerivation;
|
||||
private final KeyDerivation defaultDerivation;
|
||||
private final KeyDerivation requiredDerivation;
|
||||
private final String message;
|
||||
private final List<StandardAccount> availableAccounts;
|
||||
private final Device device;
|
||||
@@ -74,13 +76,14 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
|
||||
private boolean defaultDevice;
|
||||
|
||||
public DevicePane(Wallet wallet, Device device, boolean defaultDevice, KeyDerivation requiredDerivation) {
|
||||
public DevicePane(Wallet wallet, Device device, boolean defaultDevice, KeyDerivation defaultDerivation, KeyDerivation requiredDerivation) {
|
||||
super(device.getModel().toDisplayString(), "", "", device.getModel());
|
||||
this.deviceOperation = DeviceOperation.IMPORT;
|
||||
this.wallet = wallet;
|
||||
this.psbt = null;
|
||||
this.outputDescriptor = null;
|
||||
this.keyDerivation = requiredDerivation;
|
||||
this.defaultDerivation = defaultDerivation;
|
||||
this.requiredDerivation = requiredDerivation;
|
||||
this.message = null;
|
||||
this.availableAccounts = null;
|
||||
this.device = device;
|
||||
@@ -107,7 +110,8 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
this.wallet = wallet;
|
||||
this.psbt = psbt;
|
||||
this.outputDescriptor = null;
|
||||
this.keyDerivation = null;
|
||||
this.defaultDerivation = null;
|
||||
this.requiredDerivation = null;
|
||||
this.message = null;
|
||||
this.availableAccounts = null;
|
||||
this.device = device;
|
||||
@@ -134,7 +138,8 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
this.wallet = wallet;
|
||||
this.psbt = null;
|
||||
this.outputDescriptor = outputDescriptor;
|
||||
this.keyDerivation = null;
|
||||
this.defaultDerivation = null;
|
||||
this.requiredDerivation = null;
|
||||
this.message = null;
|
||||
this.availableAccounts = null;
|
||||
this.device = device;
|
||||
@@ -151,13 +156,14 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
buttonBox.getChildren().addAll(setPassphraseButton, displayAddressButton);
|
||||
}
|
||||
|
||||
public DevicePane(Wallet wallet, String message, KeyDerivation keyDerivation, Device device, boolean defaultDevice) {
|
||||
public DevicePane(Wallet wallet, String message, KeyDerivation requiredDerivation, Device device, boolean defaultDevice) {
|
||||
super(device.getModel().toDisplayString(), "", "", device.getModel());
|
||||
this.deviceOperation = DeviceOperation.SIGN_MESSAGE;
|
||||
this.wallet = wallet;
|
||||
this.psbt = null;
|
||||
this.outputDescriptor = null;
|
||||
this.keyDerivation = keyDerivation;
|
||||
this.defaultDerivation = requiredDerivation;
|
||||
this.requiredDerivation = requiredDerivation;
|
||||
this.message = message;
|
||||
this.availableAccounts = null;
|
||||
this.device = device;
|
||||
@@ -184,7 +190,8 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
this.wallet = wallet;
|
||||
this.psbt = null;
|
||||
this.outputDescriptor = null;
|
||||
this.keyDerivation = null;
|
||||
this.defaultDerivation = null;
|
||||
this.requiredDerivation = null;
|
||||
this.message = null;
|
||||
this.device = device;
|
||||
this.defaultDevice = defaultDevice;
|
||||
@@ -207,7 +214,8 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
this.wallet = null;
|
||||
this.psbt = null;
|
||||
this.outputDescriptor = null;
|
||||
this.keyDerivation = null;
|
||||
this.defaultDerivation = null;
|
||||
this.requiredDerivation = null;
|
||||
this.message = null;
|
||||
this.device = device;
|
||||
this.defaultDevice = defaultDevice;
|
||||
@@ -286,13 +294,12 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
}
|
||||
|
||||
private void createImportButton() {
|
||||
importButton = keyDerivation == null ? new SplitMenuButton() : new Button();
|
||||
importButton = requiredDerivation == null ? new SplitMenuButton() : new Button();
|
||||
importButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
importButton.setText("Import Keystore");
|
||||
importButton.setOnAction(event -> {
|
||||
importButton.setDisable(true);
|
||||
List<ChildNumber> defaultDerivation = wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation();
|
||||
importKeystore(keyDerivation == null ? defaultDerivation : keyDerivation.getDerivation());
|
||||
importKeystore(requiredDerivation == null ? getDefaultDerivation() : requiredDerivation.getDerivation());
|
||||
});
|
||||
|
||||
if(importButton instanceof SplitMenuButton importMenuButton) {
|
||||
@@ -307,6 +314,11 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
});
|
||||
importMenuButton.getItems().add(item);
|
||||
}
|
||||
importMenuButton.getItems().add(new SeparatorMenuItem());
|
||||
MenuItem discoverItem = new MenuItem("Discover Wallet...");
|
||||
discoverItem.setDisable(!AppServices.isConnected());
|
||||
discoverItem.setOnAction(_ -> discoverWallet());
|
||||
importMenuButton.getItems().add(discoverItem);
|
||||
} else {
|
||||
String[] accounts = new String[] {"Default Account #0", "Account #1", "Account #2", "Account #3", "Account #4", "Account #5", "Account #6", "Account #7", "Account #8", "Account #9"};
|
||||
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
|
||||
@@ -363,7 +375,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
signMessageButton.managedProperty().bind(signMessageButton.visibleProperty());
|
||||
signMessageButton.setVisible(false);
|
||||
|
||||
if(device.getFingerprint() != null && !device.getFingerprint().equals(keyDerivation.getMasterFingerprint())) {
|
||||
if(device.getFingerprint() != null && !device.getFingerprint().equals(requiredDerivation.getMasterFingerprint())) {
|
||||
signMessageButton.setDisable(true);
|
||||
}
|
||||
}
|
||||
@@ -372,7 +384,6 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
discoverKeystoresButton = new Button("Discover");
|
||||
discoverKeystoresButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
discoverKeystoresButton.setOnAction(event -> {
|
||||
discoverKeystoresButton.setDisable(true);
|
||||
discoverKeystores();
|
||||
});
|
||||
discoverKeystoresButton.managedProperty().bind(discoverKeystoresButton.visibleProperty());
|
||||
@@ -433,6 +444,14 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
getAddressButton.setVisible(false);
|
||||
}
|
||||
|
||||
private List<ChildNumber> getDefaultDerivation() {
|
||||
if(defaultDerivation != null && !defaultDerivation.getDerivation().isEmpty()) {
|
||||
return defaultDerivation.getDerivation();
|
||||
}
|
||||
|
||||
return wallet == null || wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation();
|
||||
}
|
||||
|
||||
private void unlock(Device device) {
|
||||
if(device.getModel().requiresPinPrompt()) {
|
||||
promptPin();
|
||||
@@ -500,7 +519,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
|
||||
SplitMenuButton sendPassphraseButton = new SplitMenuButton();
|
||||
sendPassphraseButton.setText("Send Passphrase");
|
||||
sendPassphraseButton.getStyleClass().add("default-button");
|
||||
setDefaultButton(sendPassphraseButton);
|
||||
sendPassphraseButton.setOnAction(event -> {
|
||||
setExpanded(false);
|
||||
setDescription("Confirm passphrase on device...");
|
||||
@@ -864,7 +883,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
if(device.isCard()) {
|
||||
try {
|
||||
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
|
||||
Service<String> signMessageService = cardApi.getSignMessageService(message, wallet.getScriptType(), keyDerivation.getDerivation(), messageProperty);
|
||||
Service<String> signMessageService = cardApi.getSignMessageService(message, wallet.getScriptType(), requiredDerivation.getDerivation(), messageProperty);
|
||||
handleCardOperation(signMessageService, signMessageButton, "Signing", true, event -> {
|
||||
String signature = signMessageService.getValue();
|
||||
EventManager.get().post(new MessageSignedEvent(wallet, signature));
|
||||
@@ -875,7 +894,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
signButton.setDisable(false);
|
||||
}
|
||||
} else {
|
||||
Hwi.SignMessageService signMessageService = new Hwi.SignMessageService(device, passphrase.get(), message, keyDerivation.getDerivationPath());
|
||||
Hwi.SignMessageService signMessageService = new Hwi.SignMessageService(device, passphrase.get(), message, requiredDerivation.getDerivationPath());
|
||||
signMessageService.setOnSucceeded(successEvent -> {
|
||||
String signature = signMessageService.getValue();
|
||||
EventManager.get().post(new MessageSignedEvent(wallet, signature));
|
||||
@@ -889,29 +908,133 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
}
|
||||
}
|
||||
|
||||
private void discoverWallet() {
|
||||
importButton.setDisable(true);
|
||||
importButton.setMaxHeight(importButton.getHeight());
|
||||
ProgressIndicator progressIndicator = new ProgressIndicator(0);
|
||||
progressIndicator.getStyleClass().add("button-progress");
|
||||
importButton.setGraphic(progressIndicator);
|
||||
List<Wallet> wallets = new ArrayList<>();
|
||||
|
||||
RangeInputDialog rangeInputDialog = new RangeInputDialog(StandardAccount.ACCOUNT_0.getAccountNumber(), StandardAccount.ACCOUNT_30.getAccountNumber(), StandardAccount.ACCOUNT_10.getAccountNumber());
|
||||
rangeInputDialog.setTitle("Choose number of accounts");
|
||||
rangeInputDialog.setHeaderText("Enter the number of additional accounts to scan for existing funds.\n\nThis may take a few minutes depending on how many accounts are selected.");
|
||||
Optional<Integer> optRange = rangeInputDialog.showAndWait();
|
||||
if(optRange.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<StandardAccount> discoveryAccounts = new ArrayList<>(Arrays.asList(StandardAccount.values()).subList(0, optRange.get() + 1));
|
||||
Map<Hwi.WalletType, String> derivationPaths = new LinkedHashMap<>();
|
||||
List<ScriptType> scriptTypes = new ArrayList<>(ScriptType.getAddressableScriptTypes(PolicyType.SINGLE));
|
||||
if(device.getModel() == WalletModel.BITBOX_02) {
|
||||
scriptTypes.remove(ScriptType.P2PKH);
|
||||
}
|
||||
for(ScriptType scriptType : scriptTypes) {
|
||||
for(StandardAccount discoveryAccount : discoveryAccounts) {
|
||||
derivationPaths.put(new Hwi.WalletType(scriptType, discoveryAccount), KeyDerivation.writePath(scriptType.getDefaultDerivation(discoveryAccount.getAccountNumber())));
|
||||
}
|
||||
}
|
||||
|
||||
Hwi.GetXpubsService getXpubsService = new Hwi.GetXpubsService(device, passphrase.get(), derivationPaths);
|
||||
getXpubsService.setOnSucceeded(_ -> {
|
||||
Map<Hwi.WalletType, String> accountXpubs = getXpubsService.getValue();
|
||||
|
||||
for(Map.Entry<Hwi.WalletType, String> entry : accountXpubs.entrySet()) {
|
||||
try {
|
||||
Wallet wallet = new Wallet(device.getModel().toDisplayString());
|
||||
wallet.setPolicyType(PolicyType.SINGLE);
|
||||
wallet.setScriptType(entry.getKey().scriptType());
|
||||
Keystore keystore = new Keystore();
|
||||
keystore.setLabel(device.getModel().toDisplayString());
|
||||
keystore.setSource(KeystoreSource.HW_USB);
|
||||
keystore.setWalletModel(device.getModel());
|
||||
keystore.setKeyDerivation(new KeyDerivation(device.getFingerprint(), derivationPaths.get(entry.getKey())));
|
||||
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(entry.getValue()));
|
||||
wallet.getKeystores().add(keystore);
|
||||
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, entry.getKey().scriptType(), wallet.getKeystores(), 1));
|
||||
if(entry.getKey().standardAccount().equals(StandardAccount.ACCOUNT_0)) {
|
||||
wallets.add(wallet);
|
||||
} else {
|
||||
Wallet masterWallet = wallets.getLast();
|
||||
wallet.setName(entry.getKey().standardAccount().getName());
|
||||
wallet.setMasterWallet(masterWallet);
|
||||
masterWallet.getChildWallets().add(wallet);
|
||||
}
|
||||
} catch(Exception e) {
|
||||
setError("Could not retrieve xpub", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
ElectrumServer.WalletDiscoveryService walletDiscoveryService = new ElectrumServer.WalletDiscoveryService(wallets);
|
||||
walletDiscoveryService.setOnSucceeded(_ -> {
|
||||
importButton.setGraphic(null);
|
||||
Optional<List<Wallet>> optWallets = walletDiscoveryService.getValue();
|
||||
if(optWallets.isPresent()) {
|
||||
List<Wallet> discoveredWallets = optWallets.get();
|
||||
if(discoveredWallets.size() > 1) {
|
||||
for(Wallet wallet : discoveredWallets) {
|
||||
wallet.setName(wallet.getName() + " " + wallet.getScriptType().getDescription());
|
||||
}
|
||||
}
|
||||
EventManager.get().post(new WalletImportEvent(discoveredWallets));
|
||||
} else {
|
||||
AppServices.showErrorDialog("No existing wallet found",
|
||||
Config.get().getServerType() == ServerType.BITCOIN_CORE ? "The configured server type is Bitcoin Core, which does not support wallet discovery.\n\n" +
|
||||
"You can however import the " + device.getModel().toDisplayString() + " and scan the blockchain by supplying a start date." :
|
||||
"Could not find a wallet with existing transactions using the " + device.getModel().toDisplayString() + ".");
|
||||
setDefaultStatus();
|
||||
importButton.setDisable(false);
|
||||
}
|
||||
});
|
||||
walletDiscoveryService.setOnFailed(failedEvent -> {
|
||||
log.error("Failed to discover wallets", failedEvent.getSource().getException());
|
||||
setError("Failed to discover wallets", failedEvent.getSource().getException().getMessage());
|
||||
importButton.setGraphic(null);
|
||||
importButton.setDisable(false);
|
||||
});
|
||||
walletDiscoveryService.start();
|
||||
});
|
||||
getXpubsService.setOnFailed(_ -> {
|
||||
setError("Could not retrieve xpub", getXpubsService.getException().getMessage());
|
||||
importButton.setGraphic(null);
|
||||
importButton.setDisable(false);
|
||||
});
|
||||
progressIndicator.progressProperty().bind(getXpubsService.progressProperty());
|
||||
getXpubsService.progressProperty().addListener((_, _, newValue) -> setDescription("Discovering... (" + Math.round(newValue.doubleValue() * 100) + "%)"));
|
||||
showHideLink.setVisible(false);
|
||||
getXpubsService.start();
|
||||
}
|
||||
|
||||
private void discoverKeystores() {
|
||||
if(wallet.getKeystores().size() != 1) {
|
||||
setError("Could not discover keystores", "Only single signature wallets are supported for keystore discovery");
|
||||
return;
|
||||
}
|
||||
|
||||
discoverKeystoresButton.setDisable(true);
|
||||
discoverKeystoresButton.setMaxHeight(discoverKeystoresButton.getHeight());
|
||||
ProgressIndicator progressIndicator = new ProgressIndicator(0);
|
||||
progressIndicator.getStyleClass().add("button-progress");
|
||||
discoverKeystoresButton.setGraphic(progressIndicator);
|
||||
|
||||
String masterFingerprint = wallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint();
|
||||
|
||||
Wallet copyWallet = wallet.copy();
|
||||
Map<StandardAccount, String> accountDerivationPaths = new LinkedHashMap<>();
|
||||
Map<Hwi.WalletType, String> accountDerivationPaths = new LinkedHashMap<>();
|
||||
for(StandardAccount availableAccount : availableAccounts) {
|
||||
Wallet availableWallet = copyWallet.addChildWallet(availableAccount);
|
||||
Keystore availableKeystore = availableWallet.getKeystores().get(0);
|
||||
String derivationPath = availableKeystore.getKeyDerivation().getDerivationPath();
|
||||
accountDerivationPaths.put(availableAccount, derivationPath);
|
||||
accountDerivationPaths.put(new Hwi.WalletType(wallet.getScriptType(), availableAccount), derivationPath);
|
||||
}
|
||||
|
||||
Map<StandardAccount, Keystore> importedKeystores = new LinkedHashMap<>();
|
||||
Hwi.GetXpubsService getXpubsService = new Hwi.GetXpubsService(device, passphrase.get(), accountDerivationPaths);
|
||||
getXpubsService.setOnSucceeded(workerStateEvent -> {
|
||||
Map<StandardAccount, String> accountXpubs = getXpubsService.getValue();
|
||||
Map<Hwi.WalletType, String> accountXpubs = getXpubsService.getValue();
|
||||
|
||||
for(Map.Entry<StandardAccount, String> entry : accountXpubs.entrySet()) {
|
||||
for(Map.Entry<Hwi.WalletType, String> entry : accountXpubs.entrySet()) {
|
||||
try {
|
||||
Keystore keystore = new Keystore();
|
||||
keystore.setLabel(device.getModel().toDisplayString());
|
||||
@@ -919,7 +1042,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
keystore.setWalletModel(device.getModel());
|
||||
keystore.setKeyDerivation(new KeyDerivation(masterFingerprint, accountDerivationPaths.get(entry.getKey())));
|
||||
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(entry.getValue()));
|
||||
importedKeystores.put(entry.getKey(), keystore);
|
||||
importedKeystores.put(entry.getKey().standardAccount(), keystore);
|
||||
} catch(Exception e) {
|
||||
setError("Could not retrieve xpub", e.getMessage());
|
||||
}
|
||||
@@ -933,15 +1056,18 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
accountDiscoveryService.setOnFailed(event -> {
|
||||
log.error("Failed to discover accounts", event.getSource().getException());
|
||||
setError("Failed to discover accounts", event.getSource().getException().getMessage());
|
||||
discoverKeystoresButton.setGraphic(null);
|
||||
discoverKeystoresButton.setDisable(false);
|
||||
});
|
||||
accountDiscoveryService.start();
|
||||
});
|
||||
getXpubsService.setOnFailed(workerStateEvent -> {
|
||||
setError("Could not retrieve xpub", getXpubsService.getException().getMessage());
|
||||
discoverKeystoresButton.setGraphic(null);
|
||||
discoverKeystoresButton.setDisable(false);
|
||||
});
|
||||
setDescription("Discovering...");
|
||||
progressIndicator.progressProperty().bind(getXpubsService.progressProperty());
|
||||
getXpubsService.progressProperty().addListener((_, _, newValue) -> setDescription("Discovering... (" + Math.round(newValue.doubleValue() * 100) + "%)"));
|
||||
showHideLink.setVisible(false);
|
||||
getXpubsService.start();
|
||||
}
|
||||
@@ -998,13 +1124,12 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
private void showOperationButton() {
|
||||
if(deviceOperation.equals(DeviceOperation.IMPORT)) {
|
||||
if(defaultDevice) {
|
||||
importButton.getStyleClass().add("default-button");
|
||||
setDefaultButton(importButton);
|
||||
}
|
||||
importButton.setVisible(true);
|
||||
showHideLink.setText("Show derivation...");
|
||||
showHideLink.setVisible(!device.isCard());
|
||||
List<ChildNumber> defaultDerivation = wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation();
|
||||
setContent(getDerivationEntry(keyDerivation == null ? defaultDerivation : keyDerivation.getDerivation()));
|
||||
setContent(getDerivationEntry(requiredDerivation == null ? getDefaultDerivation() : requiredDerivation.getDerivation()));
|
||||
} else if(deviceOperation.equals(DeviceOperation.SIGN)) {
|
||||
signButton.setDefaultButton(defaultDevice);
|
||||
signButton.setVisible(true);
|
||||
@@ -1023,7 +1148,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
showHideLink.setVisible(false);
|
||||
} else if(deviceOperation.equals(DeviceOperation.GET_PRIVATE_KEY)) {
|
||||
if(defaultDevice) {
|
||||
getPrivateKeyButton.getStyleClass().add("default-button");
|
||||
setDefaultButton(getPrivateKeyButton);
|
||||
}
|
||||
getPrivateKeyButton.setVisible(true);
|
||||
showHideLink.setVisible(false);
|
||||
@@ -1038,7 +1163,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
TextField derivationField = new TextField();
|
||||
derivationField.setPromptText("Derivation path");
|
||||
derivationField.setText(KeyDerivation.writePath(derivation));
|
||||
derivationField.setDisable(device.isCard() || keyDerivation != null);
|
||||
derivationField.setDisable(device.isCard() || requiredDerivation != null);
|
||||
HBox.setHgrow(derivationField, Priority.ALWAYS);
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
|
||||
@@ -62,7 +62,7 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
private static final List<String> ARCHIVE_EXTENSIONS = List.of("zip", "tar.gz", "tar.bz2", "tar.xz", "rar", "7z");
|
||||
|
||||
private static final String SPARROW_RELEASE_PREFIX = "sparrow-";
|
||||
private static final String SPARROW_RELEASE_ALT_PREFIX = "sparrow_";
|
||||
private static final String[] SPARROW_RELEASE_ALT_PREFIXES = { "sparrowwallet-", "sparrowwallet_", "sparrowserver-", "sparrowserver_" };
|
||||
private static final String SPARROW_MANIFEST_SUFFIX = "-manifest.txt";
|
||||
private static final String SPARROW_SIGNATURE_SUFFIX = SPARROW_MANIFEST_SUFFIX + ".asc";
|
||||
private static final Pattern SPARROW_RELEASE_VERSION = Pattern.compile("[0-9]+(\\.[0-9]+)*");
|
||||
@@ -465,7 +465,7 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
}
|
||||
|
||||
String providedName = providedFile.getName().toLowerCase(Locale.ROOT);
|
||||
if(providedName.startsWith(SPARROW_RELEASE_PREFIX) || providedName.startsWith(SPARROW_RELEASE_ALT_PREFIX)) {
|
||||
if(providedName.startsWith(SPARROW_RELEASE_PREFIX) || Arrays.stream(SPARROW_RELEASE_ALT_PREFIXES).anyMatch(providedName::startsWith)) {
|
||||
Matcher matcher = SPARROW_RELEASE_VERSION.matcher(providedFile.getName());
|
||||
if(matcher.find()) {
|
||||
String version = matcher.group();
|
||||
@@ -591,7 +591,8 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
}
|
||||
}
|
||||
|
||||
if((name.startsWith(SPARROW_RELEASE_PREFIX) || name.startsWith(SPARROW_RELEASE_ALT_PREFIX)) && file.length() >= MIN_VALID_SPARROW_RELEASE_SIZE) {
|
||||
if((name.startsWith(SPARROW_RELEASE_PREFIX) || Arrays.stream(SPARROW_RELEASE_ALT_PREFIXES).anyMatch(name::startsWith))
|
||||
&& file.length() >= MIN_VALID_SPARROW_RELEASE_SIZE) {
|
||||
Matcher matcher = SPARROW_RELEASE_VERSION.matcher(name);
|
||||
return matcher.find();
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
super.updateItem(entry, empty);
|
||||
|
||||
//Return immediately to avoid CPU usage when updating the same invisible cell to determine tableview size (see https://bugs.openjdk.org/browse/JDK-8280442)
|
||||
if(this == lastCell && !getTableRow().isVisible()) {
|
||||
if(this == lastCell && !getTableRow().isVisible() && isTableSizeRecalculation()) {
|
||||
return;
|
||||
}
|
||||
lastCell = this;
|
||||
@@ -124,13 +124,13 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
setGraphic(actionBox);
|
||||
} else if(entry instanceof NodeEntry nodeEntry) {
|
||||
Address address = nodeEntry.getAddress();
|
||||
getStyleClass().add("address-cell");
|
||||
setText(address.toString());
|
||||
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry, true, getTreeTableView()));
|
||||
Tooltip tooltip = new Tooltip();
|
||||
tooltip.setShowDelay(Duration.millis(250));
|
||||
tooltip.setText(nodeEntry.getNode().toString());
|
||||
setTooltip(tooltip);
|
||||
getStyleClass().add("address-cell");
|
||||
|
||||
HBox actionBox = new HBox();
|
||||
actionBox.getStyleClass().add("cell-actions");
|
||||
@@ -856,4 +856,11 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isTableSizeRecalculation() {
|
||||
//As per https://bugs.openjdk.org/browse/JDK-8265669 we check for cell visibility to avoid unnecessary recalculation, but this can result in false positives
|
||||
//The method releaseCell in VirtualFlow is responsible for setting accumCell visibility to false after use, so check this method is calling updateItem
|
||||
return StackWalker.getInstance().walk(frames -> frames.anyMatch(frame -> frame.getClassName().equals("javafx.scene.control.skin.VirtualFlow")
|
||||
&& frame.getMethodName().equals("releaseCell")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.sparrowwallet.drongo.OsType;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.sparrow.CurrencyRate;
|
||||
import com.sparrowwallet.sparrow.UnitFormat;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import javafx.scene.control.ContextMenu;
|
||||
import javafx.scene.control.MenuItem;
|
||||
@@ -47,20 +48,27 @@ public class FiatCell extends TreeTableCell<Entry, Number> {
|
||||
CurrencyRate currencyRate = coinTreeTable.getCurrencyRate();
|
||||
|
||||
if(currencyRate != null && currencyRate.isAvailable()) {
|
||||
Currency currency = currencyRate.getCurrency();
|
||||
double btcRate = currencyRate.getBtcRate();
|
||||
if(Config.get().isHideAmounts()) {
|
||||
setText(CoinLabel.HIDDEN_AMOUNT_TEXT);
|
||||
setGraphic(null);
|
||||
setTooltip(null);
|
||||
setContextMenu(null);
|
||||
} else {
|
||||
Currency currency = currencyRate.getCurrency();
|
||||
double btcRate = currencyRate.getBtcRate();
|
||||
|
||||
BigDecimal satsBalance = BigDecimal.valueOf(amount.longValue());
|
||||
BigDecimal btcBalance = satsBalance.divide(BigDecimal.valueOf(Transaction.SATOSHIS_PER_BITCOIN));
|
||||
BigDecimal fiatBalance = btcBalance.multiply(BigDecimal.valueOf(btcRate));
|
||||
BigDecimal satsBalance = BigDecimal.valueOf(amount.longValue());
|
||||
BigDecimal btcBalance = satsBalance.divide(BigDecimal.valueOf(Transaction.SATOSHIS_PER_BITCOIN));
|
||||
BigDecimal fiatBalance = btcBalance.multiply(BigDecimal.valueOf(btcRate));
|
||||
|
||||
String label = format.formatCurrencyValue(fiatBalance.doubleValue());
|
||||
tooltip.setText("1 BTC = " + currency.getSymbol() + " " + format.formatCurrencyValue(btcRate));
|
||||
String label = format.formatCurrencyValue(fiatBalance.doubleValue());
|
||||
tooltip.setText("1 BTC = " + currency.getSymbol() + " " + format.formatCurrencyValue(btcRate));
|
||||
|
||||
setText(label);
|
||||
setGraphic(null);
|
||||
setTooltip(tooltip);
|
||||
setContextMenu(contextMenu);
|
||||
setText(label);
|
||||
setGraphic(null);
|
||||
setTooltip(tooltip);
|
||||
setContextMenu(contextMenu);
|
||||
}
|
||||
} else {
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
|
||||
@@ -90,6 +90,13 @@ public class FiatLabel extends CopyableLabel {
|
||||
|
||||
private void setValueAsText(long balance, UnitFormat unitFormat) {
|
||||
if(getCurrency() != null && getBtcRate() > 0.0) {
|
||||
if(Config.get().isHideAmounts()) {
|
||||
setText(CoinLabel.HIDDEN_AMOUNT_TEXT);
|
||||
setTooltip(null);
|
||||
setContextMenu(null);
|
||||
return;
|
||||
}
|
||||
|
||||
BigDecimal satsBalance = BigDecimal.valueOf(balance);
|
||||
BigDecimal btcBalance = satsBalance.divide(BigDecimal.valueOf(Transaction.SATOSHIS_PER_BITCOIN));
|
||||
BigDecimal fiatBalance = btcBalance.multiply(BigDecimal.valueOf(getBtcRate()));
|
||||
|
||||
@@ -157,7 +157,7 @@ public class FileKeystoreExportPane extends TitledDescriptionPane {
|
||||
if(exporter instanceof Bip129) {
|
||||
UR ur = UR.fromBytes(baos.toByteArray());
|
||||
BBQR bbqr = new BBQR(BBQRType.UNICODE, baos.toByteArray());
|
||||
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, true, false);
|
||||
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, true, QREncoding.UR);
|
||||
} else {
|
||||
qrDisplayDialog = new QRDisplayDialog(baos.toString(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@@ -171,18 +171,18 @@ public class FileWalletExportPane extends TitledDescriptionPane {
|
||||
} else if(exporter instanceof Bip129 || exporter instanceof WalletLabels) {
|
||||
UR ur = UR.fromBytes(outputStream.toByteArray());
|
||||
BBQR bbqr = new BBQR(BBQRType.UNICODE, outputStream.toByteArray());
|
||||
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, false, false);
|
||||
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, false, QREncoding.UR);
|
||||
} else if(exporter instanceof Descriptor) {
|
||||
boolean addBbqrOption = exportWallet.getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().showBbqr());
|
||||
boolean selectBbqrOption = exportWallet.getKeystores().stream().allMatch(keystore -> keystore.getWalletModel().selectBbqr());
|
||||
QREncoding encoding = exportWallet.getKeystores().stream().allMatch(keystore -> keystore.getWalletModel().selectBbqr()) ? QREncoding.BBQR : QREncoding.UR;
|
||||
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(exportWallet, KeyPurpose.DEFAULT_PURPOSES, null);
|
||||
CryptoOutput cryptoOutput = getCryptoOutput(exportWallet);
|
||||
BBQR bbqr = addBbqrOption ? new BBQR(BBQRType.UNICODE, outputDescriptor.toString(true).getBytes(StandardCharsets.UTF_8)) : null;
|
||||
qrDisplayDialog = new DescriptorQRDisplayDialog(exportWallet.getFullDisplayName(), outputDescriptor.toString(true), cryptoOutput.toUR(), bbqr, selectBbqrOption);
|
||||
qrDisplayDialog = new DescriptorQRDisplayDialog(exportWallet.getFullDisplayName(), outputDescriptor.toString(true), cryptoOutput.toUR(), bbqr, encoding);
|
||||
} else if(exporter.getClass().equals(ColdcardMultisig.class)) {
|
||||
UR ur = UR.fromBytes(outputStream.toByteArray());
|
||||
BBQR bbqr = new BBQR(BBQRType.UNICODE, outputStream.toByteArray());
|
||||
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, false, true);
|
||||
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, false, QREncoding.BBQR);
|
||||
} else {
|
||||
qrDisplayDialog = new QRDisplayDialog(outputStream.toString(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
address.getStyleClass().add("id");
|
||||
address.setEditable(walletNode == null);
|
||||
address.setTooltip(new Tooltip("Only singlesig addresses can sign"));
|
||||
address.setSkin(new AddressTextFieldSkin(address));
|
||||
addressField.getInputs().add(address);
|
||||
|
||||
if(walletNode != null) {
|
||||
|
||||
@@ -6,10 +6,9 @@ import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import javafx.beans.property.SimpleListProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.geometry.Orientation;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.layout.GridPane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.TilePane;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -32,11 +31,9 @@ public class MnemonicKeystoreDisplayPane extends MnemonicKeystorePane {
|
||||
VBox vBox = new VBox();
|
||||
vBox.setSpacing(10);
|
||||
|
||||
wordsPane = new TilePane();
|
||||
wordsPane.setPrefRows(Math.ceilDiv(numWords, 3));
|
||||
wordsPane = new GridPane();
|
||||
wordsPane.setHgap(10);
|
||||
wordsPane.setVgap(10);
|
||||
wordsPane.setOrientation(Orientation.VERTICAL);
|
||||
|
||||
List<String> words = new ArrayList<>();
|
||||
for(int i = 0; i < numWords; i++) {
|
||||
@@ -53,7 +50,14 @@ public class MnemonicKeystoreDisplayPane extends MnemonicKeystorePane {
|
||||
wordEntries.get(i).setNextEntry(wordEntries.get(i + 1));
|
||||
wordEntries.get(i).setNextField(wordEntries.get(i + 1).getEditor());
|
||||
}
|
||||
wordsPane.getChildren().addAll(wordEntries);
|
||||
|
||||
int numCols = 3;
|
||||
int numRows = Math.ceilDiv(numWords, numCols);
|
||||
for(int i = 0; i < wordEntries.size(); i++) {
|
||||
int col = i / numRows;
|
||||
int row = i % numRows;
|
||||
wordsPane.add(wordEntries.get(i), col, row);
|
||||
}
|
||||
|
||||
vBox.getChildren().add(wordsPane);
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
|
||||
importButton = new SplitMenuButton();
|
||||
importButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
importButton.setText("Import Keystore");
|
||||
importButton.getStyleClass().add("default-button");
|
||||
setDefaultButton(importButton);
|
||||
importButton.setOnAction(event -> {
|
||||
importButton.setDisable(true);
|
||||
importKeystore(getDefaultDerivation(), false);
|
||||
|
||||
@@ -15,20 +15,15 @@ import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.concurrent.ScheduledService;
|
||||
import javafx.concurrent.Task;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Orientation;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.util.Callback;
|
||||
import javafx.util.Duration;
|
||||
import org.controlsfx.control.textfield.AutoCompletionBinding;
|
||||
import org.controlsfx.control.textfield.TextFields;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.controlsfx.validation.ValidationResult;
|
||||
import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.Validator;
|
||||
@@ -44,7 +39,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
|
||||
private static final Logger log = LoggerFactory.getLogger(MnemonicKeystorePane.class);
|
||||
|
||||
protected SplitMenuButton enterMnemonicButton;
|
||||
protected TilePane wordsPane;
|
||||
protected GridPane wordsPane;
|
||||
protected Label validLabel;
|
||||
protected Label invalidLabel;
|
||||
|
||||
@@ -167,11 +162,9 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
|
||||
VBox vBox = new VBox();
|
||||
vBox.setSpacing(10);
|
||||
|
||||
wordsPane = new TilePane();
|
||||
wordsPane.setPrefRows(Math.ceilDiv(numWords, 3));
|
||||
wordsPane = new GridPane();
|
||||
wordsPane.setHgap(10);
|
||||
wordsPane.setVgap(10);
|
||||
wordsPane.setOrientation(Orientation.VERTICAL);
|
||||
|
||||
List<String> words = new ArrayList<>();
|
||||
for(int i = 0; i < numWords; i++) {
|
||||
@@ -188,7 +181,14 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
|
||||
wordEntries.get(i).setNextEntry(wordEntries.get(i + 1));
|
||||
wordEntries.get(i).setNextField(wordEntries.get(i + 1).getEditor());
|
||||
}
|
||||
wordsPane.getChildren().addAll(wordEntries);
|
||||
|
||||
int numCols = 3;
|
||||
int numRows = Math.ceilDiv(numWords, numCols);
|
||||
for(int i = 0; i < wordEntries.size(); i++) {
|
||||
int col = i / numRows;
|
||||
int row = i % numRows;
|
||||
wordsPane.add(wordEntries.get(i), col, row);
|
||||
}
|
||||
|
||||
vBox.getChildren().add(wordsPane);
|
||||
|
||||
|
||||
+1
-1
@@ -196,7 +196,7 @@ public class MnemonicShareKeystoreImportPane extends MnemonicKeystorePane {
|
||||
importButton = new SplitMenuButton();
|
||||
importButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
importButton.setText("Import Keystore");
|
||||
importButton.getStyleClass().add("default-button");
|
||||
setDefaultButton(importButton);
|
||||
importButton.setOnAction(event -> {
|
||||
importButton.setDisable(true);
|
||||
importKeystore(getDefaultDerivation(), false);
|
||||
|
||||
+9
-3
@@ -134,9 +134,15 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
|
||||
progressIndicator.progressProperty().bind(walletDiscoveryService.progressProperty());
|
||||
walletDiscoveryService.setOnSucceeded(successEvent -> {
|
||||
discoverButton.setGraphic(null);
|
||||
Optional<Wallet> optWallet = walletDiscoveryService.getValue();
|
||||
if(optWallet.isPresent()) {
|
||||
EventManager.get().post(new WalletImportEvent(optWallet.get()));
|
||||
Optional<List<Wallet>> optWallets = walletDiscoveryService.getValue();
|
||||
if(optWallets.isPresent()) {
|
||||
List<Wallet> discoveredWallets = optWallets.get();
|
||||
if(discoveredWallets.size() > 1) {
|
||||
for(Wallet wallet : discoveredWallets) {
|
||||
wallet.setName(wallet.getKeystores().getFirst().getLabel() + " " + wallet.getScriptType().getDescription());
|
||||
}
|
||||
}
|
||||
EventManager.get().post(new WalletImportEvent(discoveredWallets));
|
||||
} else {
|
||||
discoverButton.setDisable(false);
|
||||
Optional<ButtonType> optButtonType = AppServices.showErrorDialog("No existing wallet found",
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.*;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTInput;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTProofException;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
@@ -28,8 +29,6 @@ import javafx.application.Platform;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
@@ -141,6 +140,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
toAddressField.setText("Sweep to:");
|
||||
toAddress = new ComboBoxTextField();
|
||||
toAddress.getStyleClass().add("fixed-width");
|
||||
toAddress.setSkin(new AddressTextFieldSkin(toAddress));
|
||||
toWallet = new ComboBox<>();
|
||||
toWallet.setItems(FXCollections.observableList(AppServices.get().getOpenWallets().keySet().stream()
|
||||
.filter(w -> !w.isWhirlpoolChildWallet() && !w.isBip47()).collect(Collectors.toList())));
|
||||
@@ -461,7 +461,11 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
psbtInput.setFinalScriptWitness(finalizedTxInput.getWitness());
|
||||
}
|
||||
|
||||
setResult(psbt.extractTransaction());
|
||||
try {
|
||||
setResult(psbt.extractTransaction());
|
||||
} catch(PSBTProofException e) {
|
||||
AppServices.showErrorDialog("Invalid Silent Payments Transaction", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public Glyph getGlyph(FontAwesome5.Glyph glyphEnum) {
|
||||
|
||||
@@ -57,7 +57,11 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
||||
|
||||
private final BBQR bbqr;
|
||||
private BBQREncoder bbqrEncoder;
|
||||
private boolean useBbqrEncoding;
|
||||
|
||||
private final String raw;
|
||||
private final boolean rawEncodable;
|
||||
|
||||
private QREncoding encoding = QREncoding.UR;
|
||||
|
||||
private final ImageView qrImageView;
|
||||
|
||||
@@ -72,14 +76,18 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
||||
private static boolean initialDensityChange;
|
||||
|
||||
public QRDisplayDialog(String type, byte[] data, boolean addLegacyEncodingOption) throws UR.URException {
|
||||
this(UR.fromBytes(type, data), null, addLegacyEncodingOption, false, false);
|
||||
this(UR.fromBytes(type, data), null, addLegacyEncodingOption, false, QREncoding.UR);
|
||||
}
|
||||
|
||||
public QRDisplayDialog(UR ur) {
|
||||
this(ur, null, false, false, false);
|
||||
this(ur, null, false, false, QREncoding.UR);
|
||||
}
|
||||
|
||||
public QRDisplayDialog(UR ur, BBQR bbqr, boolean addLegacyEncodingOption, boolean addScanButton, boolean selectBbqrButton) {
|
||||
public QRDisplayDialog(UR ur, BBQR bbqr, boolean addLegacyEncodingOption, boolean addScanButton, QREncoding defaultEncoding) {
|
||||
this(ur, bbqr, null, addLegacyEncodingOption, addScanButton, defaultEncoding);
|
||||
}
|
||||
|
||||
public QRDisplayDialog(UR ur, BBQR bbqr, String raw, boolean addLegacyEncodingOption, boolean addScanButton, QREncoding defaultEncoding) {
|
||||
this.ur = ur;
|
||||
this.bbqr = bbqr;
|
||||
this.addLegacyEncodingOption = bbqr == null && addLegacyEncodingOption;
|
||||
@@ -88,11 +96,14 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
||||
|
||||
if(bbqr != null) {
|
||||
this.bbqrEncoder = new BBQREncoder(bbqr.type(), DEFAULT_BBQR_ENCODING, bbqr.data(), Config.get().getQrDensity().getMaxBbqrFragmentLength(), 0);
|
||||
if(selectBbqrButton) {
|
||||
useBbqrEncoding = true;
|
||||
if(defaultEncoding == QREncoding.BBQR || Config.get().getQrEncoding() == QREncoding.BBQR) {
|
||||
encoding = QREncoding.BBQR;
|
||||
}
|
||||
}
|
||||
|
||||
this.raw = raw;
|
||||
this.rawEncodable = raw != null && getQrCode(raw, true) != null;
|
||||
|
||||
final DialogPane dialogPane = new QRDisplayDialogPane();
|
||||
setDialogPane(dialogPane);
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
@@ -141,6 +152,11 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
||||
dialogPane.getButtonTypes().add(scanButtonType);
|
||||
}
|
||||
|
||||
ButtonBar buttonBar = (ButtonBar)dialogPane.lookup(".button-bar");
|
||||
if(buttonBar != null) {
|
||||
buttonBar.setButtonOrder("L+B+C+O");
|
||||
}
|
||||
|
||||
dialogPane.setPrefWidth(40 + qrSize + 40);
|
||||
dialogPane.setPrefHeight(40 + qrSize + 85);
|
||||
dialogPane.setMinHeight(dialogPane.getPrefHeight());
|
||||
@@ -156,8 +172,10 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
||||
public QRDisplayDialog(String data, boolean addScanButton) {
|
||||
this.ur = null;
|
||||
this.bbqr = null;
|
||||
this.raw = data;
|
||||
this.urEncoder = null;
|
||||
this.bbqrEncoder = null;
|
||||
this.rawEncodable = true;
|
||||
|
||||
final DialogPane dialogPane = new QRDisplayDialogPane();
|
||||
setDialogPane(dialogPane);
|
||||
@@ -205,7 +223,9 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
||||
}
|
||||
|
||||
private boolean isSinglePart() {
|
||||
if(useBbqrEncoding) {
|
||||
if(encoding == QREncoding.RAW) {
|
||||
return true;
|
||||
} else if(encoding == QREncoding.BBQR) {
|
||||
return bbqrEncoder.isSinglePart();
|
||||
} else if(!useLegacyEncoding) {
|
||||
return urEncoder.isSinglePart();
|
||||
@@ -215,7 +235,9 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
||||
}
|
||||
|
||||
private void nextPart() {
|
||||
if(useBbqrEncoding) {
|
||||
if(encoding == QREncoding.RAW) {
|
||||
currentPart = raw;
|
||||
} else if(encoding == QREncoding.BBQR) {
|
||||
String fragment = bbqrEncoder.nextPart();
|
||||
currentPart = fragment.toUpperCase(Locale.ROOT);
|
||||
} else if(!useLegacyEncoding) {
|
||||
@@ -231,6 +253,10 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
||||
}
|
||||
|
||||
protected Image getQrCode(String fragment) {
|
||||
return getQrCode(fragment, false);
|
||||
}
|
||||
|
||||
protected Image getQrCode(String fragment, boolean trial) {
|
||||
try {
|
||||
QRCodeWriter qrCodeWriter = new QRCodeWriter();
|
||||
BitMatrix qrMatrix = qrCodeWriter.encode(fragment, BarcodeFormat.QR_CODE, qrSize, qrSize, Map.of(EncodeHintType.MARGIN, "2"));
|
||||
@@ -241,7 +267,9 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
||||
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
|
||||
return new Image(bais);
|
||||
} catch(Exception e) {
|
||||
log.error("Error generating QR", e);
|
||||
if(!trial) {
|
||||
log.error("Error generating QR", e);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -265,18 +293,13 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isUseBbqrEncoding() {
|
||||
return useBbqrEncoding;
|
||||
public QREncoding getEncoding() {
|
||||
return encoding;
|
||||
}
|
||||
|
||||
private void setUseBbqrEncoding(boolean useBbqrEncoding) {
|
||||
if(useBbqrEncoding) {
|
||||
this.useBbqrEncoding = true;
|
||||
restartAnimation();
|
||||
} else {
|
||||
this.useBbqrEncoding = false;
|
||||
restartAnimation();
|
||||
}
|
||||
private void setEncoding(QREncoding encoding) {
|
||||
this.encoding = encoding;
|
||||
restartAnimation();
|
||||
}
|
||||
|
||||
private void changeQRDensity() {
|
||||
@@ -373,18 +396,29 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
||||
|
||||
return scanButton;
|
||||
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.BACK_PREVIOUS) {
|
||||
ToggleButton bbqr = new ToggleButton(buttonType.getText());
|
||||
bbqr.setGraphicTextGap(5);
|
||||
bbqr.setGraphic(getGlyph(FontAwesome5.Glyph.QRCODE));
|
||||
bbqr.setSelected(useBbqrEncoding);
|
||||
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
|
||||
ButtonBar.setButtonData(bbqr, buttonData);
|
||||
ComboBox<QREncoding> encodingComboBox = new ComboBox<>();
|
||||
if(ur != null) {
|
||||
encodingComboBox.getItems().add(QREncoding.UR);
|
||||
}
|
||||
if(bbqr != null) {
|
||||
encodingComboBox.getItems().add(QREncoding.BBQR);
|
||||
}
|
||||
if(raw != null) {
|
||||
encodingComboBox.getItems().add(QREncoding.RAW);
|
||||
}
|
||||
|
||||
bbqr.selectedProperty().addListener((observable, oldValue, newValue) -> {
|
||||
setUseBbqrEncoding(newValue);
|
||||
encodingComboBox.setCellFactory(_ -> new QREncodingListCell());
|
||||
encodingComboBox.setButtonCell(new QREncodingButtonCell());
|
||||
encodingComboBox.setValue(encoding);
|
||||
encodingComboBox.setOnAction(_ -> {
|
||||
setEncoding(encodingComboBox.getValue());
|
||||
Config.get().setQrEncoding(encodingComboBox.getValue());
|
||||
});
|
||||
|
||||
return bbqr;
|
||||
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
|
||||
ButtonBar.setButtonData(encodingComboBox, buttonData);
|
||||
|
||||
return encodingComboBox;
|
||||
}
|
||||
|
||||
return super.createButton(buttonType);
|
||||
@@ -399,7 +433,7 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
||||
}
|
||||
|
||||
private void updateDensityButton(Button density) {
|
||||
density.setText(Config.get().getQrDensity() == QRDensity.NORMAL ? "Decrease Density" : "Increase Density");
|
||||
density.setText(Config.get().getQrDensity() == QRDensity.NORMAL ? "Less Dense" : "More Dense");
|
||||
if(Config.get().getQrDensity() == QRDensity.NORMAL) {
|
||||
density.setGraphic(getGlyph(FontAwesome5.Glyph.MAGNIFYING_GLASS_PLUS));
|
||||
} else {
|
||||
@@ -413,4 +447,38 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
||||
glyph.setFontSize(11);
|
||||
return glyph;
|
||||
}
|
||||
|
||||
private class QREncodingListCell extends ListCell<QREncoding> {
|
||||
@Override
|
||||
protected void updateItem(QREncoding item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if(empty || item == null) {
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
setDisable(false);
|
||||
setOpacity(1.0);
|
||||
} else {
|
||||
setText(item.getName() + " Encoding");
|
||||
setGraphic(item.getSVGImage());
|
||||
setGraphicTextGap(8.0d);
|
||||
setDisable(item == QREncoding.RAW && !rawEncodable);
|
||||
setOpacity(isDisabled() ? 0.5 : 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class QREncodingButtonCell extends ListCell<QREncoding> {
|
||||
@Override
|
||||
protected void updateItem(QREncoding item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if(item == null || empty) {
|
||||
setText("");
|
||||
setGraphic(null);
|
||||
} else {
|
||||
setText(item.getName());
|
||||
setGraphic(item.getSVGImage());
|
||||
setGraphicTextGap(8.0d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.Theme;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.Node;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.girod.javafx.svgimage.SVGLoader;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.Locale;
|
||||
|
||||
public enum QREncoding {
|
||||
UR("UR"), BBQR("BBQr"), RAW("Raw");
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(QREncoding.class);
|
||||
|
||||
QREncoding(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
private final String name;
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Node getSVGImage() {
|
||||
try {
|
||||
URL url = AppServices.class.getResource("/image/qrencoding/" + getName().toLowerCase(Locale.ROOT) + "-icon" + (Config.get().getTheme() == Theme.DARK ? "-invert" : "") + ".svg");
|
||||
if(url != null) {
|
||||
return SVGLoader.load(url);
|
||||
} else {
|
||||
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.QRCODE);
|
||||
glyph.setFontSize(12);
|
||||
glyph.setPadding(new Insets(0, 2, 0, 0));
|
||||
return glyph;
|
||||
}
|
||||
} catch(Exception e) {
|
||||
log.error("Could not load QR encoding source image for " + name);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -656,7 +656,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
|
||||
List<ChildNumber> path = cryptoKeypath.getComponents().stream().map(comp -> (IndexPathComponent)comp)
|
||||
.map(comp -> new ChildNumber(comp.getIndex(), comp.isHardened())).collect(Collectors.toList());
|
||||
String fingerprint = cryptoKeypath.getSourceFingerprint() == null ? KeystoreController.DEFAULT_WATCH_ONLY_FINGERPRINT : Utils.bytesToHex(cryptoKeypath.getSourceFingerprint());
|
||||
String fingerprint = cryptoKeypath.getSourceFingerprint() == null ? KeyDerivation.DEFAULT_WATCH_ONLY_FINGERPRINT : Utils.bytesToHex(cryptoKeypath.getSourceFingerprint());
|
||||
return new KeyDerivation(fingerprint, KeyDerivation.writePath(path));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import javafx.application.Platform;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.GridPane;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
|
||||
public class RangeInputDialog extends Dialog<Integer> {
|
||||
private final Spinner<Integer> spinner;
|
||||
|
||||
public RangeInputDialog(int min, int max, int initialValue) {
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
|
||||
setTitle("Select a Value");
|
||||
setHeaderText("Choose a value between " + min + " and " + max);
|
||||
|
||||
Glyph key = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SORT_NUMERIC_DOWN);
|
||||
key.setFontSize(50);
|
||||
key.setPadding(new Insets(0, 0, 0, 10));
|
||||
dialogPane.setGraphic(key);
|
||||
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
|
||||
|
||||
spinner = new Spinner<>();
|
||||
spinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(min, max, initialValue));
|
||||
spinner.setPrefWidth(80);
|
||||
|
||||
GridPane grid = new GridPane();
|
||||
grid.setHgap(10);
|
||||
grid.setVgap(10);
|
||||
grid.setPadding(new Insets(20, 20, 10, 20));
|
||||
|
||||
grid.add(new Label("Enter value between " + min + " and " + max + ":"), 0, 0);
|
||||
grid.add(spinner, 1, 0);
|
||||
|
||||
dialogPane.setContent(grid);
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
|
||||
Platform.runLater(spinner::requestFocus);
|
||||
|
||||
setResultConverter((dialogButton) -> {
|
||||
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
|
||||
return data == ButtonBar.ButtonData.OK_DONE ? spinner.getValue() : null;
|
||||
});
|
||||
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(230);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
}
|
||||
|
||||
public void setValue(int value) {
|
||||
spinner.getValueFactory().setValue(value);
|
||||
}
|
||||
|
||||
public int getValue() {
|
||||
return spinner.getValue();
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,7 @@ public class SearchWalletDialog extends Dialog<Entry> {
|
||||
searchField.setText("Search:");
|
||||
search = TextFields.createClearableTextField();
|
||||
search.setPromptText("Label, address, value or transaction ID");
|
||||
search.setSkin(new AddressTextFieldSkin(search));
|
||||
searchField.getInputs().add(search);
|
||||
|
||||
fieldset.getChildren().addAll(searchField);
|
||||
@@ -113,7 +114,11 @@ public class SearchWalletDialog extends Dialog<Entry> {
|
||||
entryCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Entry> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue());
|
||||
});
|
||||
entryCol.setCellFactory(p -> new SearchEntryCell());
|
||||
entryCol.setCellFactory(p -> {
|
||||
SearchEntryCell searchEntryCell = new SearchEntryCell();
|
||||
searchEntryCell.setSkin(new AddressTreeTableCellSkin<>(searchEntryCell));
|
||||
return searchEntryCell;
|
||||
});
|
||||
String address = walletForms.iterator().next().getNodeEntry(KeyPurpose.RECEIVE).getAddress().toString();
|
||||
entryCol.setMinWidth(TextUtils.computeTextWidth(AppServices.getMonospaceFont(), address, 0.0));
|
||||
results.getColumns().add(entryCol);
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
@@ -182,6 +183,22 @@ public class TitledDescriptionPane extends TitledPane {
|
||||
});
|
||||
}
|
||||
|
||||
protected static void setDefaultButton(ButtonBase button) {
|
||||
button.getStyleClass().add("default-button");
|
||||
if(button instanceof SplitMenuButton splitMenuButton) {
|
||||
for(MenuItem item : splitMenuButton.getItems()) {
|
||||
item.getStyleClass().add("default-button");
|
||||
}
|
||||
splitMenuButton.getItems().addListener((ListChangeListener<MenuItem>) c -> {
|
||||
while(c.next()) {
|
||||
for(MenuItem item : c.getAddedSubList()) {
|
||||
item.getStyleClass().add("default-button");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected static int getAccount(Wallet wallet, KeyDerivation requiredDerivation) {
|
||||
if(wallet == null || requiredDerivation == null) {
|
||||
return 0;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.OsType;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
@@ -474,7 +475,7 @@ public class TransactionDiagram extends GridPane {
|
||||
inputValue = input.getValue();
|
||||
Wallet nodeWallet = walletNode.getWallet();
|
||||
StringJoiner joiner = new StringJoiner("\n");
|
||||
joiner.add("Spending " + getSatsValue(inputValue) + " sats from " + (isFinal() ? nodeWallet.getFullDisplayName() : (nodeWallet.isNested() ? nodeWallet.getDisplayName() : "")) + " " + walletNode);
|
||||
joiner.add("Spending " + getCoinValue(inputValue) + " from " + (isFinal() ? nodeWallet.getFullDisplayName() : (nodeWallet.isNested() ? nodeWallet.getDisplayName() : "")) + " " + walletNode);
|
||||
joiner.add(input.getHashAsString() + ":" + input.getIndex());
|
||||
joiner.add(walletNode.getAddress().toString());
|
||||
if(input.getLabel() != null) {
|
||||
@@ -500,7 +501,7 @@ public class TransactionDiagram extends GridPane {
|
||||
} else if(input instanceof AdditionalBlockTransactionHashIndex additionalReference) {
|
||||
inputValue = input.getValue();
|
||||
StringJoiner joiner = new StringJoiner("\n");
|
||||
joiner.add("Spending " + getSatsValue(inputValue) + " sats from" + (isExpanded() ? ":" : " (click to expand):"));
|
||||
joiner.add("Spending " + getCoinValue(inputValue) + " from" + (isExpanded() ? ":" : " (click to expand):"));
|
||||
for(BlockTransactionHashIndex additionalInput : additionalReference.getAdditionalInputs()) {
|
||||
joiner.add(getInputDescription(additionalInput));
|
||||
}
|
||||
@@ -513,7 +514,7 @@ public class TransactionDiagram extends GridPane {
|
||||
TransactionOutput txOutput = blockTransaction.getTransaction().getOutputs().get((int) input.getIndex());
|
||||
Address fromAddress = txOutput.getScript().getToAddress();
|
||||
inputValue = txOutput.getValue();
|
||||
tooltip.setText("Input of " + getSatsValue(inputValue) + " sats\n" + input.getHashAsString() + ":" + input.getIndex() + (fromAddress != null ? "\n" + fromAddress : ""));
|
||||
tooltip.setText("Input of " + getCoinValue(inputValue) + "\n" + input.getHashAsString() + ":" + input.getIndex() + (fromAddress != null ? "\n" + fromAddress : ""));
|
||||
|
||||
ContextMenu contextMenu = new LabelContextMenu(fromAddress, inputValue);
|
||||
label.setContextMenu(contextMenu);
|
||||
@@ -532,6 +533,7 @@ public class TransactionDiagram extends GridPane {
|
||||
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
|
||||
tooltip.setShowDuration(Duration.INDEFINITE);
|
||||
tooltip.setWrapText(true);
|
||||
tooltip.setSkin(new AddressTooltipSkin(tooltip));
|
||||
Window activeWindow = AppServices.getActiveWindow();
|
||||
if(activeWindow != null) {
|
||||
tooltip.setMaxWidth(activeWindow.getWidth());
|
||||
@@ -571,9 +573,22 @@ public class TransactionDiagram extends GridPane {
|
||||
return input.getLabel() != null && !input.getLabel().isEmpty() ? input.getLabel() : input.getHashAsString().substring(0, 8) + "..:" + input.getIndex();
|
||||
}
|
||||
|
||||
String getSatsValue(long amount) {
|
||||
String getCoinValue(long amount) {
|
||||
if(Config.get().isHideAmounts()) {
|
||||
return CoinLabel.HIDDEN_AMOUNT_TEXT;
|
||||
}
|
||||
|
||||
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
|
||||
return format.formatSatsValue(amount);
|
||||
BitcoinUnit unit = Config.get().getBitcoinUnit();
|
||||
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
|
||||
unit = (amount >= BitcoinUnit.getAutoThreshold() ? BitcoinUnit.BTC : BitcoinUnit.SATOSHIS);
|
||||
}
|
||||
|
||||
if(unit.equals(BitcoinUnit.BTC)) {
|
||||
return format.formatBtcValue(amount) + " BTC";
|
||||
}
|
||||
|
||||
return format.formatSatsValue(amount) + " sats";
|
||||
}
|
||||
|
||||
private Pane getInputsLines(List<Map<BlockTransactionHashIndex, WalletNode>> displayedUtxoSets) {
|
||||
@@ -723,21 +738,26 @@ public class TransactionDiagram extends GridPane {
|
||||
for(Payment payment : displayedPayments) {
|
||||
Glyph outputGlyph = GlyphUtils.getOutputGlyph(walletTx, payment);
|
||||
boolean labelledPayment = outputGlyph.getStyleClass().stream().anyMatch(style -> List.of("premix-icon", "badbank-icon", "whirlpoolfee-icon", "anchor-icon").contains(style)) || payment instanceof AdditionalPayment || payment.getLabel() != null;
|
||||
Label recipientLabel = new Label(payment.getLabel() == null || payment.getType() == Payment.Type.FAKE_MIX || payment.getType() == Payment.Type.MIX ? payment.toString().substring(0, 8) + "..." : payment.getLabel(), outputGlyph);
|
||||
boolean addressLabel = payment.getLabel() == null || payment.getType() == Payment.Type.FAKE_MIX || payment.getType() == Payment.Type.MIX;
|
||||
Label recipientLabel = new Label(addressLabel ? payment.toString().substring(0, 8) + "..." : payment.getLabel(), outputGlyph);
|
||||
recipientLabel.getStyleClass().add("output-label");
|
||||
recipientLabel.getStyleClass().add(labelledPayment ? "payment-label" : "recipient-label");
|
||||
if(addressLabel) {
|
||||
recipientLabel.setSkin(new AddressLabelSkin(recipientLabel));
|
||||
}
|
||||
Wallet toWallet = walletTx.getToWallet(AppServices.get().getOpenWallets().keySet(), payment);
|
||||
WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getAddressNodeMap().get(payment.getAddress()) : null;
|
||||
WalletNode toNode = payment instanceof WalletNodePayment walletNodePayment ? walletNodePayment.getWalletNode() : null;
|
||||
Wallet toBip47Wallet = getBip47SendWallet(payment);
|
||||
DnsPayment dnsPayment = DnsPaymentCache.getDnsPayment(payment);
|
||||
Tooltip recipientTooltip = new Tooltip((toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ")
|
||||
+ getSatsValue(payment.getAmount()) + " sats to "
|
||||
+ getCoinValue(payment.getAmount()) + " to "
|
||||
+ (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (dnsPayment == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : dnsPayment.toString()) : toWallet.getFullDisplayName()) + "\n" + payment.getDisplayAddress())
|
||||
+ (walletTx.isDuplicateAddress(payment) ? " (Duplicate)" : ""));
|
||||
recipientTooltip.getStyleClass().add("recipient-label");
|
||||
recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
|
||||
recipientTooltip.setShowDuration(Duration.INDEFINITE);
|
||||
recipientTooltip.setWrapText(true);
|
||||
recipientTooltip.setSkin(new AddressTooltipSkin(recipientTooltip));
|
||||
Window activeWindow = AppServices.getActiveWindow();
|
||||
if(activeWindow != null) {
|
||||
recipientTooltip.setMaxWidth(activeWindow.getWidth());
|
||||
@@ -778,10 +798,12 @@ public class TransactionDiagram extends GridPane {
|
||||
String changeDesc = changeAddress.toString().substring(0, 8) + "...";
|
||||
Label changeLabel = new Label(changeDesc, overGapLimit ? getChangeWarningGlyph() : getChangeGlyph());
|
||||
changeLabel.getStyleClass().addAll("output-label", "change-label");
|
||||
Tooltip changeTooltip = new Tooltip("Change of " + getSatsValue(changeEntry.getValue()) + " sats to " + changeNode + "\n" + walletTx.getChangeAddress(changeNode).toString() + (overGapLimit ? "\nAddress is beyond the gap limit!" : ""));
|
||||
changeLabel.setSkin(new AddressLabelSkin(changeLabel));
|
||||
Tooltip changeTooltip = new Tooltip("Change of " + getCoinValue(changeEntry.getValue()) + " to " + changeNode + "\n" + walletTx.getChangeAddress(changeNode).toString() + (overGapLimit ? "\nAddress is beyond the gap limit!" : ""));
|
||||
changeTooltip.getStyleClass().add("change-label");
|
||||
changeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
|
||||
changeTooltip.setShowDuration(Duration.INDEFINITE);
|
||||
changeTooltip.setSkin(new AddressTooltipSkin(changeTooltip));
|
||||
changeLabel.setTooltip(changeTooltip);
|
||||
actionBox.getChildren().add(changeLabel);
|
||||
|
||||
@@ -837,7 +859,7 @@ public class TransactionDiagram extends GridPane {
|
||||
Label feeLabel = highFee ? new Label("High Fee", getFeeWarningGlyph()) : new Label("Fee", getFeeGlyph());
|
||||
feeLabel.getStyleClass().addAll("output-label", "fee-label");
|
||||
String percentage = walletTx.getFeePercentage() < 0.0001d ? "<0.01" : String.format("%.2f", walletTx.getFeePercentage() * 100.0);
|
||||
Tooltip feeTooltip = new Tooltip(walletTx.getFee() < 0 ? "Unknown fee" : "Fee of " + getSatsValue(walletTx.getFee()) + " sats (" + percentage + "%)");
|
||||
Tooltip feeTooltip = new Tooltip(walletTx.getFee() < 0 ? "Unknown fee" : "Fee of " + getCoinValue(walletTx.getFee()) + " (" + percentage + "%)");
|
||||
feeTooltip.getStyleClass().add("fee-tooltip");
|
||||
feeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
|
||||
feeTooltip.setShowDuration(Duration.INDEFINITE);
|
||||
|
||||
@@ -90,20 +90,20 @@ public class TransactionDiagramLabel extends HBox {
|
||||
outputLabels.add(mixOutputLabel);
|
||||
}
|
||||
} else if(walletTx.getPayments().size() >= 5 && walletTx.getPayments().stream().mapToLong(Payment::getAmount).distinct().count() <= 1 && walletTx.getWallet() != null
|
||||
&& walletTx.getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX && walletTx.getPayments().stream().anyMatch(walletTx::isConsolidationSend)) {
|
||||
&& walletTx.getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX && !walletTx.getWalletNodePayments().isEmpty()) {
|
||||
OutputLabel remixOutputLabel = getRemixOutputLabel(transactionDiagram, walletTx.getPayments());
|
||||
if(remixOutputLabel != null) {
|
||||
outputLabels.add(remixOutputLabel);
|
||||
}
|
||||
} else {
|
||||
List<Payment> payments = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT && !walletTx.isConsolidationSend(payment)).collect(Collectors.toList());
|
||||
List<Payment> payments = walletTx.getExternalPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT).collect(Collectors.toList());
|
||||
List<OutputLabel> paymentLabels = payments.stream().map(payment -> getOutputLabel(transactionDiagram, payment)).collect(Collectors.toList());
|
||||
if(walletTx.getSelectedUtxos().values().stream().allMatch(Objects::isNull)) {
|
||||
paymentLabels.sort(Comparator.comparingInt(paymentLabel -> (paymentLabel.text.startsWith("Receive") ? 0 : 1)));
|
||||
}
|
||||
outputLabels.addAll(paymentLabels);
|
||||
|
||||
List<Payment> consolidations = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT && walletTx.isConsolidationSend(payment)).collect(Collectors.toList());
|
||||
List<Payment> consolidations = walletTx.getWalletNodePayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT).collect(Collectors.toList());
|
||||
outputLabels.addAll(consolidations.stream().map(consolidation -> getOutputLabel(transactionDiagram, consolidation)).collect(Collectors.toList()));
|
||||
|
||||
List<Payment> mixes = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.MIX || payment.getType() == Payment.Type.FAKE_MIX).collect(Collectors.toList());
|
||||
@@ -147,10 +147,10 @@ public class TransactionDiagramLabel extends HBox {
|
||||
Glyph glyph = GlyphUtils.getOutputGlyph(transactionDiagram.getWalletTransaction(), premixOutput);
|
||||
String text;
|
||||
if(premixOutputs.size() == 1) {
|
||||
text = "Premix transaction with 1 output of " + transactionDiagram.getSatsValue(premixOutput.getAmount()) + " sats";
|
||||
text = "Premix transaction with 1 output of " + transactionDiagram.getCoinValue(premixOutput.getAmount());
|
||||
} else {
|
||||
text = "Premix transaction with " + premixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(premixOutput.getAmount()) + " sats each ("
|
||||
+ transactionDiagram.getSatsValue(total) + " sats)";
|
||||
text = "Premix transaction with " + premixOutputs.size() + " outputs of " + transactionDiagram.getCoinValue(premixOutput.getAmount()) + " each ("
|
||||
+ transactionDiagram.getCoinValue(total) + ")";
|
||||
}
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
@@ -158,7 +158,7 @@ public class TransactionDiagramLabel extends HBox {
|
||||
|
||||
private OutputLabel getBadbankOutputLabel(TransactionDiagram transactionDiagram, Payment payment) {
|
||||
Glyph glyph = GlyphUtils.getOutputGlyph(transactionDiagram.getWalletTransaction(), payment);
|
||||
String text = "Badbank change of " + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment.getAddress().toString();
|
||||
String text = "Badbank change of " + transactionDiagram.getCoinValue(payment.getAmount()) + " to " + payment.getAddress().toString();
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
@@ -167,7 +167,7 @@ public class TransactionDiagramLabel extends HBox {
|
||||
long total = premixOutputs.stream().mapToLong(Payment::getAmount).sum();
|
||||
double feePercentage = (double)whirlpoolFee.getAmount() / (total - whirlpoolFee.getAmount());
|
||||
Glyph glyph = GlyphUtils.getOutputGlyph(transactionDiagram.getWalletTransaction(), whirlpoolFee);
|
||||
String text = "Whirlpool fee of " + transactionDiagram.getSatsValue(whirlpoolFee.getAmount()) + " sats (" + String.format("%.2f", feePercentage * 100.0) + "% of total premix value)";
|
||||
String text = "Whirlpool fee of " + transactionDiagram.getCoinValue(whirlpoolFee.getAmount()) + " (" + String.format("%.2f", feePercentage * 100.0) + "% of total premix value)";
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
@@ -180,8 +180,8 @@ public class TransactionDiagramLabel extends HBox {
|
||||
Payment remixOutput = mixOutputs.get(0);
|
||||
long total = mixOutputs.stream().mapToLong(Payment::getAmount).sum();
|
||||
Glyph glyph = GlyphUtils.getPremixGlyph();
|
||||
String text = "Mix transaction with " + mixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(remixOutput.getAmount()) + " sats each ("
|
||||
+ transactionDiagram.getSatsValue(total) + " sats)";
|
||||
String text = "Mix transaction with " + mixOutputs.size() + " outputs of " + transactionDiagram.getCoinValue(remixOutput.getAmount()) + " each ("
|
||||
+ transactionDiagram.getCoinValue(total) + ")";
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
@@ -194,8 +194,8 @@ public class TransactionDiagramLabel extends HBox {
|
||||
Payment remixOutput = remixOutputs.get(0);
|
||||
long total = remixOutputs.stream().mapToLong(Payment::getAmount).sum();
|
||||
Glyph glyph = GlyphUtils.getPremixGlyph();
|
||||
String text = "Remix transaction with " + remixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(remixOutput.getAmount()) + " sats each ("
|
||||
+ transactionDiagram.getSatsValue(total) + " sats)";
|
||||
String text = "Remix transaction with " + remixOutputs.size() + " outputs of " + transactionDiagram.getCoinValue(remixOutput.getAmount()) + " each ("
|
||||
+ transactionDiagram.getCoinValue(total) + ")";
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
@@ -203,10 +203,10 @@ public class TransactionDiagramLabel extends HBox {
|
||||
private OutputLabel getOutputLabel(TransactionDiagram transactionDiagram, Payment payment) {
|
||||
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
|
||||
Wallet toWallet = walletTx.getToWallet(AppServices.get().getOpenWallets().keySet(), payment);
|
||||
WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getAddressNodeMap().get(payment.getAddress()) : null;
|
||||
WalletNode toNode = payment instanceof WalletNodePayment walletNodePayment ? walletNodePayment.getWalletNode() : null;
|
||||
|
||||
Glyph glyph = GlyphUtils.getOutputGlyph(transactionDiagram.getWalletTransaction(), payment);
|
||||
String text = (toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ") + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment;
|
||||
String text = (toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ") + transactionDiagram.getCoinValue(payment.getAmount()) + " to " + payment;
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
@@ -215,7 +215,7 @@ public class TransactionDiagramLabel extends HBox {
|
||||
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
|
||||
|
||||
Glyph glyph = GlyphUtils.getChangeGlyph();
|
||||
String text = "Change of " + transactionDiagram.getSatsValue(changeEntry.getValue()) + " sats to " + walletTx.getChangeAddress(changeEntry.getKey()).toString();
|
||||
String text = "Change of " + transactionDiagram.getCoinValue(changeEntry.getValue()) + " to " + walletTx.getChangeAddress(changeEntry.getKey()).toString();
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
@@ -228,7 +228,7 @@ public class TransactionDiagramLabel extends HBox {
|
||||
|
||||
Glyph glyph = GlyphUtils.getFeeGlyph();
|
||||
String percentage = walletTx.getFeePercentage() < 0.0001d ? "<0.01" : String.format("%.2f", walletTx.getFeePercentage() * 100.0);
|
||||
String text = "Fee of " + transactionDiagram.getSatsValue(walletTx.getFee()) + " sats (" + percentage + "%)";
|
||||
String text = "Fee of " + transactionDiagram.getCoinValue(walletTx.getFee()) + " (" + percentage + "%)";
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
@@ -240,6 +240,7 @@ public class TransactionDiagramLabel extends HBox {
|
||||
icon.setGraphic(glyph);
|
||||
|
||||
CopyableLabel label = new CopyableLabel();
|
||||
label.setSkin(new AddressTextFieldSkin(label));
|
||||
label.setFont(Font.font("Fragment Mono Italic", 13));
|
||||
label.setText(text);
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ public class UsbStatusButton extends MenuButton {
|
||||
for(Device device : devices) {
|
||||
MenuItem deviceItem = new MenuItem(device.getModel().toDisplayString());
|
||||
if(!device.isNeedsPinSent() && (device.getModel() == WalletModel.TREZOR_1 || device.getModel() == WalletModel.TREZOR_T || device.getModel() == WalletModel.TREZOR_SAFE_3 ||
|
||||
device.getModel() == WalletModel.TREZOR_SAFE_5 || device.getModel() == WalletModel.KEEPKEY || device.getModel() == WalletModel.BITBOX_02 ||
|
||||
device.getModel() == WalletModel.TREZOR_SAFE_5 || device.getModel() == WalletModel.TREZOR_SAFE_7 || device.getModel() == WalletModel.KEEPKEY || device.getModel() == WalletModel.BITBOX_02 ||
|
||||
device.getModel() == WalletModel.ONEKEY_CLASSIC_1S || device.getModel() == WalletModel.ONEKEY_PRO)) {
|
||||
deviceItem = new Menu(device.getModel().toDisplayString());
|
||||
MenuItem toggleItem = new MenuItem("Toggle Passphrase" + (!device.getModel().externalPassphraseEntry() ? "" : (device.isNeedsPassphraseSent() ? " Off" : " On")));
|
||||
|
||||
@@ -90,6 +90,10 @@ public class UtxosChart extends BarChart<String, Number> {
|
||||
private void installTooltip(XYChart.Data<String, Number> item) {
|
||||
Tooltip.uninstall(item.getNode(), null);
|
||||
|
||||
if(Config.get().isHideAmounts()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String satsValue = String.format(Locale.ENGLISH, "%,d", item.getYValue());
|
||||
Tooltip tooltip = new Tooltip(item.getXValue() + "\n" + satsValue + " sats");
|
||||
tooltip.setShowDelay(Duration.millis(TOOLTIP_SHOW_DELAY));
|
||||
@@ -129,4 +133,21 @@ public class UtxosChart extends BarChart<String, Number> {
|
||||
NumberAxis yaxis = (NumberAxis)getYAxis();
|
||||
yaxis.setTickLabelFormatter(new CoinAxisFormatter(yaxis, format, unit));
|
||||
}
|
||||
|
||||
public void refreshAxisLabels() {
|
||||
NumberAxis yaxis = (NumberAxis)getYAxis();
|
||||
// Force the axis to redraw by invalidating the upper and lower bounds
|
||||
yaxis.setAutoRanging(false);
|
||||
double lower = yaxis.getLowerBound();
|
||||
double upper = yaxis.getUpperBound();
|
||||
yaxis.setLowerBound(lower);
|
||||
yaxis.setUpperBound(upper);
|
||||
yaxis.setAutoRanging(true);
|
||||
}
|
||||
|
||||
public void refreshTooltips() {
|
||||
for(XYChart.Data<String, Number> data : utxoSeries.getData()) {
|
||||
installTooltip(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,11 @@ public class UtxosTreeTable extends CoinTreeTable {
|
||||
addressCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, UtxoEntry.AddressStatus> param) -> {
|
||||
return ((UtxoEntry)param.getValue().getValue()).addressStatusProperty();
|
||||
});
|
||||
addressCol.setCellFactory(p -> new AddressCell());
|
||||
addressCol.setCellFactory(p -> {
|
||||
AddressCell addressCell = new AddressCell();
|
||||
addressCell.setSkin(new AddressTreeTableCellSkin<>(addressCell));
|
||||
return addressCell;
|
||||
});
|
||||
addressCol.setSortable(true);
|
||||
addressCol.setComparator(Comparator.comparing(o -> o.getAddress().toString()));
|
||||
getColumns().add(addressCol);
|
||||
|
||||
@@ -21,8 +21,8 @@ import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
public class WalletImportDialog extends Dialog<Wallet> {
|
||||
private Wallet wallet;
|
||||
public class WalletImportDialog extends Dialog<List<Wallet>> {
|
||||
private List<Wallet> wallets;
|
||||
private final Accordion importAccordion;
|
||||
private final Button scanButton;
|
||||
|
||||
@@ -52,7 +52,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
|
||||
|
||||
importAccordion = new Accordion();
|
||||
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(),
|
||||
new GordianSeedTool(), new SeedSigner(), new SpecterDIY(), new Krux(), new AirGapVault(), new Samourai());
|
||||
new GordianSeedTool(), new SeedSigner(), new SpecterDIY(), new Krux(), new AirGapVault(), new Samourai(), new KeycardShellSinglesig());
|
||||
for(KeystoreFileImport importer : keystoreImporters) {
|
||||
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
|
||||
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
|
||||
@@ -95,13 +95,13 @@ public class WalletImportDialog extends Dialog<Wallet> {
|
||||
dialogPane.setMinHeight(dialogPane.getPrefHeight());
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallet : null);
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallets : null);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletImported(WalletImportEvent event) {
|
||||
wallet = event.getWallet();
|
||||
setResult(wallet);
|
||||
wallets = event.getWallets();
|
||||
setResult(wallets);
|
||||
}
|
||||
|
||||
private void scan() {
|
||||
@@ -112,7 +112,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
|
||||
List<Device> devices = enumerateService.getValue();
|
||||
importAccordion.getPanes().removeIf(titledPane -> titledPane instanceof DevicePane);
|
||||
for(Device device : devices) {
|
||||
DevicePane devicePane = new DevicePane(new Wallet(), device, devices.size() == 1, null);
|
||||
DevicePane devicePane = new DevicePane(new Wallet(), device, devices.size() == 1, null, null);
|
||||
importAccordion.getPanes().add(0, devicePane);
|
||||
}
|
||||
Platform.runLater(() -> EventManager.get().post(new UsbDeviceEvent(devices)));
|
||||
|
||||
@@ -73,7 +73,7 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
|
||||
name = (CustomTextField)TextFields.createClearableTextField();
|
||||
name.setText(initialName);
|
||||
name.setTextFormatter(new TextFormatter<>((change) -> {
|
||||
change.setText(change.getText().replaceAll("[\\\\/:*?\"<>|]", "_"));
|
||||
change.setText(change.getText().replaceAll("[\\\\/:*?\"<>|;]", "_"));
|
||||
return change;
|
||||
}));
|
||||
content.getChildren().add(name);
|
||||
|
||||
@@ -7,6 +7,7 @@ public enum WebcamPixelFormat {
|
||||
PIX_FMT_RGB24("RGB3", true),
|
||||
PIX_FMT_YUYV("YUYV", true),
|
||||
PIX_FMT_NV12("NV12", true),
|
||||
PIX_FMT_YU12("YU12", true),
|
||||
PIX_FMT_MJPG("MJPG", true);
|
||||
|
||||
private final String name;
|
||||
|
||||
@@ -36,7 +36,7 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
|
||||
private ExtendedKey xprv;
|
||||
|
||||
public XprvKeystoreImportPane(Wallet wallet, KeystoreXprvImport importer, KeyDerivation defaultDerivation) {
|
||||
super(importer.getName(), "Extended key import", importer.getKeystoreImportDescription(), importer.getWalletModel());
|
||||
super(importer.getName(), "Enter master private key", importer.getKeystoreImportDescription(), importer.getWalletModel());
|
||||
this.wallet = wallet;
|
||||
this.importer = importer;
|
||||
this.defaultDerivation = defaultDerivation;
|
||||
@@ -79,7 +79,7 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
|
||||
importButton = new SplitMenuButton();
|
||||
importButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
importButton.setText("Import Keystore");
|
||||
importButton.getStyleClass().add("default-button");
|
||||
setDefaultButton(importButton);
|
||||
importButton.setOnAction(event -> {
|
||||
importButton.setDisable(true);
|
||||
importKeystore(getDefaultDerivation());
|
||||
@@ -151,6 +151,7 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
|
||||
Button importXprvButton = new Button("Import");
|
||||
importXprvButton.setMinWidth(80);
|
||||
importXprvButton.setDisable(true);
|
||||
importXprvButton.setDefaultButton(true);
|
||||
importXprvButton.setOnAction(event -> {
|
||||
enterXprvButton.setVisible(false);
|
||||
importButton.setVisible(true);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
public class HideAmountsStatusEvent {
|
||||
private final boolean hideAmounts;
|
||||
|
||||
public HideAmountsStatusEvent(boolean hideAmounts) {
|
||||
this.hideAmounts = hideAmounts;
|
||||
}
|
||||
|
||||
public boolean isHideAmounts() {
|
||||
return hideAmounts;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransaction;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.UnitFormat;
|
||||
import com.sparrowwallet.sparrow.control.CoinLabel;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
|
||||
@@ -48,6 +49,10 @@ public class NewWalletTransactionsEvent {
|
||||
}
|
||||
|
||||
public String getValueAsText(long value) {
|
||||
if(Config.get().isHideAmounts()) {
|
||||
return CoinLabel.HIDDEN_AMOUNT_TEXT;
|
||||
}
|
||||
|
||||
UnitFormat format = Config.get().getUnitFormat();
|
||||
if(format == null) {
|
||||
format = UnitFormat.DOT;
|
||||
|
||||
@@ -2,14 +2,20 @@ package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class WalletImportEvent {
|
||||
private Wallet wallet;
|
||||
private List<Wallet> wallets;
|
||||
|
||||
public WalletImportEvent(Wallet wallet) {
|
||||
this.wallet = wallet;
|
||||
this.wallets = List.of(wallet);
|
||||
}
|
||||
|
||||
public Wallet getWallet() {
|
||||
return wallet;
|
||||
public WalletImportEvent(List<Wallet> wallets) {
|
||||
this.wallets = wallets;
|
||||
}
|
||||
|
||||
public List<Wallet> getWallets() {
|
||||
return wallets;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.sparrowwallet.sparrow.glyphfont;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Payment;
|
||||
import com.sparrowwallet.drongo.wallet.WalletNodePayment;
|
||||
import com.sparrowwallet.drongo.wallet.WalletTransaction;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.control.TransactionDiagram;
|
||||
@@ -15,7 +16,7 @@ public class GlyphUtils {
|
||||
return getFakeMixGlyph();
|
||||
} else if(payment.getType().equals(Payment.Type.ANCHOR)) {
|
||||
return getAnchorGlyph();
|
||||
} else if(walletTx.isConsolidationSend(payment)) {
|
||||
} else if(payment instanceof WalletNodePayment) {
|
||||
return getConsolidationGlyph();
|
||||
} else if(walletTx.isPremixSend(payment)) {
|
||||
return getPremixGlyph();
|
||||
|
||||
@@ -197,6 +197,7 @@ public abstract class Instance {
|
||||
File sparrowHome = Storage.getSparrowHome(true);
|
||||
if(!sparrowHome.exists()) {
|
||||
Storage.createOwnerOnlyDirectory(sparrowHome);
|
||||
sparrowHome.deleteOnExit(); //Will only delete on exit if empty
|
||||
}
|
||||
|
||||
return sparrowHome.toPath().resolve(applicationId + ".default");
|
||||
|
||||
@@ -21,7 +21,7 @@ public class Bip32 implements KeystoreXprvImport {
|
||||
|
||||
@Override
|
||||
public String getKeystoreImportDescription() {
|
||||
return "Import an extended master private key (BIP 32 xprv)";
|
||||
return "Import an extended master private key (BIP 32 xprv).";
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.crypto.DeterministicKey;
|
||||
import com.sparrowwallet.drongo.crypto.HDKeyDerivation;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.MasterPrivateExtendedKey;
|
||||
import com.sparrowwallet.drongo.wallet.MnemonicException;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.drongo.wallet.bip93.Codex32;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class Bip93 implements KeystoreCodexImport {
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Codex32 (BIP93)";
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletModel getWalletModel() {
|
||||
return WalletModel.SEED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeystoreImportDescription() {
|
||||
return "Import your Codex32 secret share. Only a single share is currently supported.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Keystore getKeystore(List<ChildNumber> derivation, String secretShare) throws ImportException {
|
||||
try {
|
||||
Codex32.Codex32Data secretData = Codex32.decode(secretShare);
|
||||
DeterministicKey key = HDKeyDerivation.createMasterPrivateKey(secretData.payloadToBip32Secret());
|
||||
MasterPrivateExtendedKey mpek = new MasterPrivateExtendedKey(key);
|
||||
Keystore keystore = Keystore.fromMasterPrivateExtendedKey(mpek, derivation);
|
||||
keystore.setLabel("BIP93");
|
||||
return keystore;
|
||||
} catch(MnemonicException e) {
|
||||
throw new ImportException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.io.ckcard.CkCardApi;
|
||||
import com.sparrowwallet.sparrow.io.keycard.KeycardApi;
|
||||
import com.sparrowwallet.sparrow.io.satochip.SatoCardApi;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.concurrent.Service;
|
||||
@@ -58,6 +59,13 @@ public abstract class CardApi {
|
||||
//ignore
|
||||
}
|
||||
|
||||
try {
|
||||
KeycardApi keycardApi = new KeycardApi(null, null);
|
||||
cards.add(keycardApi.getCardType());
|
||||
} catch(Exception e) {
|
||||
//ignore
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
@@ -70,6 +78,10 @@ public abstract class CardApi {
|
||||
return new SatoCardApi(walletModel, pin);
|
||||
}
|
||||
|
||||
if(walletModel == WalletModel.KEYCARD) {
|
||||
return new KeycardApi(walletModel, pin);
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Cannot create card API for " + walletModel.toDisplayString());
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.sparrowwallet.sparrow.UnitFormat;
|
||||
import com.sparrowwallet.sparrow.Mode;
|
||||
import com.sparrowwallet.sparrow.Theme;
|
||||
import com.sparrowwallet.sparrow.control.QRDensity;
|
||||
import com.sparrowwallet.sparrow.control.QREncoding;
|
||||
import com.sparrowwallet.sparrow.control.WebcamResolution;
|
||||
import com.sparrowwallet.sparrow.net.*;
|
||||
import com.sparrowwallet.sparrow.wallet.FeeRatesSelection;
|
||||
@@ -46,7 +47,9 @@ public class Config {
|
||||
private boolean checkNewVersions = true;
|
||||
private Theme theme;
|
||||
private boolean openWalletsInNewWindows = false;
|
||||
private boolean chunkAddresses = true;
|
||||
private boolean hideEmptyUsedAddresses = false;
|
||||
private boolean hideAmounts = false;
|
||||
private boolean showTransactionHex = true;
|
||||
private boolean showLoadingLog = true;
|
||||
private boolean showAddressTransactionCount = false;
|
||||
@@ -56,11 +59,14 @@ public class Config {
|
||||
private Boolean connectToBroadcast;
|
||||
private Boolean connectToResolve;
|
||||
private Boolean suggestSendToMany;
|
||||
private Boolean suggestChangeWalletsDir;
|
||||
private File walletsDir;
|
||||
private List<File> recentWalletFiles;
|
||||
private Integer keyDerivationPeriod;
|
||||
private long dustAttackThreshold = DUST_ATTACK_THRESHOLD_SATS;
|
||||
private int enumerateHwPeriod = ENUMERATE_HW_PERIOD_SECS;
|
||||
private QRDensity qrDensity;
|
||||
private QREncoding qrEncoding;
|
||||
private WebcamResolution webcamResolution;
|
||||
private boolean mirrorCapture = true;
|
||||
private boolean useZbar = true;
|
||||
@@ -294,6 +300,15 @@ public class Config {
|
||||
flush();
|
||||
}
|
||||
|
||||
public boolean isChunkAddresses() {
|
||||
return chunkAddresses;
|
||||
}
|
||||
|
||||
public void setChunkAddresses(boolean chunkAddresses) {
|
||||
this.chunkAddresses = chunkAddresses;
|
||||
flush();
|
||||
}
|
||||
|
||||
public boolean isHideEmptyUsedAddresses() {
|
||||
return hideEmptyUsedAddresses;
|
||||
}
|
||||
@@ -303,6 +318,15 @@ public class Config {
|
||||
flush();
|
||||
}
|
||||
|
||||
public boolean isHideAmounts() {
|
||||
return hideAmounts;
|
||||
}
|
||||
|
||||
public void setHideAmounts(boolean hideAmounts) {
|
||||
this.hideAmounts = hideAmounts;
|
||||
flush();
|
||||
}
|
||||
|
||||
public boolean isShowTransactionHex() {
|
||||
return showTransactionHex;
|
||||
}
|
||||
@@ -384,6 +408,24 @@ public class Config {
|
||||
flush();
|
||||
}
|
||||
|
||||
public Boolean getSuggestChangeWalletsDir() {
|
||||
return suggestChangeWalletsDir;
|
||||
}
|
||||
|
||||
public void setSuggestChangeWalletsDir(Boolean suggestChangeWalletsDir) {
|
||||
this.suggestChangeWalletsDir = suggestChangeWalletsDir;
|
||||
flush();
|
||||
}
|
||||
|
||||
public File getWalletsDir() {
|
||||
return walletsDir;
|
||||
}
|
||||
|
||||
public void setWalletsDir(File walletsDir) {
|
||||
this.walletsDir = walletsDir;
|
||||
flush();
|
||||
}
|
||||
|
||||
public List<File> getRecentWalletFiles() {
|
||||
return recentWalletFiles;
|
||||
}
|
||||
@@ -419,6 +461,15 @@ public class Config {
|
||||
flush();
|
||||
}
|
||||
|
||||
public QREncoding getQrEncoding() {
|
||||
return qrEncoding;
|
||||
}
|
||||
|
||||
public void setQrEncoding(QREncoding qrEncoding) {
|
||||
this.qrEncoding = qrEncoding;
|
||||
flush();
|
||||
}
|
||||
|
||||
public WebcamResolution getWebcamResolution() {
|
||||
return webcamResolution;
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ public class Descriptor implements WalletImport, WalletExport {
|
||||
private static Wallet ensureKeyDerivations(Wallet wallet) {
|
||||
for(Keystore keystore : wallet.getKeystores()) {
|
||||
if(keystore.getKeyDerivation().getMasterFingerprint() == null || keystore.getKeyDerivation().getDerivationPath() == null) {
|
||||
keystore.setKeyDerivation(new KeyDerivation(KeystoreController.DEFAULT_WATCH_ONLY_FINGERPRINT, wallet.getScriptType().getDefaultDerivationPath()));
|
||||
keystore.setKeyDerivation(new KeyDerivation(KeyDerivation.DEFAULT_WATCH_ONLY_FINGERPRINT, wallet.getScriptType().getDefaultDerivationPath()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,13 +11,23 @@ import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.lark.DeviceException;
|
||||
import com.sparrowwallet.lark.Lark;
|
||||
import com.sparrowwallet.lark.bitbox02.BitBoxFileNoiseConfig;
|
||||
import com.sparrowwallet.lark.trezor.TrezorFileNoiseConfig;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.SparrowWallet;
|
||||
import com.sparrowwallet.sparrow.control.BitBoxPairingDialog;
|
||||
import com.sparrowwallet.sparrow.control.TextfieldDialog;
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.MapChangeListener;
|
||||
import javafx.collections.ObservableMap;
|
||||
import javafx.concurrent.ScheduledService;
|
||||
import javafx.concurrent.Service;
|
||||
import javafx.concurrent.Task;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.TextFormatter;
|
||||
import javafx.scene.layout.HBox;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -26,7 +36,9 @@ import javax.smartcardio.CardNotPresentException;
|
||||
import java.io.File;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public class Hwi {
|
||||
@@ -34,8 +46,9 @@ public class Hwi {
|
||||
private static final String HWI_HOME_DIR = "hwi";
|
||||
private static final String LARK_HOME_DIR = "lark";
|
||||
private static final String BITBOX_FILENAME = "bitbox02.json";
|
||||
private static final String TREZOR_FILENAME = "trezor.json";
|
||||
|
||||
private static boolean isPromptActive = false;
|
||||
private static volatile boolean isPromptActive = false;
|
||||
|
||||
private final Set<byte[]> newDeviceRegistrations = new HashSet<>();
|
||||
|
||||
@@ -83,7 +96,7 @@ public class Hwi {
|
||||
} catch(CardNotPresentException e) {
|
||||
//ignore
|
||||
} catch(CardException e) {
|
||||
log.error("Error reading card", e);
|
||||
log.info("Error reading card", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,9 +145,8 @@ public class Hwi {
|
||||
}
|
||||
}
|
||||
|
||||
public Map<StandardAccount, String> getXpubs(Device device, String passphrase, Map<StandardAccount, String> accountDerivationPaths) throws ImportException {
|
||||
Map<StandardAccount, String> accountXpubs = new LinkedHashMap<>();
|
||||
for(Map.Entry<StandardAccount, String> entry : accountDerivationPaths.entrySet()) {
|
||||
public Map<WalletType, String> getXpubs(Device device, String passphrase, Map<WalletType, String> accountDerivationPaths, Map<WalletType, String> accountXpubs) throws ImportException {
|
||||
for(Map.Entry<WalletType, String> entry : accountDerivationPaths.entrySet()) {
|
||||
accountXpubs.put(entry.getKey(), getXpub(device, passphrase, entry.getValue()));
|
||||
}
|
||||
|
||||
@@ -223,6 +235,7 @@ public class Hwi {
|
||||
private Lark getLark(String passphrase, OutputDescriptor walletDescriptor, String walletName, byte[] walletRegistration) {
|
||||
Lark lark = new Lark(AppServices.getHttpClientService());
|
||||
lark.setBitBoxNoiseConfig(new BitBoxFxNoiseConfig());
|
||||
lark.setTrezorNoiseConfig(new TrezorFxNoiseConfig());
|
||||
if(passphrase != null) {
|
||||
lark.setPassphrase(passphrase);
|
||||
}
|
||||
@@ -430,23 +443,26 @@ public class Hwi {
|
||||
}
|
||||
}
|
||||
|
||||
public static class GetXpubsService extends Service<Map<StandardAccount, String>> {
|
||||
public static class GetXpubsService extends Service<Map<WalletType, String>> {
|
||||
private final Device device;
|
||||
private final String passphrase;
|
||||
private final Map<StandardAccount, String> accountDerivationPaths;
|
||||
private final Map<WalletType, String> accountDerivationPaths;
|
||||
|
||||
public GetXpubsService(Device device, String passphrase, Map<StandardAccount, String> accountDerivationPaths) {
|
||||
public GetXpubsService(Device device, String passphrase, Map<WalletType, String> accountDerivationPaths) {
|
||||
this.device = device;
|
||||
this.passphrase = passphrase;
|
||||
this.accountDerivationPaths = accountDerivationPaths;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<Map<StandardAccount, String>> createTask() {
|
||||
protected Task<Map<WalletType, String>> createTask() {
|
||||
return new Task<>() {
|
||||
protected Map<StandardAccount, String> call() throws ImportException {
|
||||
protected Map<WalletType, String> call() throws ImportException {
|
||||
Hwi hwi = new Hwi();
|
||||
return hwi.getXpubs(device, passphrase, accountDerivationPaths);
|
||||
updateProgress(0, accountDerivationPaths.size());
|
||||
ObservableMap<WalletType, String> accountXpubs = FXCollections.observableMap(new LinkedHashMap<>());
|
||||
accountXpubs.addListener((MapChangeListener<? super WalletType, ? super String>) _ -> updateProgress(accountXpubs.size(), accountDerivationPaths.size()));
|
||||
return hwi.getXpubs(device, passphrase, accountDerivationPaths, accountXpubs);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -536,4 +552,89 @@ public class Hwi {
|
||||
return confirmedDevice.get();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class TrezorFxNoiseConfig extends TrezorFileNoiseConfig {
|
||||
private String deviceInfo;
|
||||
|
||||
public TrezorFxNoiseConfig() {
|
||||
super(Path.of(Storage.getSparrowHome().getAbsolutePath(), LARK_HOME_DIR, TREZOR_FILENAME).toFile());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String promptForPairingCode() {
|
||||
CompletableFuture<String> future = new CompletableFuture<>();
|
||||
Platform.runLater(() -> {
|
||||
TextfieldDialog textfieldDialog = new TextfieldDialog();
|
||||
textfieldDialog.initOwner(AppServices.getActiveWindow());
|
||||
textfieldDialog.setTitle("Enter Pairing Code");
|
||||
textfieldDialog.setHeaderText("Enter the code shown on the " + deviceInfo + ":");
|
||||
textfieldDialog.getDialogPane().setPrefWidth(300);
|
||||
textfieldDialog.getEditor().setOnAction(_ -> textfieldDialog.setResult(textfieldDialog.getEditor().getText()));
|
||||
textfieldDialog.getEditor().setTextFormatter(new TextFormatter<>(change -> {
|
||||
String newText = change.getControlNewText();
|
||||
if(newText.matches("\\d*")) {
|
||||
return change;
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
textfieldDialog.getEditor().setStyle("-fx-font-size: 30px;");
|
||||
HBox.setMargin(textfieldDialog.getEditor(), new Insets(0, 65, 0, 65));
|
||||
textfieldDialog.getEditor().requestFocus();
|
||||
textfieldDialog.showAndWait().ifPresentOrElse(future::complete, () -> future.complete(null));
|
||||
});
|
||||
|
||||
try {
|
||||
isPromptActive = true;
|
||||
return future.get(); // Block until dialog is closed
|
||||
} catch (InterruptedException | ExecutionException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
} finally {
|
||||
isPromptActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean confirmPairing(String deviceInfo) {
|
||||
this.deviceInfo = deviceInfo;
|
||||
CompletableFuture<ButtonType> future = new CompletableFuture<>();
|
||||
Platform.runLater(() -> {
|
||||
AppServices.showAlertDialog("Pairing Required", "Pair the " + deviceInfo + " with " + SparrowWallet.APP_NAME + "?",
|
||||
Alert.AlertType.CONFIRMATION, ButtonType.YES, ButtonType.NO).ifPresentOrElse(future::complete, () -> future.complete(null));
|
||||
});
|
||||
|
||||
try {
|
||||
isPromptActive = true;
|
||||
return future.get() == ButtonType.YES; // Block until dialog is closed
|
||||
} catch (InterruptedException | ExecutionException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
} finally {
|
||||
isPromptActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void displayPairingCode(String code) {
|
||||
super.displayPairingCode(code);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAppName() {
|
||||
return SparrowWallet.APP_NAME;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pairingFailed(String reason) {
|
||||
Platform.runLater(() -> AppServices.showErrorDialog("Pairing Failed", "Pairing failed: " + reason));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pairingSuccessful(String deviceInfo) {
|
||||
this.deviceInfo = deviceInfo;
|
||||
Platform.runLater(() -> AppServices.showSuccessDialog("Pairing Successful", "The " + deviceInfo + " has been successfully paired."));
|
||||
}
|
||||
}
|
||||
|
||||
public record WalletType(ScriptType scriptType, StandardAccount standardAccount) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
public class KeycardShellMultisig extends ColdcardMultisig {
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Keycard Shell Multisig";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeystoreImportDescription(int account) {
|
||||
return "Import QR created on your Keycard Shell by selecting Connect software wallet > Bitcoin MultiSig";
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletModel getWalletModel() {
|
||||
return WalletModel.KEYCARD_SHELL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
|
||||
Keystore keystore = super.getKeystore(scriptType, inputStream, password);
|
||||
keystore.setLabel("Keycard Shell");
|
||||
keystore.setWalletModel(getWalletModel());
|
||||
|
||||
return keystore;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFileFormatAvailable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWalletImportFileFormatAvailable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWalletImportScannable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWalletExportScannable() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
public class KeycardShellSinglesig extends KeystoneSinglesig {
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Keycard Shell";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeystoreImportDescription(int account) {
|
||||
return "Import QR created on your Keycard Shell by selecting Connect software wallet > Bitcoin" + (Network.getCanonical() == Network.TESTNET ? " Testnet" : "");
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletModel getWalletModel() {
|
||||
return WalletModel.KEYCARD_SHELL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
|
||||
Keystore keystore = super.getKeystore(scriptType, inputStream, password);
|
||||
keystore.setLabel("Keycard Shell");
|
||||
keystore.setWalletModel(getWalletModel());
|
||||
|
||||
return keystore;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFileFormatAvailable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWalletImportFileFormatAvailable() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface KeystoreCodexImport extends KeystoreImport {
|
||||
Keystore getKeystore(List<ChildNumber> derivation, String secretShare) throws ImportException;
|
||||
}
|
||||
@@ -29,10 +29,7 @@ public class Samourai implements KeystoreFileImport {
|
||||
try {
|
||||
String input = CharStreams.toString(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
|
||||
|
||||
Gson gson = new Gson();
|
||||
Type stringStringMap = new TypeToken<Map<String, JsonElement>>() {
|
||||
}.getType();
|
||||
Map<String, JsonElement> map = gson.fromJson(input, stringStringMap);
|
||||
Map<String, JsonElement> map = this.parseJsonInput(input);
|
||||
|
||||
String payload = input;
|
||||
if(map.containsKey("payload")) {
|
||||
@@ -53,7 +50,7 @@ public class Samourai implements KeystoreFileImport {
|
||||
throw new ImportException("Unsupported backup version: " + version);
|
||||
}
|
||||
|
||||
SamouraiBackup backup = gson.fromJson(decrypted, SamouraiBackup.class);
|
||||
SamouraiBackup backup = new Gson().fromJson(decrypted, SamouraiBackup.class);
|
||||
DeterministicSeed seed = new DeterministicSeed(Utils.hexToBytes(backup.wallet.seed), password, 0);
|
||||
Keystore keystore = Keystore.fromSeed(seed, scriptType.getDefaultDerivation());
|
||||
keystore.setLabel(getWalletModel().toDisplayString());
|
||||
@@ -67,6 +64,24 @@ public class Samourai implements KeystoreFileImport {
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, JsonElement> parseJsonInput(String input) {
|
||||
Gson gson = new Gson();
|
||||
Type stringStringMap = new TypeToken<Map<String, JsonElement>>() {
|
||||
}.getType();
|
||||
|
||||
try {
|
||||
return gson.fromJson(input, stringStringMap);
|
||||
} catch (JsonParseException e) {
|
||||
int closingBracket = input.indexOf('}');
|
||||
if (closingBracket < 0) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
String fixedInput = input.substring(0, closingBracket + 1);
|
||||
return gson.fromJson(fixedInput, stringStringMap);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isKeystoreImportScannable() {
|
||||
return false;
|
||||
|
||||
@@ -485,7 +485,16 @@ public class Storage {
|
||||
}
|
||||
|
||||
public static File getWalletsDir() {
|
||||
File walletsDir = new File(getSparrowDir(), WALLETS_DIR);
|
||||
File walletsDir = Config.get().getWalletsDir();
|
||||
if(walletsDir != null) {
|
||||
if(!walletsDir.exists() && (walletsDir.getParentFile() == null || !walletsDir.getParentFile().exists() || !walletsDir.getParentFile().canWrite())) {
|
||||
log.info("Configured wallets directory " + walletsDir.getAbsolutePath() + " is not reachable, reverting to default");
|
||||
walletsDir = null;
|
||||
}
|
||||
}
|
||||
if(walletsDir == null) {
|
||||
walletsDir = new File(getSparrowDir(), WALLETS_DIR);
|
||||
}
|
||||
if(!walletsDir.exists()) {
|
||||
createOwnerOnlyDirectory(walletsDir);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ public class CardTransport {
|
||||
}
|
||||
}
|
||||
} catch(CborException e) {
|
||||
log.error("CBOR encoding error", e);
|
||||
throw new CardException("CBOR encoding error", e);
|
||||
}
|
||||
|
||||
return new JsonObject();
|
||||
|
||||
@@ -36,6 +36,7 @@ import java.nio.file.StandardCopyOption;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -54,6 +55,7 @@ public class DbPersistence implements Persistence {
|
||||
private static final String H2_USER = "sa";
|
||||
private static final String H2_PASSWORD = "";
|
||||
public static final String MIGRATION_RESOURCES_DIR = "com/sparrowwallet/sparrow/sql/";
|
||||
private static final Pattern JDBC_URL_INJECTION_PATTERN = Pattern.compile(";\\w+=");
|
||||
|
||||
private HikariDataSource dataSource;
|
||||
private AsymmetricKeyDeriver keyDeriver;
|
||||
@@ -695,7 +697,10 @@ public class DbPersistence implements Persistence {
|
||||
}
|
||||
}
|
||||
|
||||
private String getUrl(File walletFile, String password) {
|
||||
private String getUrl(File walletFile, String password) throws StorageException {
|
||||
if(JDBC_URL_INJECTION_PATTERN.matcher(walletFile.getName()).find()) {
|
||||
throw new StorageException("Wallet file name contains invalid characters");
|
||||
}
|
||||
return "jdbc:h2:" + walletFile.getAbsolutePath().replace("." + getType().getExtension(), "") + ";INIT=SET TRACE_LEVEL_FILE=4;TRACE_LEVEL_FILE=4;DEFRAG_ALWAYS=true;MAX_COMPACT_TIME=5000;DATABASE_TO_UPPER=false" + (password == null ? "" : ";CIPHER=AES");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.sparrowwallet.sparrow.io.keycard;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* ISO7816-4 APDU.
|
||||
*/
|
||||
public class APDUCommand {
|
||||
protected int cla;
|
||||
protected int ins;
|
||||
protected int p1;
|
||||
protected int p2;
|
||||
protected int lc;
|
||||
protected byte[] data;
|
||||
protected boolean needsLE;
|
||||
|
||||
/**
|
||||
* Constructs an APDU with no response data length field. The data field cannot be null, but can be a zero-length array.
|
||||
*
|
||||
* @param cla class byte
|
||||
* @param ins instruction code
|
||||
* @param p1 P1 parameter
|
||||
* @param p2 P2 parameter
|
||||
* @param data the APDU data
|
||||
*/
|
||||
public APDUCommand(int cla, int ins, int p1, int p2, byte[] data) {
|
||||
this(cla, ins, p1, p2, data, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs an APDU with an optional data length field. The data field cannot be null, but can be a zero-length array.
|
||||
* The LE byte, if sent, is set to 0.
|
||||
*
|
||||
* @param cla class byte
|
||||
* @param ins instruction code
|
||||
* @param p1 P1 parameter
|
||||
* @param p2 P2 parameter
|
||||
* @param data the APDU data
|
||||
* @param needsLE whether the LE byte should be sent or not
|
||||
*/
|
||||
public APDUCommand(int cla, int ins, int p1, int p2, byte[] data, boolean needsLE) {
|
||||
this.cla = cla & 0xff;
|
||||
this.ins = ins & 0xff;
|
||||
this.p1 = p1 & 0xff;
|
||||
this.p2 = p2 & 0xff;
|
||||
this.data = data;
|
||||
this.needsLE = needsLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the APDU in order to send it to the card.
|
||||
*
|
||||
* @return the byte array representation of the APDU
|
||||
*/
|
||||
public byte[] serialize() throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(this.cla);
|
||||
out.write(this.ins);
|
||||
out.write(this.p1);
|
||||
out.write(this.p2);
|
||||
out.write(this.data.length);
|
||||
out.write(this.data);
|
||||
|
||||
if(this.needsLE) {
|
||||
out.write(0); // Response length
|
||||
}
|
||||
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CLA of the APDU
|
||||
*
|
||||
* @return the CLA of the APDU
|
||||
*/
|
||||
public int getCla() {
|
||||
return cla;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the INS of the APDU
|
||||
*
|
||||
* @return the INS of the APDU
|
||||
*/
|
||||
public int getIns() {
|
||||
return ins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the P1 of the APDU
|
||||
*
|
||||
* @return the P1 of the APDU
|
||||
*/
|
||||
public int getP1() {
|
||||
return p1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the P2 of the APDU
|
||||
*
|
||||
* @return the P2 of the APDU
|
||||
*/
|
||||
public int getP2() {
|
||||
return p2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data field of the APDU
|
||||
*
|
||||
* @return the data field of the APDU
|
||||
*/
|
||||
public byte[] getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether LE is sent or not.
|
||||
*
|
||||
* @return whether LE is sent or not
|
||||
*/
|
||||
public boolean getNeedsLE() {
|
||||
return this.needsLE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.sparrowwallet.sparrow.io.keycard;
|
||||
|
||||
/**
|
||||
* Exception thrown when the response APDU from the card contains unexpected SW or data.
|
||||
*/
|
||||
public class APDUException extends Exception {
|
||||
public final int sw;
|
||||
|
||||
/**
|
||||
* Creates an exception with SW and message.
|
||||
*
|
||||
* @param sw the status word
|
||||
* @param message a descriptive message of the error
|
||||
*/
|
||||
public APDUException(int sw, String message) {
|
||||
super(message + ", 0x" + String.format("%04X", sw));
|
||||
this.sw = sw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an exception with a message.
|
||||
*
|
||||
* @param message a descriptive message of the error
|
||||
*/
|
||||
public APDUException(String message) {
|
||||
super(message);
|
||||
this.sw = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.sparrowwallet.sparrow.io.keycard;
|
||||
|
||||
import com.sparrowwallet.sparrow.io.CardAuthorizationException;
|
||||
|
||||
/**
|
||||
* ISO7816-4 APDU response.
|
||||
*/
|
||||
public class APDUResponse {
|
||||
public static final int SW_OK = 0x9000;
|
||||
public static final int SW_SECURITY_CONDITION_NOT_SATISFIED = 0x6982;
|
||||
public static final int SW_AUTHENTICATION_METHOD_BLOCKED = 0x6983;
|
||||
public static final int SW_CARD_LOCKED = 0x6283;
|
||||
public static final int SW_REFERENCED_DATA_NOT_FOUND = 0x6A88;
|
||||
public static final int SW_CONDITIONS_OF_USE_NOT_SATISFIED = 0x6985; // applet may be already installed
|
||||
public static final int SW_WRONG_PIN_MASK = 0x63C0;
|
||||
|
||||
private byte[] apdu;
|
||||
private byte[] data;
|
||||
private int sw;
|
||||
private int sw1;
|
||||
private int sw2;
|
||||
|
||||
/**
|
||||
* Creates an APDU object by parsing the raw response from the card.
|
||||
*
|
||||
* @param apdu the raw response from the card.
|
||||
*/
|
||||
public APDUResponse(byte[] apdu) {
|
||||
if(apdu.length < 2) {
|
||||
throw new IllegalArgumentException("APDU response must be at least 2 bytes");
|
||||
}
|
||||
this.apdu = apdu;
|
||||
this.parse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the APDU response, separating the response data from SW.
|
||||
*/
|
||||
private void parse() {
|
||||
int length = this.apdu.length;
|
||||
|
||||
this.sw1 = this.apdu[length - 2] & 0xff;
|
||||
this.sw2 = this.apdu[length - 1] & 0xff;
|
||||
this.sw = (this.sw1 << 8) | this.sw2;
|
||||
|
||||
this.data = new byte[length - 2];
|
||||
System.arraycopy(this.apdu, 0, this.data, 0, length - 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the SW is 0x9000.
|
||||
*
|
||||
* @return true if the SW is 0x9000.
|
||||
*/
|
||||
public boolean isOK() {
|
||||
return this.sw == SW_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the SW is 0x9000. Throws an exception if it isn't
|
||||
*
|
||||
* @return this object, to simplify chaining
|
||||
* @throws APDUException if the SW is not 0x9000
|
||||
*/
|
||||
public APDUResponse checkOK() throws APDUException {
|
||||
return this.checkSW(SW_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the SW is contained in the given list. Throws an exception if it isn't.
|
||||
*
|
||||
* @param codes the list of SWs to match.
|
||||
* @return this object, to simplify chaining
|
||||
* @throws APDUException if the SW is not 0x9000
|
||||
*/
|
||||
public APDUResponse checkSW(int... codes) throws APDUException {
|
||||
for(int code : codes) {
|
||||
if(this.sw == code) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
switch(this.sw) {
|
||||
case SW_SECURITY_CONDITION_NOT_SATISFIED:
|
||||
throw new APDUException(this.sw, "security condition not satisfied");
|
||||
case SW_AUTHENTICATION_METHOD_BLOCKED:
|
||||
throw new APDUException(this.sw, "authentication method blocked");
|
||||
default:
|
||||
throw new APDUException(this.sw, "Unexpected error SW");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the SW is 0x9000. Throws an exception with the given message if it isn't
|
||||
*
|
||||
* @param message the error message
|
||||
* @return this object, to simplify chaining
|
||||
* @throws APDUException if the SW is not 0x9000
|
||||
*/
|
||||
public APDUResponse checkOK(String message) throws APDUException {
|
||||
return checkSW(message, SW_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the SW is contained in the given list. Throws an exception with the given message if it isn't.
|
||||
*
|
||||
* @param message the error message
|
||||
* @param codes the list of SWs to match.
|
||||
* @return this object, to simplify chaining
|
||||
* @throws APDUException if the SW is not 0x9000
|
||||
*/
|
||||
public APDUResponse checkSW(String message, int... codes) throws APDUException {
|
||||
for(int code : codes) {
|
||||
if(this.sw == code) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
throw new APDUException(this.sw, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks response from an authentication command (VERIFY PIN, UNBLOCK PUK)
|
||||
*
|
||||
* @throws CardAuthorizationException wrong PIN
|
||||
* @throws APDUException unexpected response
|
||||
*/
|
||||
public APDUResponse checkAuthOK() throws CardAuthorizationException, APDUException {
|
||||
if((this.sw & SW_WRONG_PIN_MASK) == SW_WRONG_PIN_MASK) {
|
||||
int retryAttempts = sw2 & 0x0F;
|
||||
throw new CardAuthorizationException("Wrong PIN, remaining tries: " + retryAttempts);
|
||||
} else {
|
||||
return checkOK();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data field of this APDU.
|
||||
*
|
||||
* @return the data field of this APDU
|
||||
*/
|
||||
public byte[] getData() {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Status Word.
|
||||
*
|
||||
* @return the status word
|
||||
*/
|
||||
public int getSw() {
|
||||
return this.sw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SW1 byte
|
||||
*
|
||||
* @return SW1
|
||||
*/
|
||||
public int getSw1() {
|
||||
return this.sw1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SW2 byte
|
||||
*
|
||||
* @return SW2
|
||||
*/
|
||||
public int getSw2() {
|
||||
return this.sw2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw unparsed response.
|
||||
*
|
||||
* @return raw APDU data
|
||||
*/
|
||||
public byte[] getBytes() {
|
||||
return this.apdu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.sparrowwallet.sparrow.io.keycard;
|
||||
|
||||
/**
|
||||
* Parses the response from a SELECT command. If the card has not yet received the INIT command the isInitializedCard
|
||||
* will return false and only the getSecureChannelPubKey method will return a valid value.
|
||||
*/
|
||||
public class ApplicationInfo {
|
||||
private boolean initializedCard;
|
||||
private byte[] instanceUID;
|
||||
private byte[] secureChannelPubKey;
|
||||
private short appVersion;
|
||||
private byte freePairingSlots;
|
||||
private byte[] keyUID;
|
||||
private byte capabilities;
|
||||
|
||||
public static final byte TLV_APPLICATION_INFO_TEMPLATE = (byte) 0xA4;
|
||||
public static final byte TLV_PUB_KEY = (byte) 0x80;
|
||||
public static final byte TLV_UID = (byte) 0x8F;
|
||||
public static final byte TLV_KEY_UID = (byte) 0x8E;
|
||||
public static final byte TLV_CAPABILITIES = (byte) 0x8D;
|
||||
|
||||
static final byte CAPABILITY_SECURE_CHANNEL = (byte) 0x01;
|
||||
static final byte CAPABILITY_KEY_MANAGEMENT = (byte) 0x02;
|
||||
static final byte CAPABILITY_CREDENTIALS_MANAGEMENT = (byte) 0x04;
|
||||
static final byte CAPABILITY_NDEF = (byte) 0x08;
|
||||
static final byte CAPABILITY_FACTORY_RESET = (byte) 0x10;
|
||||
|
||||
static final byte CAPABILITIES_ALL = CAPABILITY_SECURE_CHANNEL | CAPABILITY_KEY_MANAGEMENT | CAPABILITY_CREDENTIALS_MANAGEMENT | CAPABILITY_NDEF | CAPABILITY_FACTORY_RESET;
|
||||
|
||||
/**
|
||||
* Constructs an object by parsing the TLV data.
|
||||
*
|
||||
* @param tlvData the raw response data from the card
|
||||
* @throws IllegalArgumentException the TLV does not follow the allowed format
|
||||
*/
|
||||
public ApplicationInfo(byte[] tlvData) throws IllegalArgumentException {
|
||||
TinyBERTLV tlv = new TinyBERTLV(tlvData);
|
||||
|
||||
int topTag = tlv.readTag();
|
||||
tlv.unreadLastTag();
|
||||
|
||||
if(topTag == TLV_PUB_KEY) {
|
||||
secureChannelPubKey = tlv.readPrimitive(TLV_PUB_KEY);
|
||||
initializedCard = false;
|
||||
capabilities = CAPABILITY_CREDENTIALS_MANAGEMENT;
|
||||
|
||||
if(secureChannelPubKey.length > 0) {
|
||||
capabilities |= CAPABILITY_SECURE_CHANNEL;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
tlv.enterConstructed(TLV_APPLICATION_INFO_TEMPLATE);
|
||||
instanceUID = tlv.readPrimitive(TLV_UID);
|
||||
secureChannelPubKey = tlv.readPrimitive(TLV_PUB_KEY);
|
||||
appVersion = (short) tlv.readInt();
|
||||
freePairingSlots = (byte) tlv.readInt();
|
||||
keyUID = tlv.readPrimitive(TLV_KEY_UID);
|
||||
|
||||
if(tlv.readTag() != TinyBERTLV.END_OF_TLV) {
|
||||
tlv.unreadLastTag();
|
||||
capabilities = tlv.readPrimitive(TLV_CAPABILITIES)[0];
|
||||
} else {
|
||||
capabilities = CAPABILITIES_ALL;
|
||||
}
|
||||
|
||||
initializedCard = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the card is initialized or not. If this method returns false, only the getSecureChannelPubKey method
|
||||
* will return a valid value.
|
||||
*
|
||||
* @return true if initialized, false otherwise
|
||||
*/
|
||||
public boolean isInitializedCard() {
|
||||
return initializedCard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to discover if the card has a master key.
|
||||
*
|
||||
* @return true if the card has a master key, false otherwise
|
||||
*/
|
||||
public boolean hasMasterKey() {
|
||||
return (keyUID != null) && (keyUID.length != 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The instance UID of the applet. This ID never changes for the lifetime of the applet.
|
||||
*
|
||||
* @return the instance UID
|
||||
*/
|
||||
public byte[] getInstanceUID() {
|
||||
return instanceUID;
|
||||
}
|
||||
|
||||
/**
|
||||
* The public key to be used for secure channel opening. Usually handled internally by the KeycardCommandSet.
|
||||
*
|
||||
* @return the public key
|
||||
*/
|
||||
public byte[] getSecureChannelPubKey() {
|
||||
return secureChannelPubKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* The application version, encoded as a short. The msb is the major revision number and the lsb is the minor one.
|
||||
*
|
||||
* @return the application version
|
||||
*/
|
||||
public short getAppVersion() {
|
||||
return appVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* A formatted application version.
|
||||
*
|
||||
* @return the string representation of the application version
|
||||
*/
|
||||
public String getAppVersionString() {
|
||||
return getAppVersionString(appVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* A formatted application version.
|
||||
*
|
||||
* @return the string representation of the application version
|
||||
*/
|
||||
static String getAppVersionString(short appVersion) {
|
||||
return (appVersion >> 8) + "." + (appVersion & 0xff);
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of remaining pairing slots. If zero is returned, no further pairing is possible.
|
||||
*
|
||||
* @return the number of remaining pairing slots
|
||||
*/
|
||||
public byte getFreePairingSlots() {
|
||||
return freePairingSlots;
|
||||
}
|
||||
|
||||
/**
|
||||
* The UID of the master key on this card. Changes every time a different master key is stored. It has zero length if
|
||||
* no key is on the card.
|
||||
*
|
||||
* @return the Key UID.
|
||||
*/
|
||||
public byte[] getKeyUID() {
|
||||
return keyUID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the capability descriptor for the device.
|
||||
*
|
||||
* @return the capability descriptor for the device.
|
||||
*/
|
||||
public byte getCapabilities() {
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the device supports the Secure Channel capability.
|
||||
*
|
||||
* @return true or false
|
||||
*/
|
||||
public boolean hasSecureChannelCapability() {
|
||||
return (capabilities & CAPABILITY_SECURE_CHANNEL) == CAPABILITY_SECURE_CHANNEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the device supports the Key Management capability.
|
||||
*
|
||||
* @return true or false
|
||||
*/
|
||||
public boolean hasKeyManagementCapability() {
|
||||
return (capabilities & CAPABILITY_KEY_MANAGEMENT) == CAPABILITY_KEY_MANAGEMENT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the device supports the Credentials Management capability.
|
||||
*
|
||||
* @return true or false
|
||||
*/
|
||||
public boolean hasCredentialsManagementCapability() {
|
||||
return (capabilities & CAPABILITY_CREDENTIALS_MANAGEMENT) == CAPABILITY_CREDENTIALS_MANAGEMENT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the device supports the NDEF capability.
|
||||
*
|
||||
* @return true or false
|
||||
*/
|
||||
public boolean hasNDEFCapability() {
|
||||
return (capabilities & CAPABILITY_NDEF) == CAPABILITY_NDEF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the device supports the Factory Reset capability.
|
||||
*
|
||||
* @return true or false
|
||||
*/
|
||||
public boolean hasFactoryResetCapability() {
|
||||
return (capabilities & CAPABILITY_FACTORY_RESET) == CAPABILITY_FACTORY_RESET;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.sparrowwallet.sparrow.io.keycard;
|
||||
|
||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||
|
||||
/**
|
||||
* Represents a BIP32 keypair. This can be a master key or any other key in the path. Contains convenience method to
|
||||
* read and write formats the the card understands.
|
||||
*/
|
||||
public class BIP32KeyPair {
|
||||
private byte[] privateKey;
|
||||
private byte[] chainCode;
|
||||
private byte[] publicKey;
|
||||
|
||||
static final byte TLV_KEY_TEMPLATE = (byte) 0xA1;
|
||||
static final byte TLV_PUB_KEY = (byte) 0x80;
|
||||
static final byte TLV_PRIV_KEY = (byte) 0x81;
|
||||
static final byte TLV_CHAIN_CODE = (byte) 0x82;
|
||||
|
||||
/**
|
||||
* Constructs a BIP32 keypair from a KEY TEMPLATE TLV. Data can be the output of the EXPORT KEY command.
|
||||
*
|
||||
* @param tlvData the TLV data
|
||||
* @return the BIP32 keypair
|
||||
*/
|
||||
public static BIP32KeyPair fromTLV(byte[] tlvData) {
|
||||
TinyBERTLV tlv = new TinyBERTLV(tlvData);
|
||||
tlv.enterConstructed(TLV_KEY_TEMPLATE);
|
||||
|
||||
byte[] pubKey = null;
|
||||
byte[] privKey = null;
|
||||
byte[] chainCode = null;
|
||||
|
||||
int tag = tlv.readTag();
|
||||
|
||||
if(tag == TLV_PUB_KEY) {
|
||||
tlv.unreadLastTag();
|
||||
pubKey = tlv.readPrimitive(TLV_PUB_KEY);
|
||||
tag = tlv.readTag();
|
||||
}
|
||||
|
||||
if(tag == TLV_PRIV_KEY) {
|
||||
tlv.unreadLastTag();
|
||||
privKey = tlv.readPrimitive(TLV_PRIV_KEY);
|
||||
tag = tlv.readTag();
|
||||
}
|
||||
|
||||
if(tag == TLV_CHAIN_CODE) {
|
||||
tlv.unreadLastTag();
|
||||
chainCode = tlv.readPrimitive(TLV_CHAIN_CODE);
|
||||
}
|
||||
|
||||
return new BIP32KeyPair(privKey, chainCode, pubKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Low level constructor. If the private key is not null, the public key can be omitted and it will be calculated
|
||||
* automatically.
|
||||
*
|
||||
* @param privateKey the private key
|
||||
* @param chainCode the chain code
|
||||
* @param publicKey the public key
|
||||
*/
|
||||
public BIP32KeyPair(byte[] privateKey, byte[] chainCode, byte[] publicKey) {
|
||||
this.privateKey = privateKey;
|
||||
this.chainCode = chainCode;
|
||||
|
||||
if(publicKey != null) {
|
||||
this.publicKey = publicKey;
|
||||
} else {
|
||||
calculatePublicKey();
|
||||
}
|
||||
}
|
||||
|
||||
private void calculatePublicKey() {
|
||||
ECKey key = ECKey.fromPrivate(this.privateKey, false);
|
||||
this.publicKey = key.getPubKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the TLV representation of this object.
|
||||
*
|
||||
* @return the TLV representation of this object.
|
||||
*/
|
||||
public byte[] toTLV() {
|
||||
return toTLV(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the TLV representation of this object, optionally omitting the public component.
|
||||
*
|
||||
* @return the TLV representation of this object.
|
||||
*/
|
||||
public byte[] toTLV(boolean includePublic) {
|
||||
int privLen = privateKey.length;
|
||||
int privOff = 0;
|
||||
|
||||
if(privateKey[0] == 0x00) {
|
||||
privOff++;
|
||||
privLen--;
|
||||
}
|
||||
|
||||
int off = 0;
|
||||
int totalLength = includePublic ? (publicKey.length + 2) : 0;
|
||||
totalLength += (privLen + 2);
|
||||
totalLength += isExtended() ? (chainCode.length + 2) : 0;
|
||||
|
||||
if(totalLength > 127) {
|
||||
totalLength += 3;
|
||||
} else {
|
||||
totalLength += 2;
|
||||
}
|
||||
|
||||
byte[] data = new byte[totalLength];
|
||||
data[off++] = TLV_KEY_TEMPLATE;
|
||||
|
||||
if(totalLength > 127) {
|
||||
data[off++] = (byte) 0x81;
|
||||
data[off++] = (byte) (totalLength - 3);
|
||||
} else {
|
||||
data[off++] = (byte) (totalLength - 2);
|
||||
}
|
||||
|
||||
if(includePublic) {
|
||||
data[off++] = TLV_PUB_KEY;
|
||||
data[off++] = (byte) publicKey.length;
|
||||
System.arraycopy(publicKey, 0, data, off, publicKey.length);
|
||||
off += publicKey.length;
|
||||
}
|
||||
|
||||
data[off++] = TLV_PRIV_KEY;
|
||||
data[off++] = (byte) privLen;
|
||||
System.arraycopy(privateKey, privOff, data, off, privLen);
|
||||
off += privLen;
|
||||
|
||||
if(isExtended()) {
|
||||
data[off++] = (byte) TLV_CHAIN_CODE;
|
||||
data[off++] = (byte) chainCode.length;
|
||||
System.arraycopy(chainCode, 0, data, off, chainCode.length);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the private key. Might be null.
|
||||
*
|
||||
* @return the private key
|
||||
*/
|
||||
public byte[] getPrivateKey() {
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the chain code. Might be null.
|
||||
*
|
||||
* @return the chain code
|
||||
*/
|
||||
public byte[] getChainCode() {
|
||||
return chainCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the public key. Is never null.
|
||||
*
|
||||
* @return the public key
|
||||
*/
|
||||
public byte[] getPublicKey() {
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if only the public key is contained, false otherwise.
|
||||
*
|
||||
* @return true or false
|
||||
*/
|
||||
public boolean isPublicOnly() {
|
||||
return privateKey == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the chain code is contained, false otherwise.
|
||||
*
|
||||
* @return true or false
|
||||
*/
|
||||
public boolean isExtended() {
|
||||
return chainCode != null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.sparrowwallet.sparrow.io.keycard;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* A channel to transcieve ISO7816-4 APDUs.
|
||||
*/
|
||||
public interface CardChannel {
|
||||
/**
|
||||
* Sends the given C-APDU and returns an R-APDU.
|
||||
*
|
||||
* @param cmd the command to send
|
||||
* @return the card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
APDUResponse send(APDUCommand cmd) throws IOException;
|
||||
|
||||
/**
|
||||
* True if connected, false otherwise
|
||||
*
|
||||
* @return true if connected, false otherwise
|
||||
*/
|
||||
boolean isConnected();
|
||||
|
||||
/**
|
||||
* Returns the iteration count for deriving the pairing key from the pairing password. The default is 50000 and is
|
||||
* should only be changed for devices where the PBKDF2 is calculated on-board and the resource do not permit a
|
||||
* high iteration count. If a lower count is used other security mechanism should be used to prevent brute force
|
||||
* attacks.
|
||||
*
|
||||
* @return the iteration count
|
||||
*/
|
||||
default int pairingPasswordPBKDF2IterationCount() {
|
||||
return 50000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.sparrowwallet.sparrow.io.keycard;
|
||||
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public class Identifiers {
|
||||
public static final byte[] PACKAGE_AID = Utils.hexToBytes("A0000008040001");
|
||||
|
||||
public static final byte[] KEYCARD_AID = Utils.hexToBytes("A000000804000101");
|
||||
public static final int KEYCARD_DEFAULT_INSTANCE_IDX = 1;
|
||||
|
||||
public static final byte[] NDEF_AID = Utils.hexToBytes("A000000804000102");
|
||||
public static final byte[] NDEF_INSTANCE_AID = Utils.hexToBytes("D2760000850101");
|
||||
|
||||
public static final byte[] CASH_AID = Utils.hexToBytes("A000000804000103");
|
||||
public static final byte[] CASH_INSTANCE_AID = Utils.hexToBytes("A00000080400010301");
|
||||
|
||||
public static final byte[] IDENT_AID = Utils.hexToBytes("A000000804000104");
|
||||
public static final byte[] IDENT_INSTANCE_AID = Utils.hexToBytes("A00000080400010401");
|
||||
|
||||
/**
|
||||
* Gets the instance AID of the default instance of the Keycard applet.
|
||||
*
|
||||
* @return the instance AID of the Keycard applet
|
||||
*/
|
||||
public static byte[] getKeycardInstanceAID() {
|
||||
return getKeycardInstanceAID(KEYCARD_DEFAULT_INSTANCE_IDX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the instance AID of the Keycard applet with the given index. Since multiple instances of the Keycard applet
|
||||
* could be installed in parallel, this method allows selecting a specific instance. The index is between 01 and ff
|
||||
*
|
||||
* @return the instance AID of the Keycard applet
|
||||
*/
|
||||
public static byte[] getKeycardInstanceAID(int instanceIdx) {
|
||||
if(instanceIdx < 0x01 || instanceIdx > 0xff) {
|
||||
throw new IllegalArgumentException("The instance index must be between 1 and 255");
|
||||
}
|
||||
|
||||
byte[] instanceAID = Arrays.copyOf(KEYCARD_AID, KEYCARD_AID.length + 1);
|
||||
instanceAID[KEYCARD_AID.length] = (byte) instanceIdx;
|
||||
return instanceAID;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.sparrowwallet.sparrow.io.keycard;
|
||||
|
||||
import java.util.StringTokenizer;
|
||||
|
||||
/**
|
||||
* Keypath object to be used with the KeycardCommandSet
|
||||
*/
|
||||
public class KeyPath {
|
||||
private int source;
|
||||
private byte[] data;
|
||||
|
||||
/**
|
||||
* Parses a keypath into a byte array and source parameter to be used with the KeycardCommandSet object.
|
||||
* <p>
|
||||
* A valid string is composed of a minimum of one and a maximum of 11 components separated by "/".
|
||||
* <p>
|
||||
* The first component can be either "m", indicating the master key, "..", indicating the parent of the current key,
|
||||
* or "." indicating the current key. It can also be omitted, in which case it is considered the same as being ".".
|
||||
* <p>
|
||||
* All other components are positive integers fitting in 31 bit, eventually suffixed by an apostrophe (') sign,
|
||||
* which indicates an hardened key.
|
||||
* <p>
|
||||
* An example of a valid path is "m/44'/0'/0'/0/0"
|
||||
*
|
||||
* @param keypath the keypath as a string
|
||||
*/
|
||||
public KeyPath(String keypath) {
|
||||
StringTokenizer tokenizer = new StringTokenizer(keypath, "/");
|
||||
|
||||
String sourceOrFirstElement = tokenizer.nextToken();
|
||||
|
||||
switch(sourceOrFirstElement) {
|
||||
case "m":
|
||||
source = KeycardCommandSet.DERIVE_P1_SOURCE_MASTER;
|
||||
break;
|
||||
case "..":
|
||||
source = KeycardCommandSet.DERIVE_P1_SOURCE_PARENT;
|
||||
break;
|
||||
case ".":
|
||||
source = KeycardCommandSet.DERIVE_P1_SOURCE_CURRENT;
|
||||
break;
|
||||
default:
|
||||
source = KeycardCommandSet.DERIVE_P1_SOURCE_CURRENT;
|
||||
tokenizer = new StringTokenizer(keypath, "/"); // rewind
|
||||
break;
|
||||
}
|
||||
|
||||
int componentCount = tokenizer.countTokens();
|
||||
if(componentCount > 10) {
|
||||
throw new IllegalArgumentException("Too many components");
|
||||
}
|
||||
|
||||
data = new byte[4 * componentCount];
|
||||
|
||||
for(int i = 0; i < componentCount; i++) {
|
||||
long component = parseComponent(tokenizer.nextToken());
|
||||
writeComponent(component, i);
|
||||
}
|
||||
}
|
||||
|
||||
public KeyPath(byte[] data, int source) {
|
||||
this.data = data;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
public KeyPath(byte[] data) {
|
||||
this(data, KeycardCommandSet.DERIVE_P1_SOURCE_MASTER);
|
||||
}
|
||||
|
||||
private long parseComponent(String num) {
|
||||
long sign;
|
||||
|
||||
if(num.endsWith("'")) {
|
||||
sign = 0x80000000L;
|
||||
num = num.substring(0, (num.length() - 1));
|
||||
} else {
|
||||
sign = 0L;
|
||||
}
|
||||
|
||||
if(num.startsWith("+") || num.startsWith("-")) {
|
||||
throw new NumberFormatException("No sign allowed");
|
||||
}
|
||||
return (sign | Long.parseLong(num));
|
||||
}
|
||||
|
||||
private void writeComponent(long component, int i) {
|
||||
int off = (i * 4);
|
||||
data[off] = (byte) ((component >> 24) & 0xff);
|
||||
data[off + 1] = (byte) ((component >> 16) & 0xff);
|
||||
data[off + 2] = (byte) ((component >> 8) & 0xff);
|
||||
data[off + 3] = (byte) (component & 0xff);
|
||||
}
|
||||
|
||||
/**
|
||||
* The source of the derive command.
|
||||
*
|
||||
* @return the source of the derive command
|
||||
*/
|
||||
public int getSource() {
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* The byte encoded key path.
|
||||
*
|
||||
* @return byte encoded key path
|
||||
*/
|
||||
public byte[] getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuffer sb = new StringBuffer();
|
||||
|
||||
switch(source) {
|
||||
case KeycardCommandSet.DERIVE_P1_SOURCE_MASTER:
|
||||
sb.append('m');
|
||||
break;
|
||||
case KeycardCommandSet.DERIVE_P1_SOURCE_PARENT:
|
||||
sb.append("..");
|
||||
break;
|
||||
case KeycardCommandSet.DERIVE_P1_SOURCE_CURRENT:
|
||||
sb.append('.');
|
||||
break;
|
||||
}
|
||||
|
||||
for(int i = 0; i < this.data.length; i += 4) {
|
||||
sb.append('/');
|
||||
appendComponent(sb, i);
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void appendComponent(StringBuffer sb, int i) {
|
||||
int num = ((this.data[i] & 0x7f) << 24) | ((this.data[i + 1] & 0xff) << 16) | ((this.data[i + 2] & 0xff) << 8) | (this.data[i + 3] & 0xff);
|
||||
sb.append(num);
|
||||
|
||||
if((this.data[i] & 0x80) == 0x80) {
|
||||
sb.append('\'');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.sparrowwallet.sparrow.io.keycard;
|
||||
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.io.ImportException;
|
||||
import com.sparrowwallet.sparrow.io.KeystoreCardImport;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import javax.smartcardio.CardException;
|
||||
import java.util.List;
|
||||
|
||||
public class Keycard implements KeystoreCardImport {
|
||||
|
||||
@Override
|
||||
public boolean isInitialized() throws CardException {
|
||||
KeycardApi cardApi = null;
|
||||
try {
|
||||
cardApi = new KeycardApi(WalletModel.KEYCARD, null);
|
||||
return cardApi.isInitialized();
|
||||
} finally {
|
||||
if(cardApi != null) {
|
||||
cardApi.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize(String pin, byte[] entropy, StringProperty messageProperty) throws CardException {
|
||||
if(!StringUtils.isNumeric(pin)) {
|
||||
throw new CardException("PIN must be all digits.");
|
||||
}
|
||||
|
||||
if(pin.length() != 6) {
|
||||
throw new CardException("PIN must be 6 digit longs.");
|
||||
}
|
||||
|
||||
KeycardApi cardApi = null;
|
||||
try {
|
||||
cardApi = new KeycardApi(WalletModel.KEYCARD, pin);
|
||||
if(cardApi.isInitialized()) {
|
||||
throw new IllegalStateException("Card is already initialized.");
|
||||
}
|
||||
|
||||
cardApi.initialize(0, entropy);
|
||||
} finally {
|
||||
if(cardApi != null) {
|
||||
cardApi.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Keystore getKeystore(String pin, List<ChildNumber> derivation, StringProperty messageProperty) throws ImportException {
|
||||
if(!StringUtils.isNumeric(pin)) {
|
||||
throw new ImportException("PIN must be all digits.");
|
||||
}
|
||||
|
||||
if(pin.length() != 6) {
|
||||
throw new ImportException("PIN must be 6 digit longs.");
|
||||
}
|
||||
|
||||
KeycardApi cardApi = null;
|
||||
try {
|
||||
cardApi = new KeycardApi(WalletModel.KEYCARD, pin);
|
||||
if(!cardApi.isInitialized()) {
|
||||
throw new IllegalStateException("Card is not initialized.");
|
||||
}
|
||||
cardApi.setDerivation(derivation);
|
||||
return cardApi.getKeystore();
|
||||
} catch(Exception e) {
|
||||
throw new ImportException(e);
|
||||
} finally {
|
||||
if(cardApi != null) {
|
||||
cardApi.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeystoreImportDescription(int account) {
|
||||
return "Import the keystore from your Keycard by inserting or placing it on the card reader.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Keycard";
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletModel getWalletModel() {
|
||||
return WalletModel.KEYCARD;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package com.sparrowwallet.sparrow.io.keycard;
|
||||
|
||||
import com.sparrowwallet.drongo.*;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.crypto.ECDSASignature;
|
||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||
import com.sparrowwallet.drongo.protocol.*;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTInput;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTInputSigner;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.control.CardImportPane;
|
||||
import com.sparrowwallet.sparrow.io.CardApi;
|
||||
import com.sparrowwallet.sparrow.io.CardAuthorizationException;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.concurrent.Service;
|
||||
import javafx.concurrent.Task;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.smartcardio.CardException;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class KeycardApi extends CardApi {
|
||||
private static final Logger log = LoggerFactory.getLogger(KeycardApi.class);
|
||||
|
||||
private final WalletModel cardType;
|
||||
private final KeycardTransport cardTransport;
|
||||
private final KeycardCommandSet cardProtocol;
|
||||
private final String pin;
|
||||
private String basePath = null;
|
||||
|
||||
public KeycardApi(WalletModel cardType, String pin) throws CardException {
|
||||
this.cardType = cardType;
|
||||
this.cardTransport = new KeycardTransport(Identifiers.getKeycardInstanceAID());
|
||||
this.cardProtocol = new KeycardCommandSet(cardTransport);
|
||||
this.pin = pin;
|
||||
|
||||
try {
|
||||
this.cardProtocol.select().checkOK();
|
||||
} catch(IOException | APDUException e) {
|
||||
throw new CardException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isInitialized() throws CardException {
|
||||
return getStatus().hasMasterKey();
|
||||
}
|
||||
|
||||
//TODO
|
||||
@Override
|
||||
public void initialize(int slot, byte[] seedBytes) throws CardException {
|
||||
// TODO check device certificate
|
||||
ApplicationInfo cardStatus = this.getStatus();
|
||||
|
||||
if(!cardStatus.isInitializedCard()) {
|
||||
try {
|
||||
String puk = String.format("%012d", new SecureRandom().nextLong(999999999999L));
|
||||
this.cardProtocol.init(pin, puk, "KeycardDefaultPairing").checkOK();
|
||||
this.cardProtocol.select().checkOK();
|
||||
} catch(IOException | APDUException e) {
|
||||
throw new CardException(e);
|
||||
}
|
||||
}
|
||||
|
||||
if(!cardStatus.hasMasterKey()) {
|
||||
try {
|
||||
this.authenticate();
|
||||
this.cardProtocol.autoUnpair();
|
||||
this.cardProtocol.loadKey(seedBytes).checkOK();
|
||||
} catch(IOException | APDUException e) {
|
||||
throw new CardException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletModel getCardType() throws CardException {
|
||||
return WalletModel.KEYCARD;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCurrentSlot() throws CardException {
|
||||
throw new CardException("Keycard does not support slots");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ScriptType getDefaultScriptType() {
|
||||
return ScriptType.P2WPKH;
|
||||
}
|
||||
|
||||
ApplicationInfo getStatus() {
|
||||
return this.cardProtocol.getApplicationInfo();
|
||||
}
|
||||
|
||||
void authenticate() throws IOException, APDUException, CardAuthorizationException {
|
||||
this.cardProtocol.autoPair("KeycardDefaultPairing");
|
||||
this.cardProtocol.autoOpenSecureChannel();
|
||||
this.cardProtocol.verifyPIN(pin).checkAuthOK();
|
||||
this.cardProtocol.autoUnpair();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<Void> getAuthDelayService() throws CardException {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresBackup() throws CardException {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<String> getBackupService() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean changePin(String newPin) throws CardException {
|
||||
try {
|
||||
this.authenticate();
|
||||
this.cardProtocol.changePIN(newPin).checkOK();
|
||||
} catch(IOException | APDUException e) {
|
||||
throw new CardException(e);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void setDerivation(List<ChildNumber> derivation) throws CardException {
|
||||
this.basePath = KeyDerivation.writePath(derivation);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<Void> getInitializationService(byte[] seedBytes, StringProperty messageProperty) {
|
||||
return new KeycardApi.CardInitializationService(seedBytes, messageProperty);
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<Keystore> getImportService(List<ChildNumber> derivation, StringProperty messageProperty) {
|
||||
return new CardImportPane.CardImportService(new Keycard(), pin, derivation, messageProperty);
|
||||
}
|
||||
|
||||
private byte[] compressedPub(byte[] uncompressedPub) {
|
||||
byte[] compressed = Arrays.copyOfRange(uncompressedPub, 0, 33);
|
||||
compressed[0] = (byte) (0x02 | (uncompressedPub[64] & 0x1));
|
||||
return compressed;
|
||||
}
|
||||
|
||||
private String cardBip32GetXpub(String stringPath, ExtendedKey.Header xtype) throws IOException, APDUException {
|
||||
KeyPath keyPath = new KeyPath(stringPath);
|
||||
int bytepathLen = keyPath.getData().length;
|
||||
int depth = bytepathLen / 4;
|
||||
APDUResponse rapdu = this.cardProtocol.exportKey(keyPath.getData(), keyPath.getSource(), false, KeycardCommandSet.EXPORT_KEY_P2_EXTENDED_PUBLIC).checkOK();
|
||||
BIP32KeyPair extendedkey = BIP32KeyPair.fromTLV(rapdu.getData());
|
||||
|
||||
byte[] fingerprint = new byte[4];
|
||||
byte[] childNumber = new byte[4];
|
||||
|
||||
if(depth == 0) { //masterkey
|
||||
// fingerprint and childnumber set to all-zero bytes by default
|
||||
//fingerprint= bytes([0,0,0,0])
|
||||
//childNumber= bytes([0,0,0,0])
|
||||
} else { //get parent info
|
||||
APDUResponse rapdu2 = this.cardProtocol.exportKey(Arrays.copyOfRange(keyPath.getData(), 0, bytepathLen - 4), keyPath.getSource(), false, KeycardCommandSet.EXPORT_KEY_P2_PUBLIC_ONLY).checkOK();
|
||||
BIP32KeyPair keyParent = BIP32KeyPair.fromTLV(rapdu2.getData());
|
||||
byte[] identifier = Utils.sha256hash160(compressedPub(keyParent.getPublicKey()));
|
||||
fingerprint = Arrays.copyOfRange(identifier, 0, 4);
|
||||
childNumber = Arrays.copyOfRange(keyPath.getData(), bytepathLen - 4, bytepathLen);
|
||||
}
|
||||
|
||||
ByteBuffer buffer = ByteBuffer.allocate(78);
|
||||
buffer.putInt(xtype.getHeader());
|
||||
buffer.put((byte) depth);
|
||||
buffer.put(fingerprint);
|
||||
buffer.put(childNumber);
|
||||
buffer.put(extendedkey.getChainCode()); // chaincode
|
||||
buffer.put(compressedPub(extendedkey.getPublicKey())); // pubkey (compressed)
|
||||
byte[] xpubByte = buffer.array();
|
||||
|
||||
return Base58.encodeChecked(xpubByte);
|
||||
}
|
||||
|
||||
/*
|
||||
* Keycard derives BIP32 keys based on the fullPath (from masterseed to leaf), not the partial path from a given xpub.
|
||||
* the basePath (from masterseed to xpub) is only provided in Keycard.java:getKeystore(String pin, List<ChildNumber> derivation, StringProperty messageProperty)
|
||||
* In Keycard:getKeystore(), no derivation path (i.e. basePath from masterSeed to xpub or relative path) is given and no derivation is reliably available as a object field.
|
||||
* currently, we try to get the path from this.basePath if available (or use a default value) but it's not reliable enough
|
||||
*/
|
||||
@Override
|
||||
public Keystore getKeystore() throws CardException {
|
||||
String keyDerivationString = (this.basePath != null ? this.basePath : getDefaultScriptType().getDefaultDerivationPath());
|
||||
ExtendedKey.Header xtype = Network.get().getXpubHeader();
|
||||
|
||||
String xpub;
|
||||
String masterXpub;
|
||||
try {
|
||||
this.authenticate();
|
||||
xpub = this.cardBip32GetXpub(keyDerivationString, xtype);
|
||||
masterXpub = this.cardBip32GetXpub("m", xtype);
|
||||
} catch(IOException | APDUException e) {
|
||||
throw new CardException(e);
|
||||
}
|
||||
|
||||
ExtendedKey extendedKey = ExtendedKey.fromDescriptor(xpub);
|
||||
ExtendedKey masterExtendedKey = ExtendedKey.fromDescriptor(masterXpub);
|
||||
String masterFingerprint = Utils.bytesToHex(masterExtendedKey.getKey().getFingerprint());
|
||||
KeyDerivation keyDerivation = new KeyDerivation(masterFingerprint, keyDerivationString, true);
|
||||
|
||||
Keystore keystore = new Keystore();
|
||||
keystore.setLabel(WalletModel.KEYCARD.toDisplayString());
|
||||
keystore.setKeyDerivation(keyDerivation);
|
||||
keystore.setSource(KeystoreSource.HW_USB);
|
||||
keystore.setExtendedPublicKey(extendedKey);
|
||||
keystore.setWalletModel(WalletModel.KEYCARD);
|
||||
|
||||
return keystore;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<PSBT> getSignService(Wallet wallet, PSBT psbt, StringProperty messageProperty) {
|
||||
return new KeycardApi.SignService(wallet, psbt, messageProperty);
|
||||
}
|
||||
|
||||
void sign(Wallet wallet, PSBT psbt) throws CardException {
|
||||
Map<PSBTInput, WalletNode> signingNodes = wallet.getSigningNodes(psbt);
|
||||
for(PSBTInput psbtInput : psbt.getPsbtInputs()) {
|
||||
if(!psbtInput.isSigned()) {
|
||||
WalletNode signingNode = signingNodes.get(psbtInput);
|
||||
List<Keystore> keystores = wallet.getKeystores();
|
||||
// recover derivation path from Keycard keystore
|
||||
String fullPath = null;
|
||||
for(int i = 0; i < keystores.size(); i++) {
|
||||
Keystore keystore = keystores.get(i);
|
||||
WalletModel walletModel = keystore.getWalletModel();
|
||||
if(walletModel == WalletModel.KEYCARD) {
|
||||
String basePath = keystore.getKeyDerivation().getDerivationPath();
|
||||
String extendedPath = signingNode.getDerivationPath().substring(1);
|
||||
fullPath = basePath + extendedPath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(fullPath == null) {
|
||||
// recover a default derivation path from first keystore
|
||||
Keystore keystore = keystores.get(0);
|
||||
String basePath = keystore.getKeyDerivation().getDerivationPath();
|
||||
String extendedPath = signingNode.getDerivationPath().substring(1);
|
||||
fullPath = basePath + extendedPath;
|
||||
}
|
||||
psbtInput.sign(new KeycardApi.CardPSBTInputSigner(signingNode, fullPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<String> getSignMessageService(String message, ScriptType scriptType, List<ChildNumber> derivation, StringProperty messageProperty) {
|
||||
return new KeycardApi.SignMessageService(message, scriptType, derivation, messageProperty);
|
||||
}
|
||||
|
||||
String signMessage(String message, ScriptType scriptType, List<ChildNumber> derivation) throws CardException {
|
||||
String fullpath = KeyDerivation.writePath(derivation);
|
||||
ECKey pubkey;
|
||||
|
||||
try {
|
||||
authenticate();
|
||||
APDUResponse rapdu = cardProtocol.exportKey(fullpath, false, KeycardCommandSet.EXPORT_KEY_P2_PUBLIC_ONLY).checkOK();
|
||||
BIP32KeyPair keys = BIP32KeyPair.fromTLV(rapdu.getData());
|
||||
pubkey = ECKey.fromPublicOnly(compressedPub(keys.getPublicKey()));
|
||||
} catch(IOException | APDUException e) {
|
||||
throw new CardException(e);
|
||||
}
|
||||
|
||||
// sign msg
|
||||
return pubkey.signMessage(message, scriptType, hash -> {
|
||||
try {
|
||||
// do the signature with Keycard
|
||||
APDUResponse rapdu2 = cardProtocol.signWithPath(hash.getBytes(), fullpath, false).checkOK();
|
||||
RecoverableSignature sig = new RecoverableSignature(hash.getBytes(), rapdu2.getData());
|
||||
return new ECDSASignature(new BigInteger(1, sig.getR()), new BigInteger(1, sig.getS()));
|
||||
} catch(Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<ECKey> getPrivateKeyService(Integer slot, StringProperty messageProperty) {
|
||||
throw new UnsupportedOperationException("Keycard does not support private key export");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<Address> getAddressService(StringProperty messageProperty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect() {
|
||||
try {
|
||||
cardTransport.disconnect();
|
||||
} catch(CardException e) {
|
||||
log.error("Error disconnecting Keycard" + e);
|
||||
}
|
||||
}
|
||||
|
||||
public class CardInitializationService extends Service<Void> {
|
||||
private final byte[] seedBytes;
|
||||
private final StringProperty messageProperty;
|
||||
|
||||
public CardInitializationService(byte[] seedBytes, StringProperty messageProperty) {
|
||||
this.seedBytes = seedBytes;
|
||||
this.messageProperty = messageProperty;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<Void> createTask() {
|
||||
return new Task<>() {
|
||||
@Override
|
||||
protected Void call() throws Exception {
|
||||
if(seedBytes == null) {
|
||||
throw new CardException("Failed to initialize Keycard - no seed provided");
|
||||
}
|
||||
|
||||
initialize(0, seedBytes);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class SignService extends Service<PSBT> {
|
||||
private final Wallet wallet;
|
||||
private final PSBT psbt;
|
||||
private final StringProperty messageProperty;
|
||||
|
||||
public SignService(Wallet wallet, PSBT psbt, StringProperty messageProperty) {
|
||||
this.wallet = wallet;
|
||||
this.psbt = psbt;
|
||||
this.messageProperty = messageProperty;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<PSBT> createTask() {
|
||||
return new Task<>() {
|
||||
@Override
|
||||
protected PSBT call() throws Exception {
|
||||
sign(wallet, psbt);
|
||||
return psbt;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private class CardPSBTInputSigner implements PSBTInputSigner {
|
||||
private final WalletNode signingNode;
|
||||
private final String fullPath;
|
||||
private ECKey pubkey;
|
||||
|
||||
// todo: provide derivationpath instead of WalletNode??
|
||||
public CardPSBTInputSigner(WalletNode signingNode, String fullPath) {
|
||||
this.signingNode = signingNode;
|
||||
this.fullPath = fullPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransactionSignature sign(Sha256Hash hash, SigHash sigHash, TransactionSignature.Type signatureType) {
|
||||
try {
|
||||
// verify PIN
|
||||
authenticate();
|
||||
|
||||
if(signatureType == TransactionSignature.Type.ECDSA) {
|
||||
// do the signature with Keycard
|
||||
APDUResponse rapdu = cardProtocol.signWithPath(hash.getBytes(), fullPath, false).checkOK();
|
||||
RecoverableSignature sig = new RecoverableSignature(hash.getBytes(), rapdu.getData());
|
||||
pubkey = ECKey.fromPublicOnly(compressedPub(sig.getPublicKey()));
|
||||
|
||||
ECDSASignature ecdsaSig = new ECDSASignature(new BigInteger(1, sig.getR()), new BigInteger(1, sig.getS())).toCanonicalised();
|
||||
TransactionSignature txSig = new TransactionSignature(ecdsaSig, sigHash);
|
||||
|
||||
boolean isCorrect = pubkey.verify(hash, txSig);
|
||||
return txSig;
|
||||
} else {
|
||||
throw new CardException(WalletModel.KEYCARD.toDisplayString() + " cannot sign Taproot transactions");
|
||||
}
|
||||
} catch(Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ECKey getPubKey() {
|
||||
return pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
public class SignMessageService extends Service<String> {
|
||||
private final String message;
|
||||
private final ScriptType scriptType;
|
||||
private final List<ChildNumber> derivation;
|
||||
private final StringProperty messageProperty;
|
||||
|
||||
public SignMessageService(String message, ScriptType scriptType, List<ChildNumber> derivation, StringProperty messageProperty) {
|
||||
this.message = message;
|
||||
this.scriptType = scriptType;
|
||||
this.derivation = derivation;
|
||||
this.messageProperty = messageProperty;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<String> createTask() {
|
||||
return new Task<>() {
|
||||
@Override
|
||||
protected String call() throws Exception {
|
||||
return signMessage(message, scriptType, derivation);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
package com.sparrowwallet.sparrow.io.keycard;
|
||||
|
||||
import com.sparrowwallet.drongo.Drongo;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.SecretKeyFactory;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* This class is used to send APDU to the applet. Each method corresponds to an APDU as defined in the APPLICATION.md
|
||||
* file. Some APDUs map to multiple methods for the sake of convenience since their payload or response require some
|
||||
* pre/post processing.
|
||||
*/
|
||||
public class KeycardCommandSet {
|
||||
static final byte INS_INIT = (byte) 0xFE;
|
||||
static final byte INS_FACTORY_RESET = (byte) 0xFD;
|
||||
static final byte INS_GET_STATUS = (byte) 0xF2;
|
||||
static final byte INS_SET_NDEF = (byte) 0xF3;
|
||||
static final byte INS_IDENTIFY_CARD = (byte) 0x14;
|
||||
static final byte INS_VERIFY_PIN = (byte) 0x20;
|
||||
static final byte INS_CHANGE_PIN = (byte) 0x21;
|
||||
static final byte INS_UNBLOCK_PIN = (byte) 0x22;
|
||||
static final byte INS_LOAD_KEY = (byte) 0xD0;
|
||||
static final byte INS_DERIVE_KEY = (byte) 0xD1;
|
||||
static final byte INS_GENERATE_MNEMONIC = (byte) 0xD2;
|
||||
static final byte INS_REMOVE_KEY = (byte) 0xD3;
|
||||
static final byte INS_GENERATE_KEY = (byte) 0xD4;
|
||||
static final byte INS_SIGN = (byte) 0xC0;
|
||||
static final byte INS_SET_PINLESS_PATH = (byte) 0xC1;
|
||||
static final byte INS_EXPORT_KEY = (byte) 0xC2;
|
||||
static final byte INS_GET_DATA = (byte) 0xCA;
|
||||
static final byte INS_STORE_DATA = (byte) 0xE2;
|
||||
|
||||
public static final byte CHANGE_PIN_P1_USER_PIN = 0x00;
|
||||
public static final byte CHANGE_PIN_P1_PUK = 0x01;
|
||||
public static final byte CHANGE_PIN_P1_PAIRING_SECRET = 0x02;
|
||||
|
||||
public static final byte GET_STATUS_P1_APPLICATION = 0x00;
|
||||
public static final byte GET_STATUS_P1_KEY_PATH = 0x01;
|
||||
|
||||
public static final byte LOAD_KEY_P1_EC = 0x01;
|
||||
public static final byte LOAD_KEY_P1_EXT_EC = 0x02;
|
||||
public static final byte LOAD_KEY_P1_SEED = 0x03;
|
||||
|
||||
public static final byte DERIVE_P1_SOURCE_MASTER = (byte) 0x00;
|
||||
public static final byte DERIVE_P1_SOURCE_PARENT = (byte) 0x40;
|
||||
public static final byte DERIVE_P1_SOURCE_CURRENT = (byte) 0x80;
|
||||
|
||||
static final byte SIGN_P1_CURRENT_KEY = 0x00;
|
||||
static final byte SIGN_P1_DERIVE = 0x01;
|
||||
static final byte SIGN_P1_DERIVE_AND_MAKE_CURRENT = 0x02;
|
||||
static final byte SIGN_P1_PINLESS = 0x03;
|
||||
|
||||
public static final byte SIGN_P2_ECDSA = 0x00;
|
||||
public static final byte SIGN_P2_BLS12_381 = 0x01;
|
||||
|
||||
public static final byte STORE_DATA_P1_PUBLIC = 0x00;
|
||||
public static final byte STORE_DATA_P1_NDEF = 0x01;
|
||||
public static final byte STORE_DATA_P1_CASH = 0x02;
|
||||
|
||||
public static final int GENERATE_MNEMONIC_12_WORDS = 0x04;
|
||||
public static final int GENERATE_MNEMONIC_15_WORDS = 0x05;
|
||||
public static final int GENERATE_MNEMONIC_18_WORDS = 0x06;
|
||||
public static final int GENERATE_MNEMONIC_21_WORDS = 0x07;
|
||||
public static final int GENERATE_MNEMONIC_24_WORDS = 0x08;
|
||||
|
||||
static final byte EXPORT_KEY_P1_CURRENT = 0x00;
|
||||
static final byte EXPORT_KEY_P1_DERIVE = 0x01;
|
||||
static final byte EXPORT_KEY_P1_DERIVE_AND_MAKE_CURRENT = 0x02;
|
||||
|
||||
public static final byte EXPORT_KEY_P2_PRIVATE_AND_PUBLIC = 0x00;
|
||||
public static final byte EXPORT_KEY_P2_PUBLIC_ONLY = 0x01;
|
||||
public static final byte EXPORT_KEY_P2_EXTENDED_PUBLIC = 0x02;
|
||||
|
||||
static final byte FACTORY_RESET_P1_MAGIC = (byte) 0xAA;
|
||||
static final byte FACTORY_RESET_P2_MAGIC = 0x55;
|
||||
|
||||
static final byte TLV_APPLICATION_INFO_TEMPLATE = (byte) 0xA4;
|
||||
|
||||
private final CardChannel apduChannel;
|
||||
private SecureChannelSession secureChannel;
|
||||
private ApplicationInfo info;
|
||||
|
||||
/**
|
||||
* Creates a KeycardCommandSet using the given APDU Channel
|
||||
*
|
||||
* @param apduChannel APDU channel
|
||||
*/
|
||||
public KeycardCommandSet(CardChannel apduChannel) {
|
||||
this.apduChannel = apduChannel;
|
||||
this.secureChannel = new SecureChannelSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the application info as stored from the last sent SELECT command. Returns null if no succesful SELECT
|
||||
* command has been sent using this command set.
|
||||
*
|
||||
* @return the application info object
|
||||
*/
|
||||
public ApplicationInfo getApplicationInfo() {
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the SecureChannel object
|
||||
*
|
||||
* @param secureChannel secure channel
|
||||
*/
|
||||
protected void setSecureChannel(SecureChannelSession secureChannel) {
|
||||
this.secureChannel = secureChannel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current pairing data.
|
||||
*/
|
||||
public Pairing getPairing() {
|
||||
return secureChannel.getPairing();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the pairing data.
|
||||
*
|
||||
* @param pairing data from an existing pairing
|
||||
*/
|
||||
public void setPairing(Pairing pairing) {
|
||||
secureChannel.setPairing(pairing);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the default instance of the Keycard applet. The applet is assumed to have been installed with its default
|
||||
* AID. The returned data is a public key which must be used to initialize the secure channel.
|
||||
*
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse select() throws IOException {
|
||||
return select(Identifiers.KEYCARD_DEFAULT_INSTANCE_IDX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a Keycard instance. The applet is assumed to have been installed with its default AID. The returned data is
|
||||
* a public key which must be used to initialize the secure channel.
|
||||
*
|
||||
* @param instanceIdx the instance index
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse select(int instanceIdx) throws IOException {
|
||||
APDUCommand selectApplet = new APDUCommand(0x00, 0xA4, 4, 0, Identifiers.getKeycardInstanceAID(instanceIdx));
|
||||
APDUResponse resp = apduChannel.send(selectApplet);
|
||||
|
||||
|
||||
if(resp.getSw() == 0x9000) {
|
||||
info = new ApplicationInfo(resp.getData());
|
||||
|
||||
if(info.hasSecureChannelCapability()) {
|
||||
this.secureChannel.generateSecret(info.getSecureChannelPubKey());
|
||||
this.secureChannel.reset();
|
||||
}
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the secure channel. Calls the corresponding method of the SecureChannel class.
|
||||
*
|
||||
* @throws IOException communication error
|
||||
* @throws APDUException secure channel error
|
||||
*/
|
||||
public void autoOpenSecureChannel() throws IOException, APDUException {
|
||||
secureChannel.autoOpenSecureChannel(apduChannel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically pairs. Derives the secret from the given password.
|
||||
*
|
||||
* @throws IOException communication error
|
||||
* @throws APDUException pairing error
|
||||
*/
|
||||
public void autoPair(String pairingPassword) throws IOException, APDUException {
|
||||
byte[] secret = pairingPasswordToSecret(pairingPassword);
|
||||
|
||||
secureChannel.autoPair(apduChannel, secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a pairing password to a binary pairing secret.
|
||||
*
|
||||
* @param pairingPassword the pairing password
|
||||
* @return the pairing secret
|
||||
*/
|
||||
public byte[] pairingPasswordToSecret(String pairingPassword) {
|
||||
SecretKey key;
|
||||
|
||||
try {
|
||||
SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256", Drongo.getProvider());
|
||||
PBEKeySpec spec = new PBEKeySpec(pairingPassword.toCharArray(), "Keycard Pairing Password Salt".getBytes(), apduChannel.pairingPasswordPBKDF2IterationCount(), 32 * 8);
|
||||
key = skf.generateSecret(spec);
|
||||
} catch(Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return key.getEncoded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically pairs. Calls the corresponding method of the SecureChannel class.
|
||||
*
|
||||
* @throws IOException communication error
|
||||
* @throws APDUException pairing error
|
||||
*/
|
||||
public void autoPair(byte[] sharedSecret) throws IOException, APDUException {
|
||||
secureChannel.autoPair(apduChannel, sharedSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically unpairs. Calls the corresponding method of the SecureChannel class.
|
||||
*
|
||||
* @throws IOException communication error
|
||||
* @throws APDUException unpairing error
|
||||
*/
|
||||
public void autoUnpair() throws IOException, APDUException {
|
||||
secureChannel.autoUnpair(apduChannel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a OPEN SECURE CHANNEL APDU. Calls the corresponding method of the SecureChannel class.
|
||||
*/
|
||||
public APDUResponse openSecureChannel(byte index, byte[] data) throws IOException {
|
||||
return secureChannel.openSecureChannel(apduChannel, index, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a MUTUALLY AUTHENTICATE APDU. Calls the corresponding method of the SecureChannel class.
|
||||
*/
|
||||
public APDUResponse mutuallyAuthenticate() throws IOException {
|
||||
return secureChannel.mutuallyAuthenticate(apduChannel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a MUTUALLY AUTHENTICATE APDU. Calls the corresponding method of the SecureChannel class.
|
||||
*/
|
||||
public APDUResponse mutuallyAuthenticate(byte[] data) throws IOException {
|
||||
return secureChannel.mutuallyAuthenticate(apduChannel, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a PAIR APDU. Calls the corresponding method of the SecureChannel class.
|
||||
*/
|
||||
public APDUResponse pair(byte p1, byte[] data) throws IOException {
|
||||
return secureChannel.pair(apduChannel, p1, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a UNPAIR APDU. Calls the corresponding method of the SecureChannel class.
|
||||
*/
|
||||
public APDUResponse unpair(byte p1) throws IOException {
|
||||
return secureChannel.unpair(apduChannel, p1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpair all other clients.
|
||||
*
|
||||
* @throws IOException communication error
|
||||
* @throws APDUException unpairing error
|
||||
*/
|
||||
public void unpairOthers() throws IOException, APDUException {
|
||||
secureChannel.unpairOthers(apduChannel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an IDENTIFY CARD APDU. The challenge is sent as APDU data as-is. It must be 32 bytes long
|
||||
*
|
||||
* @param challenge the data of the APDU
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse identifyCard(byte[] challenge) throws IOException {
|
||||
APDUCommand identifyCard = secureChannel.protectedCommand(0x80, INS_IDENTIFY_CARD, 0, 0, challenge);
|
||||
return secureChannel.transmit(apduChannel, identifyCard);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a GET STATUS APDU. The info byte is the P1 parameter of the command, valid constants are defined in the applet
|
||||
* class itself.
|
||||
*
|
||||
* @param info the P1 of the APDU
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse getStatus(byte info) throws IOException {
|
||||
APDUCommand getStatus = secureChannel.protectedCommand(0x80, INS_GET_STATUS, info, 0, new byte[0]);
|
||||
return secureChannel.transmit(apduChannel, getStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a VERIFY PIN APDU. The raw bytes of the given string are encrypted using the secure channel and used as APDU
|
||||
* data.
|
||||
*
|
||||
* @param pin the PIN
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse verifyPIN(String pin) throws IOException {
|
||||
APDUCommand verifyPIN = secureChannel.protectedCommand(0x80, INS_VERIFY_PIN, 0, 0, pin.getBytes());
|
||||
return secureChannel.transmit(apduChannel, verifyPIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a CHANGE PIN APDU to change the user PIN.
|
||||
*
|
||||
* @param pin the new PIN
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse changePIN(String pin) throws IOException {
|
||||
return changePIN(CHANGE_PIN_P1_USER_PIN, pin.getBytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a CHANGE PIN APDU to change the PUK.
|
||||
*
|
||||
* @param puk the new PUK
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse changePUK(String puk) throws IOException {
|
||||
return changePIN(CHANGE_PIN_P1_PUK, puk.getBytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a CHANGE PIN APDU to change the pairing password. This does not break existing pairings, but new pairings
|
||||
* will be made using the new password.
|
||||
*
|
||||
* @param pairingPassword the new pairing password
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse changePairingPassword(String pairingPassword) throws IOException {
|
||||
return changePIN(CHANGE_PIN_P1_PAIRING_SECRET, pairingPasswordToSecret(pairingPassword));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a CHANGE PIN APDU. The raw bytes of the given string are encrypted using the secure channel and used as APDU
|
||||
* data.
|
||||
*
|
||||
* @param pinType the PIN type
|
||||
* @param pin the new PIN
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse changePIN(int pinType, String pin) throws IOException {
|
||||
return changePIN(pinType, pin.getBytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a CHANGE PIN APDU. The raw bytes of the given string are encrypted using the secure channel and used as APDU
|
||||
* data.
|
||||
*
|
||||
* @param pinType the PIN type
|
||||
* @param pin the new PIN
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse changePIN(int pinType, byte[] pin) throws IOException {
|
||||
APDUCommand changePIN = secureChannel.protectedCommand(0x80, INS_CHANGE_PIN, pinType, 0, pin);
|
||||
return secureChannel.transmit(apduChannel, changePIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an UNBLOCK PIN APDU. The PUK and PIN are concatenated and the raw bytes are encrypted using the secure
|
||||
* channel and used as APDU data.
|
||||
*
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse unblockPIN(String puk, String newPin) throws IOException {
|
||||
APDUCommand unblockPIN = secureChannel.protectedCommand(0x80, INS_UNBLOCK_PIN, 0, 0, (puk + newPin).getBytes());
|
||||
return secureChannel.transmit(apduChannel, unblockPIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a LOAD KEY APDU. The given seed is sent as-is and the P1 of the command is set to LOAD_KEY_P1_SEED (0x03).
|
||||
* This works on cards which support public key derivation. The loaded keyset is extended and support further
|
||||
* key derivation.
|
||||
*
|
||||
* @param seed the binary seed
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse loadKey(byte[] seed) throws IOException {
|
||||
return loadKey(seed, LOAD_KEY_P1_SEED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a LOAD KEY APDU. The key is sent in TLV format. The public key is included if not null. The chain code is
|
||||
* included if not null. P1 is set automatically to either LOAD_KEY_P1_EC or
|
||||
* LOAD_KEY_P1_EXT_EC depending on the presence of the chainCode.
|
||||
*
|
||||
* @param publicKey a raw public key
|
||||
* @param privateKey a raw private key
|
||||
* @param chainCode the chain code
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse loadKey(byte[] publicKey, byte[] privateKey, byte[] chainCode) throws IOException {
|
||||
return loadKey(new BIP32KeyPair(privateKey, chainCode, publicKey), publicKey == null);
|
||||
}
|
||||
|
||||
public APDUResponse loadKey(BIP32KeyPair keyPair) throws IOException {
|
||||
return loadKey(keyPair, false);
|
||||
}
|
||||
|
||||
public APDUResponse loadKey(BIP32KeyPair keyPair, boolean omitPublic) throws IOException {
|
||||
byte p1;
|
||||
|
||||
if(keyPair.isExtended()) {
|
||||
p1 = LOAD_KEY_P1_EXT_EC;
|
||||
} else {
|
||||
p1 = LOAD_KEY_P1_EC;
|
||||
}
|
||||
|
||||
return loadKey(keyPair.toTLV(!omitPublic), p1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a LOAD KEY APDU. The data is encrypted and sent as-is. The keyType parameter is used as P1.
|
||||
*
|
||||
* @param data key data
|
||||
* @param keyType the P1 parameter
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse loadKey(byte[] data, byte keyType) throws IOException {
|
||||
APDUCommand loadKey = secureChannel.protectedCommand(0x80, INS_LOAD_KEY, keyType, 0, data);
|
||||
return secureChannel.transmit(apduChannel, loadKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a GENERATE MNEMONIC APDU. The cs parameter is the length of the checksum and is used as P1.
|
||||
*
|
||||
* @param cs the P1 parameter
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse generateMnemonic(int cs) throws IOException {
|
||||
APDUCommand generateMnemonic = secureChannel.protectedCommand(0x80, INS_GENERATE_MNEMONIC, cs, 0, new byte[0]);
|
||||
return secureChannel.transmit(apduChannel, generateMnemonic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a REMOVE KEY APDU.
|
||||
*
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse removeKey() throws IOException {
|
||||
APDUCommand removeKey = secureChannel.protectedCommand(0x80, INS_REMOVE_KEY, 0, 0, new byte[0]);
|
||||
return secureChannel.transmit(apduChannel, removeKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a GENERATE KEY APDU.
|
||||
*
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse generateKey() throws IOException {
|
||||
APDUCommand generateKey = secureChannel.protectedCommand(0x80, INS_GENERATE_KEY, 0, 0, new byte[0]);
|
||||
return secureChannel.transmit(apduChannel, generateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a SIGN APDU. This signs a precomputed hash that must be exactly 32-bytes long.
|
||||
*
|
||||
* @param hash the hash to sign
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse sign(byte[] hash) throws IOException {
|
||||
return sign(hash, SIGN_P1_CURRENT_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a SIGN APDU. This signs a precomputed hash that must be exactly 32-bytes long. The key used to sign is given
|
||||
* as a parameter.
|
||||
*
|
||||
* @param hash the hash to sign
|
||||
* @param makeCurrent ture if the key used to sign should become the current key, false otherwise
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
* @params path the path of the key to use
|
||||
*/
|
||||
public APDUResponse signWithPath(byte[] hash, String path, boolean makeCurrent) throws IOException {
|
||||
KeyPath keyPath = new KeyPath(path);
|
||||
byte[] pathData = keyPath.getData();
|
||||
byte[] data = Arrays.copyOf(hash, hash.length + pathData.length);
|
||||
System.arraycopy(pathData, 0, data, hash.length, pathData.length);
|
||||
return sign(data, keyPath.getSource() | (makeCurrent ? SIGN_P1_DERIVE_AND_MAKE_CURRENT : SIGN_P1_DERIVE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a SIGN APDU. This signs a precomputed hash that must be exactly 32-bytes long. The pinless path will be used
|
||||
* to sign. This command is the only variant of SIGN which can also be executed without a Secure Channel.
|
||||
*
|
||||
* @param hash the hash to sign
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse signPinless(byte[] hash) throws IOException {
|
||||
return sign(hash, SIGN_P1_PINLESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a SIGN APDU. This signs a precomputed hash so the input must be exactly 32-bytes long, eventually followed by
|
||||
* a derivation path.
|
||||
*
|
||||
* @param p1 the p1 parameter
|
||||
* @param data the data to sign
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse sign(byte[] data, int p1) throws IOException {
|
||||
APDUCommand sign = secureChannel.protectedCommand(0x80, INS_SIGN, p1, 0x01, data);
|
||||
return secureChannel.transmit(apduChannel, sign);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a DERIVE KEY APDU with the given key path.
|
||||
*
|
||||
* @param keypath the string key path
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse deriveKey(String keypath) throws IOException {
|
||||
KeyPath path = new KeyPath(keypath);
|
||||
return deriveKey(path.getData(), path.getSource());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a DERIVE KEY APDU. The data is encrypted and sent as-is. The P1 is forced to 0, meaning that the derivation
|
||||
* starts from the master key.
|
||||
*
|
||||
* @param data the raw key path
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse deriveKey(byte[] data) throws IOException {
|
||||
return deriveKey(data, DERIVE_P1_SOURCE_MASTER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a DERIVE KEY APDU. The data is encrypted and sent as-is. The source parameter is used as P1.
|
||||
*
|
||||
* @param data the raw key path or a public key
|
||||
* @param source the source to start derivation
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse deriveKey(byte[] data, int source) throws IOException {
|
||||
APDUCommand deriveKey = secureChannel.protectedCommand(0x80, INS_DERIVE_KEY, source, 0x00, data);
|
||||
return secureChannel.transmit(apduChannel, deriveKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a SET PINLESS PATH APDU. The path must be absolute, that is starting from the master key.
|
||||
*
|
||||
* @param path the path. Must be an absolute path (i.e: starting from the master key)
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse setPinlessPath(String path) throws IOException {
|
||||
KeyPath keyPath = new KeyPath(path);
|
||||
if(keyPath.getSource() != DERIVE_P1_SOURCE_MASTER) {
|
||||
throw new IllegalArgumentException("Only absolute paths can be set as PINLESS path");
|
||||
}
|
||||
|
||||
return setPinlessPath(keyPath.getData());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an empty SET PINLESS PATH APDU, resetting it. After this command the card does not have a PINless path until
|
||||
* a new one is set.
|
||||
*
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse resetPinlessPath() throws IOException {
|
||||
return setPinlessPath(new byte[]{});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a SET PINLESS PATH APDU. The data is encrypted and sent as-is.
|
||||
*
|
||||
* @param data the raw key path
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse setPinlessPath(byte[] data) throws IOException {
|
||||
APDUCommand setPinlessPath = secureChannel.protectedCommand(0x80, INS_SET_PINLESS_PATH, 0x00, 0x00, data);
|
||||
return secureChannel.transmit(apduChannel, setPinlessPath);
|
||||
}
|
||||
|
||||
private byte poToP2(boolean publicOnly) {
|
||||
return publicOnly ? EXPORT_KEY_P2_PUBLIC_ONLY : EXPORT_KEY_P2_PRIVATE_AND_PUBLIC;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an EXPORT KEY APDU to export the current key.
|
||||
*
|
||||
* @param publicOnly exports only the public key
|
||||
* @return the raw card reponse
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse exportCurrentKey(boolean publicOnly) throws IOException {
|
||||
return exportCurrentKey(poToP2(publicOnly));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an EXPORT KEY APDU to export the current key.
|
||||
*
|
||||
* @param p2 the p2 parameter
|
||||
* @return the raw card reponse
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse exportCurrentKey(byte p2) throws IOException {
|
||||
return exportKey(EXPORT_KEY_P1_CURRENT, p2, new byte[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an EXPORT KEY APDU. Performs derivation of the given keypath and optionally makes it the current key.
|
||||
*
|
||||
* @param keyPath the keypath to export
|
||||
* @param makeCurrent if the key should be made current or not
|
||||
* @param publicOnly the P2 parameter
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse exportKey(String keyPath, boolean makeCurrent, boolean publicOnly) throws IOException {
|
||||
return exportKey(keyPath, makeCurrent, poToP2(publicOnly));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an EXPORT KEY APDU. Performs derivation of the given keypath and optionally makes it the current key.
|
||||
*
|
||||
* @param keyPath the keypath to export
|
||||
* @param makeCurrent if the key should be made current or not
|
||||
* @param p2 the P2 parameter
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse exportKey(String keyPath, boolean makeCurrent, byte p2) throws IOException {
|
||||
KeyPath path = new KeyPath(keyPath);
|
||||
return exportKey(path.getData(), path.getSource(), makeCurrent, p2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an EXPORT KEY APDU. Performs derivation of the given keypath and optionally makes it the current key.
|
||||
*
|
||||
* @param keyPath the keypath to export
|
||||
* @param makeCurrent if the key should be made current or not
|
||||
* @param publicOnly the P2 parameter
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse exportKey(byte[] keyPath, int source, boolean makeCurrent, boolean publicOnly) throws IOException {
|
||||
return exportKey(keyPath, source, makeCurrent, poToP2(publicOnly));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an EXPORT KEY APDU. Performs derivation of the given keypath and optionally makes it the current key.
|
||||
*
|
||||
* @param keyPath the keypath to export
|
||||
* @param makeCurrent if the key should be made current or not
|
||||
* @param p2 the P2 parameter
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse exportKey(byte[] keyPath, int source, boolean makeCurrent, byte p2) throws IOException {
|
||||
int p1 = source | (makeCurrent ? EXPORT_KEY_P1_DERIVE_AND_MAKE_CURRENT : EXPORT_KEY_P1_DERIVE);
|
||||
return exportKey(p1, p2, keyPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an EXPORT KEY APDU. The parameters are sent as-is.
|
||||
*
|
||||
* @param derivationOptions the P1 parameter
|
||||
* @param publicOnly the P2 parameter
|
||||
* @param keypath the data parameter
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse exportKey(int derivationOptions, boolean publicOnly, byte[] keypath) throws IOException {
|
||||
return exportKey(derivationOptions, poToP2(publicOnly), keypath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an EXPORT KEY APDU. The parameters are sent as-is.
|
||||
*
|
||||
* @param derivationOptions the P1 parameter
|
||||
* @param p2 the P2 parameter
|
||||
* @param keypath the data parameter
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse exportKey(int derivationOptions, byte p2, byte[] keypath) throws IOException {
|
||||
APDUCommand exportKey = secureChannel.protectedCommand(0x80, INS_EXPORT_KEY, derivationOptions, p2, keypath);
|
||||
return secureChannel.transmit(apduChannel, exportKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a GET DATA APDU.
|
||||
*
|
||||
* @param dataType the type of data to be stored
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse getData(byte dataType) throws IOException {
|
||||
APDUCommand getData = secureChannel.protectedCommand(0x80, INS_GET_DATA, dataType, 0, new byte[0]);
|
||||
return secureChannel.transmit(apduChannel, getData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a STORE DATA APDU for NDEF.
|
||||
*
|
||||
* @param ndef the data field of the APDU
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse setNDEF(byte[] ndef) throws IOException {
|
||||
if((info.getAppVersion() >> 8) > 2) {
|
||||
if((ndef.length - 2) != ((ndef[0] << 8) | ndef[1])) {
|
||||
byte[] tmp = new byte[ndef.length + 2];
|
||||
tmp[0] = (byte) (ndef.length >> 8);
|
||||
tmp[1] = (byte) (ndef.length & 0xff);
|
||||
System.arraycopy(ndef, 0, tmp, 2, ndef.length);
|
||||
ndef = tmp;
|
||||
}
|
||||
|
||||
return storeData(ndef, STORE_DATA_P1_NDEF);
|
||||
} else {
|
||||
APDUCommand setNDEF = secureChannel.protectedCommand(0x80, INS_SET_NDEF, 0, 0, ndef);
|
||||
return secureChannel.transmit(apduChannel, setNDEF);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a STORE DATA APDU.
|
||||
*
|
||||
* @param data the data field of the APDU
|
||||
* @param dataType the type of data to be stored
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse storeData(byte[] data, byte dataType) throws IOException {
|
||||
APDUCommand storeData = secureChannel.protectedCommand(0x80, INS_STORE_DATA, dataType, 0, data);
|
||||
return secureChannel.transmit(apduChannel, storeData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the INIT command to the card. If either pinRetries or pukRetries is zero, neither will be sent.
|
||||
*
|
||||
* @param pin the PIN
|
||||
* @param puk the PUK
|
||||
* @param pairingPassword pairing password
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse init(String pin, String puk, String pairingPassword) throws IOException {
|
||||
return this.init(pin, puk, pairingPassword, (byte) 0, (byte) 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the INIT command to the card.
|
||||
*
|
||||
* @param pin the PIN
|
||||
* @param puk the PUK
|
||||
* @param pairingPassword pairing password
|
||||
* @param pinRetries the number of allowed PIN retries
|
||||
* @param pukRetries the number of allowed PUK retries
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse init(String pin, String puk, String pairingPassword, byte pinRetries, byte pukRetries) throws IOException {
|
||||
return this.init(pin, null, puk, pairingPasswordToSecret(pairingPassword), pinRetries, pukRetries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the INIT command to the card.
|
||||
*
|
||||
* @param pin the PIN
|
||||
* @param altPin the alternative PIN
|
||||
* @param puk the PUK
|
||||
* @param pairingPassword pairing password
|
||||
* @param pinRetries the number of allowed PIN retries
|
||||
* @param pukRetries the number of allowed PUK retries
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse init(String pin, String altPin, String puk, String pairingPassword, byte pinRetries, byte pukRetries) throws IOException {
|
||||
return this.init(pin, altPin, puk, pairingPasswordToSecret(pairingPassword), pinRetries, pukRetries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the INIT command to the card.
|
||||
*
|
||||
* @param pin the PIN
|
||||
* @param puk the PUK
|
||||
* @param sharedSecret the shared secret for pairing
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse init(String pin, String puk, byte[] sharedSecret) throws IOException {
|
||||
return init(pin, null, puk, sharedSecret, (byte) 0, (byte) 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the INIT command to the card. If either pinRetries or pukRetries is zero, neither will be sent.
|
||||
*
|
||||
* @param pin the PIN
|
||||
* @param pin the alternative
|
||||
* @param puk the PUK
|
||||
* @param sharedSecret the shared secret for pairing
|
||||
* @param pinRetries the number of allowed PIN retries
|
||||
* @param pukRetries the number of allowed PUK retries
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse init(String pin, String altPin, String puk, byte[] sharedSecret, byte pinRetries, byte pukRetries) throws IOException {
|
||||
int baselen = pin.length() + puk.length() + sharedSecret.length;
|
||||
int extlen;
|
||||
|
||||
if(altPin != null) {
|
||||
extlen = 2 + altPin.length();
|
||||
} else if((pinRetries != 0) || (pukRetries != 0)) {
|
||||
extlen = 2;
|
||||
} else {
|
||||
extlen = 0;
|
||||
}
|
||||
|
||||
byte[] initData = Arrays.copyOf(pin.getBytes(), baselen + extlen);
|
||||
System.arraycopy(puk.getBytes(), 0, initData, pin.length(), puk.length());
|
||||
System.arraycopy(sharedSecret, 0, initData, pin.length() + puk.length(), sharedSecret.length);
|
||||
|
||||
if(extlen > 0) {
|
||||
initData[baselen] = pinRetries;
|
||||
initData[baselen + 1] = pukRetries;
|
||||
|
||||
if(extlen > 2) {
|
||||
System.arraycopy(altPin.getBytes(), 0, initData, baselen + 2, altPin.length());
|
||||
}
|
||||
}
|
||||
|
||||
APDUCommand init = new APDUCommand(0x80, INS_INIT, 0, 0, secureChannel.oneShotEncrypt(initData));
|
||||
return apduChannel.send(init);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the FACTORY RESET command to the card.
|
||||
*
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse factoryReset() throws IOException {
|
||||
APDUCommand factoryReset = new APDUCommand(0x80, INS_FACTORY_RESET, FACTORY_RESET_P1_MAGIC, FACTORY_RESET_P2_MAGIC, new byte[0]);
|
||||
return apduChannel.send(factoryReset);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.sparrowwallet.sparrow.io.keycard;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.smartcardio.*;
|
||||
import java.io.IOException;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
|
||||
public class KeycardTransport implements CardChannel {
|
||||
private static final Logger log = LoggerFactory.getLogger(KeycardTransport.class);
|
||||
|
||||
private final Card connection;
|
||||
|
||||
KeycardTransport(byte[] appletAid) throws CardException {
|
||||
TerminalFactory tf = TerminalFactory.getDefault();
|
||||
List<CardTerminal> terminals = tf.terminals().list();
|
||||
if(terminals.isEmpty()) {
|
||||
throw new IllegalStateException("No reader connected");
|
||||
}
|
||||
|
||||
Card connection = null;
|
||||
for(Iterator<CardTerminal> iter = terminals.iterator(); iter.hasNext(); ) {
|
||||
try {
|
||||
connection = getConnection(iter.next(), appletAid);
|
||||
break;
|
||||
} catch(CardException e) {
|
||||
if(!iter.hasNext()) {
|
||||
log.info(e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
private Card getConnection(CardTerminal cardTerminal, byte[] appletAid) throws CardException {
|
||||
Card connection = cardTerminal.connect("*");
|
||||
|
||||
javax.smartcardio.CardChannel cardChannel = connection.getBasicChannel();
|
||||
ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(0, 0xA4, 4, 0, appletAid));
|
||||
if(resp.getSW() != APDUResponse.SW_OK) {
|
||||
throw new CardException("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()));
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
public APDUResponse send(APDUCommand capdu) throws IOException {
|
||||
javax.smartcardio.CardChannel cardChannel = this.connection.getBasicChannel();
|
||||
|
||||
CommandAPDU cmd = new CommandAPDU(capdu.getCla(), capdu.getIns(), capdu.getP1(), capdu.getP2(), capdu.getData());
|
||||
ResponseAPDU resp;
|
||||
|
||||
try {
|
||||
resp = cardChannel.transmit(cmd);
|
||||
} catch(CardException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
return new APDUResponse(resp.getBytes());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isConnected() {
|
||||
return false;
|
||||
}
|
||||
|
||||
void disconnect() throws CardException {
|
||||
connection.disconnect(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.sparrowwallet.sparrow.io.keycard;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Stores pairing information.
|
||||
*/
|
||||
public class Pairing {
|
||||
private byte[] pairingKey;
|
||||
private byte pairingIndex;
|
||||
|
||||
/**
|
||||
* Constructor. The pairingKey and pairingIndex are those generated at the end of a successful pairing.
|
||||
*
|
||||
* @param pairingKey the pairing key
|
||||
* @param pairingIndex the pairing index
|
||||
*/
|
||||
public Pairing(byte[] pairingKey, byte pairingIndex) {
|
||||
this.pairingKey = pairingKey;
|
||||
this.pairingIndex = pairingIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor. Initializes from a byte array previously generated from the toByteArray method
|
||||
*
|
||||
* @param fromByteArray the result of a previous toByteArray invocation
|
||||
*/
|
||||
public Pairing(byte[] fromByteArray) {
|
||||
pairingIndex = fromByteArray[0];
|
||||
pairingKey = Arrays.copyOfRange(fromByteArray, 1, fromByteArray.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor. Initializes from a String previously generated from the toBase64 method
|
||||
*
|
||||
* @param base64 the result of a previous toBase64 invocation
|
||||
*/
|
||||
public Pairing(String base64) {
|
||||
this(Base64.getDecoder().decode(base64));
|
||||
}
|
||||
|
||||
public byte[] getPairingKey() {
|
||||
return pairingKey;
|
||||
}
|
||||
|
||||
public byte getPairingIndex() {
|
||||
return pairingIndex;
|
||||
}
|
||||
|
||||
public byte[] toByteArray() {
|
||||
byte[] res = new byte[pairingKey.length + 1];
|
||||
res[0] = pairingIndex;
|
||||
System.arraycopy(pairingKey, 0, res, 1, pairingKey.length);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
public String toBase64() {
|
||||
return Base64.getEncoder().encodeToString(toByteArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.sparrowwallet.sparrow.io.keycard;
|
||||
|
||||
import com.sparrowwallet.drongo.crypto.ECDSASignature;
|
||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Signature with recoverable public key.
|
||||
*/
|
||||
public class RecoverableSignature {
|
||||
private byte[] publicKey;
|
||||
private int recId;
|
||||
private byte[] r;
|
||||
private byte[] s;
|
||||
private boolean compressed;
|
||||
|
||||
public static final byte TLV_SIGNATURE_TEMPLATE = (byte) 0xA0;
|
||||
public static final byte TLV_RAW_SIGNATURE = (byte) 0x80;
|
||||
public static final byte TLV_ECDSA_TEMPLATE = (byte) 0x30;
|
||||
|
||||
/**
|
||||
* Parses a signature from the card and calculates the recovery ID.
|
||||
*
|
||||
* @param hash the message being signed
|
||||
* @param tlvData the signature as returned from the card
|
||||
*/
|
||||
public RecoverableSignature(byte[] hash, byte[] tlvData) {
|
||||
TinyBERTLV tlv = new TinyBERTLV(tlvData);
|
||||
int tag = tlv.readTag();
|
||||
tlv.unreadLastTag();
|
||||
|
||||
if(tag == TLV_RAW_SIGNATURE) {
|
||||
initFromRawSignature(hash, tlv.readPrimitive(tag));
|
||||
} else if(tag == TLV_SIGNATURE_TEMPLATE) {
|
||||
initFromLegacy(hash, tlv);
|
||||
} else {
|
||||
throw new IllegalArgumentException("invalid tlv");
|
||||
}
|
||||
}
|
||||
|
||||
private void initFromLegacy(byte[] hash, TinyBERTLV tlv) {
|
||||
tlv.enterConstructed(TLV_SIGNATURE_TEMPLATE);
|
||||
this.publicKey = tlv.readPrimitive(ApplicationInfo.TLV_PUB_KEY);
|
||||
tlv.enterConstructed(TLV_ECDSA_TEMPLATE);
|
||||
this.r = toUInt(tlv.readPrimitive(TinyBERTLV.TLV_INT));
|
||||
this.s = toUInt(tlv.readPrimitive(TinyBERTLV.TLV_INT));
|
||||
this.compressed = false;
|
||||
|
||||
calculateRecID(hash);
|
||||
}
|
||||
|
||||
private void initFromRawSignature(byte[] hash, byte[] signature) {
|
||||
this.r = Arrays.copyOfRange(signature, 0, 32);
|
||||
this.s = Arrays.copyOfRange(signature, 32, 64);
|
||||
this.recId = signature[64];
|
||||
this.compressed = false;
|
||||
this.publicKey = recoverFromSignature(this.recId, hash, this.r, this.s, this.compressed);
|
||||
}
|
||||
|
||||
public RecoverableSignature(byte[] publicKey, boolean compressed, byte[] r, byte[] s, int recId) {
|
||||
this.publicKey = publicKey;
|
||||
this.r = r;
|
||||
this.s = s;
|
||||
this.compressed = compressed;
|
||||
this.recId = recId;
|
||||
}
|
||||
|
||||
void calculateRecID(byte[] hash) {
|
||||
recId = -1;
|
||||
|
||||
for(int i = 0; i < 4; i++) {
|
||||
byte[] candidate = recoverFromSignature(i, hash, r, s, compressed);
|
||||
|
||||
if(Arrays.equals(candidate, publicKey)) {
|
||||
recId = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(recId == -1) {
|
||||
throw new IllegalArgumentException("Unrecoverable signature, cannot find recId");
|
||||
}
|
||||
}
|
||||
|
||||
static byte[] toUInt(byte[] signedInt) {
|
||||
if(signedInt[0] == 0) {
|
||||
return Arrays.copyOfRange(signedInt, 1, signedInt.length);
|
||||
} else {
|
||||
return signedInt;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The public key associated to this signature.
|
||||
*
|
||||
* @return the public key associated to this signature
|
||||
*/
|
||||
public byte[] getPublicKey() {
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* The recovery ID
|
||||
*
|
||||
* @return recovery ID
|
||||
*/
|
||||
public int getRecId() {
|
||||
return recId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The R value.
|
||||
*
|
||||
* @return r
|
||||
*/
|
||||
public byte[] getR() {
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* The S value
|
||||
*
|
||||
* @return s
|
||||
*/
|
||||
public byte[] getS() {
|
||||
return s;
|
||||
}
|
||||
|
||||
static byte[] recoverFromSignature(int recId, byte[] hash, byte[] r, byte[] s, boolean compressed) {
|
||||
ECDSASignature sig = new ECDSASignature(new BigInteger(1, r), new BigInteger(1, s));
|
||||
ECKey key = ECKey.recoverFromSignature(recId, sig, Sha256Hash.wrap(hash), compressed);
|
||||
|
||||
if(key == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return key.getPubKey();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
package com.sparrowwallet.sparrow.io.keycard;
|
||||
|
||||
import com.sparrowwallet.drongo.Drongo;
|
||||
import com.sparrowwallet.drongo.bip47.SecretPoint;
|
||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.IOException;
|
||||
import java.security.*;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Handles a SecureChannel session with the card.
|
||||
*/
|
||||
public class SecureChannelSession {
|
||||
public static final short SC_SECRET_LENGTH = 32;
|
||||
public static final short SC_BLOCK_SIZE = 16;
|
||||
|
||||
public static final byte INS_OPEN_SECURE_CHANNEL = 0x10;
|
||||
public static final byte INS_MUTUALLY_AUTHENTICATE = 0x11;
|
||||
public static final byte INS_PAIR = 0x12;
|
||||
public static final byte INS_UNPAIR = 0x13;
|
||||
|
||||
public static final byte PAIR_P1_FIRST_STEP = 0x00;
|
||||
public static final byte PAIR_P1_LAST_STEP = 0x01;
|
||||
|
||||
public static final int PAYLOAD_MAX_SIZE = 223;
|
||||
|
||||
static final byte PAIRING_MAX_CLIENT_COUNT = 5;
|
||||
|
||||
|
||||
private byte[] secret;
|
||||
private byte[] publicKey;
|
||||
private byte[] iv;
|
||||
private Pairing pairing;
|
||||
private Cipher sessionCipher;
|
||||
private Cipher sessionMac;
|
||||
private SecretKeySpec sessionEncKey;
|
||||
private SecretKeySpec sessionMacKey;
|
||||
private SecureRandom random;
|
||||
private boolean open;
|
||||
|
||||
/**
|
||||
* Constructs a SecureChannel session on the client.
|
||||
*/
|
||||
public SecureChannelSession() {
|
||||
random = new SecureRandom();
|
||||
open = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a pairing secret. This should be called before each session. The public key of the card is used as input
|
||||
* for the EC-DH algorithm. The output is stored as the secret.
|
||||
*
|
||||
* @param keyData the public key returned by the applet as response to the SELECT command
|
||||
*/
|
||||
public void generateSecret(byte[] keyData) {
|
||||
try {
|
||||
ECKey key = new ECKey();
|
||||
SecretPoint secretPoint = new SecretPoint(key.getPrivKeyBytes(), keyData);
|
||||
publicKey = key.getPubKey(false);
|
||||
secret = secretPoint.ECDHSecretAsBytes();
|
||||
} catch(Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the public key
|
||||
*
|
||||
* @return the public key
|
||||
*/
|
||||
public byte[] getPublicKey() {
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the pairing information
|
||||
*
|
||||
* @return the pairing information
|
||||
*/
|
||||
public Pairing getPairing() {
|
||||
return pairing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets pairing information needed to open a secure channel.
|
||||
*
|
||||
* @param pairing the pairing information
|
||||
*/
|
||||
public void setPairing(Pairing pairing) {
|
||||
this.pairing = pairing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes a Secure Channel with the card. The command parameters are the public key generated in the first step.
|
||||
* Follows the specifications from the SECURE_CHANNEL.md document.
|
||||
*
|
||||
* @param apduChannel the apdu channel
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public void autoOpenSecureChannel(CardChannel apduChannel) throws IOException, APDUException {
|
||||
APDUResponse response = openSecureChannel(apduChannel, pairing.getPairingIndex(), publicKey);
|
||||
response.checkOK("OPEN SECURE CHANNEL failed");
|
||||
processOpenSecureChannelResponse(response);
|
||||
|
||||
response = mutuallyAuthenticate(apduChannel);
|
||||
response.checkOK("MUTUALLY AUTHENTICATE failed");
|
||||
verifyMutuallyAuthenticateResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the response from OPEN SECURE CHANNEL. This initialize the session keys, Cipher and MAC internally.
|
||||
*
|
||||
* @param response the card response
|
||||
*/
|
||||
public void processOpenSecureChannelResponse(APDUResponse response) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA512");
|
||||
md.update(secret);
|
||||
md.update(pairing.getPairingKey());
|
||||
byte[] data = response.getData();
|
||||
byte[] keyData = md.digest(Arrays.copyOf(data, SC_SECRET_LENGTH));
|
||||
iv = Arrays.copyOfRange(data, SC_SECRET_LENGTH, data.length);
|
||||
|
||||
sessionEncKey = new SecretKeySpec(Arrays.copyOf(keyData, SC_SECRET_LENGTH), "AES");
|
||||
sessionMacKey = new SecretKeySpec(Arrays.copyOfRange(keyData, SC_SECRET_LENGTH, keyData.length), "AES");
|
||||
sessionCipher = Cipher.getInstance("AES/CBC/ISO7816-4Padding", Drongo.getProvider());
|
||||
sessionMac = Cipher.getInstance("AES/CBC/NoPadding", Drongo.getProvider());
|
||||
open = true;
|
||||
} catch(Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the response from MUTUALLY AUTHENTICATE is correct.
|
||||
*
|
||||
* @param response the card response
|
||||
* @return true if response is correct, false otherwise
|
||||
*/
|
||||
public void verifyMutuallyAuthenticateResponse(APDUResponse response) throws APDUException {
|
||||
if(response.getData().length != SC_SECRET_LENGTH) {
|
||||
throw new APDUException("Invalid authentication data from the card");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the entire pairing procedure in order to be able to use the secure channel
|
||||
*
|
||||
* @param apduChannel the apdu channel
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public void autoPair(CardChannel apduChannel, byte[] sharedSecret) throws IOException, APDUException {
|
||||
byte[] challenge = new byte[32];
|
||||
random.nextBytes(challenge);
|
||||
APDUResponse resp = pair(apduChannel, PAIR_P1_FIRST_STEP, challenge).checkOK("Pairing failed on step 1");
|
||||
|
||||
byte[] respData = resp.getData();
|
||||
byte[] cardCryptogram = Arrays.copyOf(respData, 32);
|
||||
byte[] cardChallenge = Arrays.copyOfRange(respData, 32, respData.length);
|
||||
byte[] checkCryptogram;
|
||||
|
||||
MessageDigest md;
|
||||
|
||||
try {
|
||||
md = MessageDigest.getInstance("SHA256");
|
||||
} catch(Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
md.update(sharedSecret);
|
||||
checkCryptogram = md.digest(challenge);
|
||||
|
||||
if(!Arrays.equals(checkCryptogram, cardCryptogram)) {
|
||||
throw new APDUException("Invalid card cryptogram");
|
||||
}
|
||||
|
||||
md.update(sharedSecret);
|
||||
checkCryptogram = md.digest(cardChallenge);
|
||||
|
||||
resp = pair(apduChannel, PAIR_P1_LAST_STEP, checkCryptogram).checkOK("Pairing failed on step 2");
|
||||
respData = resp.getData();
|
||||
md.update(sharedSecret);
|
||||
pairing = new Pairing(md.digest(Arrays.copyOfRange(respData, 1, respData.length)), respData[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpairs the current paired key
|
||||
*
|
||||
* @param apduChannel the apdu channel
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public void autoUnpair(CardChannel apduChannel) throws IOException, APDUException {
|
||||
unpair(apduChannel, pairing.getPairingIndex()).checkOK("Unpairing failed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a OPEN SECURE CHANNEL APDU.
|
||||
*
|
||||
* @param apduChannel the apdu channel
|
||||
* @param index the P1 parameter
|
||||
* @param data the data
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse openSecureChannel(CardChannel apduChannel, byte index, byte[] data) throws IOException {
|
||||
open = false;
|
||||
APDUCommand openSecureChannel = new APDUCommand(0x80, INS_OPEN_SECURE_CHANNEL, index, 0, data);
|
||||
return apduChannel.send(openSecureChannel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a MUTUALLY AUTHENTICATE APDU. The data is generated automatically
|
||||
*
|
||||
* @param apduChannel the apdu channel
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse mutuallyAuthenticate(CardChannel apduChannel) throws IOException {
|
||||
byte[] data = new byte[SC_SECRET_LENGTH];
|
||||
random.nextBytes(data);
|
||||
|
||||
return mutuallyAuthenticate(apduChannel, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a MUTUALLY AUTHENTICATE APDU.
|
||||
*
|
||||
* @param apduChannel the apdu channel
|
||||
* @param data the data
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse mutuallyAuthenticate(CardChannel apduChannel, byte[] data) throws IOException {
|
||||
APDUCommand mutuallyAuthenticate = protectedCommand(0x80, INS_MUTUALLY_AUTHENTICATE, 0, 0, data);
|
||||
return transmit(apduChannel, mutuallyAuthenticate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a PAIR APDU.
|
||||
*
|
||||
* @param apduChannel the apdu channel
|
||||
* @param p1 the P1 parameter
|
||||
* @param data the data
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse pair(CardChannel apduChannel, byte p1, byte[] data) throws IOException {
|
||||
APDUCommand pair = new APDUCommand(0x80, INS_PAIR, p1, 0, data);
|
||||
return transmit(apduChannel, pair);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a UNPAIR APDU.
|
||||
*
|
||||
* @param apduChannel the apdu channel
|
||||
* @param p1 the P1 parameter
|
||||
* @return the raw card response
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public APDUResponse unpair(CardChannel apduChannel, byte p1) throws IOException {
|
||||
APDUCommand unpair = protectedCommand(0x80, INS_UNPAIR, p1, 0, new byte[0]);
|
||||
return transmit(apduChannel, unpair);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpair all other clients
|
||||
*
|
||||
* @param apduChannel the apdu channel
|
||||
* @throws IOException communication error
|
||||
*/
|
||||
public void unpairOthers(CardChannel apduChannel) throws IOException, APDUException {
|
||||
for(int i = 0; i < PAIRING_MAX_CLIENT_COUNT; i++) {
|
||||
if(i != pairing.getPairingIndex()) {
|
||||
APDUCommand unpair = protectedCommand(0x80, INS_UNPAIR, i, 0, new byte[0]);
|
||||
transmit(apduChannel, unpair).checkOK();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts the plaintext data using the session key. The maximum plaintext size is 223 bytes. The returned ciphertext
|
||||
* already includes the IV and padding and can be sent as-is in the APDU payload. If the input is an empty byte array
|
||||
* the returned data will still contain the IV and padding.
|
||||
*
|
||||
* @param data the plaintext data
|
||||
* @return the encrypted data
|
||||
*/
|
||||
private byte[] encryptAPDU(byte[] data) {
|
||||
assert data.length <= PAYLOAD_MAX_SIZE;
|
||||
|
||||
try {
|
||||
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
|
||||
|
||||
sessionCipher.init(Cipher.ENCRYPT_MODE, sessionEncKey, ivParameterSpec);
|
||||
return sessionCipher.doFinal(data);
|
||||
} catch(Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts the response from the card using the session key. The returned data is already stripped from IV and padding
|
||||
* and can be potentially empty.
|
||||
*
|
||||
* @param data the ciphetext
|
||||
* @return the plaintext
|
||||
*/
|
||||
private byte[] decryptAPDU(byte[] data) {
|
||||
try {
|
||||
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
|
||||
sessionCipher.init(Cipher.DECRYPT_MODE, sessionEncKey, ivParameterSpec);
|
||||
return sessionCipher.doFinal(data);
|
||||
} catch(Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a command APDU with MAC and encrypted data.
|
||||
*
|
||||
* @param cla the CLA byte
|
||||
* @param ins the INS byte
|
||||
* @param p1 the P1 byte
|
||||
* @param p2 the P2 byte
|
||||
* @param data the data, can be an empty array but not null
|
||||
* @return the command APDU
|
||||
*/
|
||||
public APDUCommand protectedCommand(int cla, int ins, int p1, int p2, byte[] data) {
|
||||
byte[] finalData;
|
||||
|
||||
if(open) {
|
||||
data = encryptAPDU(data);
|
||||
byte[] meta = new byte[]{(byte) cla, (byte) ins, (byte) p1, (byte) p2, (byte) (data.length + SC_BLOCK_SIZE), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
|
||||
updateIV(meta, data);
|
||||
|
||||
finalData = Arrays.copyOf(iv, iv.length + data.length);
|
||||
System.arraycopy(data, 0, finalData, iv.length, data.length);
|
||||
} else {
|
||||
finalData = data;
|
||||
}
|
||||
|
||||
return new APDUCommand(cla, ins, p1, p2, finalData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transmits a protected command APDU and unwraps the response data. The MAC is verified, the data decrypted and the
|
||||
* SW read from the payload.
|
||||
*
|
||||
* @param apduChannel the APDU channel
|
||||
* @param apdu the APDU to send
|
||||
* @return the unwrapped response APDU
|
||||
* @throws IOException transmission error
|
||||
*/
|
||||
public APDUResponse transmit(CardChannel apduChannel, APDUCommand apdu) throws IOException {
|
||||
APDUResponse resp = apduChannel.send(apdu);
|
||||
|
||||
if(resp.getSw() == 0x6982) {
|
||||
open = false;
|
||||
}
|
||||
|
||||
if(open) {
|
||||
byte[] data = resp.getData();
|
||||
byte[] meta = new byte[]{(byte) data.length, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
|
||||
byte[] mac = Arrays.copyOf(data, iv.length);
|
||||
data = Arrays.copyOfRange(data, iv.length, data.length);
|
||||
|
||||
byte[] plainData = decryptAPDU(data);
|
||||
|
||||
updateIV(meta, data);
|
||||
|
||||
if(!Arrays.equals(iv, mac)) {
|
||||
throw new IOException("Invalid MAC");
|
||||
}
|
||||
|
||||
return new APDUResponse(plainData);
|
||||
} else {
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the SecureChannel as closed
|
||||
*/
|
||||
public void reset() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts the payload for the INIT command
|
||||
*
|
||||
* @param initData the payload for the INIT command
|
||||
* @return the encrypted buffer
|
||||
*/
|
||||
public byte[] oneShotEncrypt(byte[] initData) {
|
||||
try {
|
||||
iv = new byte[SC_BLOCK_SIZE];
|
||||
random.nextBytes(iv);
|
||||
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
|
||||
sessionEncKey = new SecretKeySpec(secret, "AES");
|
||||
sessionCipher = Cipher.getInstance("AES/CBC/ISO7816-4Padding", Drongo.getProvider());
|
||||
sessionCipher.init(Cipher.ENCRYPT_MODE, sessionEncKey, ivParameterSpec);
|
||||
initData = sessionCipher.doFinal(initData);
|
||||
byte[] encrypted = new byte[1 + publicKey.length + iv.length + initData.length];
|
||||
encrypted[0] = (byte) publicKey.length;
|
||||
System.arraycopy(publicKey, 0, encrypted, 1, publicKey.length);
|
||||
System.arraycopy(iv, 0, encrypted, (1 + publicKey.length), iv.length);
|
||||
System.arraycopy(initData, 0, encrypted, (1 + publicKey.length + iv.length), initData.length);
|
||||
return encrypted;
|
||||
} catch(Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the SecureChannel as open. Only to be used when writing tests for the SecureChannel, in normal operation this
|
||||
* would only make things wrong.
|
||||
*
|
||||
*/
|
||||
protected void setOpen() {
|
||||
open = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a CMAC from the metadata and data provided and sets it as the IV for the next message.
|
||||
*
|
||||
* @param meta metadata
|
||||
* @param data data
|
||||
*/
|
||||
private void updateIV(byte[] meta, byte[] data) {
|
||||
try {
|
||||
IvParameterSpec ivParameterSpec = new IvParameterSpec(new byte[SC_BLOCK_SIZE]);
|
||||
sessionMac.init(Cipher.ENCRYPT_MODE, sessionMacKey, ivParameterSpec);
|
||||
sessionMac.update(meta);
|
||||
byte[] tmp = sessionMac.doFinal(data);
|
||||
iv = Arrays.copyOfRange(tmp, tmp.length - SC_BLOCK_SIZE, tmp.length);
|
||||
} catch(Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.sparrowwallet.sparrow.io.keycard;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Tiny BER-TLV implementation. Not for general usage, but fast and easy to use for this project.
|
||||
*/
|
||||
public class TinyBERTLV {
|
||||
public static final byte TLV_BOOL = (byte) 0x01;
|
||||
public static final byte TLV_INT = (byte) 0x02;
|
||||
|
||||
public static final int END_OF_TLV = (int) 0xffffffff;
|
||||
|
||||
private byte[] buffer;
|
||||
private int pos;
|
||||
|
||||
public static int[] readNum(byte[] buf, int off) {
|
||||
int len = buf[off++] & 0xff;
|
||||
int lenlen = 0;
|
||||
|
||||
if((len & 0x80) == 0x80) {
|
||||
lenlen = len & 0x7f;
|
||||
len = readVal(buf, off, lenlen);
|
||||
}
|
||||
|
||||
return new int[]{len, off + lenlen};
|
||||
}
|
||||
|
||||
public static int readVal(byte[] val, int off, int len) {
|
||||
switch(len) {
|
||||
case 1:
|
||||
return val[off] & 0xff;
|
||||
case 2:
|
||||
return ((val[off] & 0xff) << 8) | (val[off + 1] & 0xff);
|
||||
case 3:
|
||||
return ((val[off] & 0xff) << 16) | ((val[off + 1] & 0xff) << 8) | (val[off + 2] & 0xff);
|
||||
case 4:
|
||||
return ((val[off] & 0xff) << 24) | ((val[off + 1] & 0xff) << 16) | ((val[off + 2] & 0xff) << 8) | (val[off + 3] & 0xff);
|
||||
default:
|
||||
throw new IllegalArgumentException("Integers of length " + len + " are unsupported");
|
||||
}
|
||||
}
|
||||
|
||||
public static void writeNum(ByteArrayOutputStream os, int len) {
|
||||
if((len & 0xff000000) != 0) {
|
||||
os.write(0x84);
|
||||
os.write((len & 0xff000000) >> 24);
|
||||
os.write((len & 0x00ff0000) >> 16);
|
||||
os.write((len & 0x0000ff00) >> 8);
|
||||
os.write(len & 0x000000ff);
|
||||
} else if((len & 0x00ff0000) != 0) {
|
||||
os.write(0x83);
|
||||
os.write((len & 0x00ff0000) >> 16);
|
||||
os.write((len & 0x0000ff00) >> 8);
|
||||
os.write(len & 0x000000ff);
|
||||
} else if((len & 0x0000ff00) != 0) {
|
||||
os.write(0x82);
|
||||
os.write((len & 0x0000ff00) >> 8);
|
||||
os.write(len & 0x000000ff);
|
||||
} else if((len & 0x00000080) != 0) {
|
||||
os.write(0x81);
|
||||
os.write(len & 0x000000ff);
|
||||
} else {
|
||||
os.write(len);
|
||||
}
|
||||
}
|
||||
|
||||
public TinyBERTLV(byte[] buffer) {
|
||||
this.buffer = buffer;
|
||||
this.pos = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enters a constructed TLV with the given tag
|
||||
*
|
||||
* @param tag the tag to enter
|
||||
* @return the length of the TLV
|
||||
* @throws IllegalArgumentException if the next tag does not match the given one
|
||||
*/
|
||||
public int enterConstructed(int tag) throws IllegalArgumentException {
|
||||
checkTag(tag, readTag());
|
||||
return readLength();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a primitive TLV with the given tag
|
||||
*
|
||||
* @param tag the tag to read
|
||||
* @return the body of the TLV
|
||||
* @throws IllegalArgumentException if the next tag does not match the given one
|
||||
*/
|
||||
public byte[] readPrimitive(int tag) throws IllegalArgumentException {
|
||||
checkTag(tag, readTag());
|
||||
int len = readLength();
|
||||
pos += len;
|
||||
return Arrays.copyOfRange(buffer, (pos - len), pos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a boolean TLV.
|
||||
*
|
||||
* @return the boolean value of the TLV
|
||||
* @throws IllegalArgumentException if the next tag is not a boolean
|
||||
*/
|
||||
public boolean readBoolean() throws IllegalArgumentException {
|
||||
byte[] val = readPrimitive(TLV_BOOL);
|
||||
return ((val[0] & 0xff) == 0xff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an integer TLV.
|
||||
*
|
||||
* @return the integer value of the TLV
|
||||
* @throws IllegalArgumentException if the next tlv is not an integer or is of unsupported length
|
||||
*/
|
||||
public int readInt() throws IllegalArgumentException {
|
||||
byte[] val = readPrimitive(TLV_INT);
|
||||
return TinyBERTLV.readVal(val, 0, val.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all unread bytes in the TLV.
|
||||
*
|
||||
* @return all unread bytes
|
||||
*/
|
||||
byte[] peekUnread() {
|
||||
return Arrays.copyOfRange(buffer, pos, buffer.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level method to unread the last read tag. Only valid if the previous call was readTag(). Does nothing if the
|
||||
* end of the TLV has been reached.
|
||||
*/
|
||||
public void unreadLastTag() {
|
||||
if(pos < buffer.length) {
|
||||
pos--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the next tag. The current implementation only reads tags on one byte. Can be extended if needed.
|
||||
*
|
||||
* @return the tag
|
||||
*/
|
||||
public int readTag() {
|
||||
return (pos < buffer.length) ? buffer[pos++] : END_OF_TLV;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the next tag. The current implementation only reads length on one and two bytes. Can be extended if needed.
|
||||
*
|
||||
* @return the tag
|
||||
*/
|
||||
public int readLength() {
|
||||
int[] len = TinyBERTLV.readNum(buffer, pos);
|
||||
pos = len[1];
|
||||
return len[0];
|
||||
}
|
||||
|
||||
private void checkTag(int expected, int actual) throws IllegalArgumentException {
|
||||
if(expected != actual) {
|
||||
unreadLastTag();
|
||||
throw new IllegalArgumentException("Expected tag: " + expected + ", received: " + actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import com.sparrowwallet.sparrow.control.TitledDescriptionPane;
|
||||
import com.sparrowwallet.sparrow.io.*;
|
||||
import com.sparrowwallet.sparrow.io.ckcard.Satschip;
|
||||
import com.sparrowwallet.sparrow.io.ckcard.Tapsigner;
|
||||
import com.sparrowwallet.sparrow.io.keycard.Keycard;
|
||||
import com.sparrowwallet.sparrow.io.satochip.Satochip;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Accordion;
|
||||
@@ -26,9 +27,9 @@ public class HwAirgappedController extends KeystoreImportDetailController {
|
||||
public void initializeView() {
|
||||
List<KeystoreFileImport> fileImporters = Collections.emptyList();
|
||||
if(getMasterController().getWallet().getPolicyType().equals(PolicyType.SINGLE)) {
|
||||
fileImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY(), new Krux(), new AirGapVault());
|
||||
fileImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY(), new Krux(), new AirGapVault(), new KeycardShellSinglesig());
|
||||
} else if(getMasterController().getWallet().getPolicyType().equals(PolicyType.MULTI)) {
|
||||
fileImporters = List.of(new Bip129(), new ColdcardMultisig(), new CoboVaultMultisig(), new JadeMultisig(), new KeystoneMultisig(), new PassportMultisig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY(), new Krux());
|
||||
fileImporters = List.of(new Bip129(), new ColdcardMultisig(), new CoboVaultMultisig(), new JadeMultisig(), new KeystoneMultisig(), new PassportMultisig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY(), new Krux(), new KeycardShellMultisig());
|
||||
}
|
||||
|
||||
for(KeystoreFileImport importer : fileImporters) {
|
||||
@@ -40,10 +41,10 @@ public class HwAirgappedController extends KeystoreImportDetailController {
|
||||
}
|
||||
}
|
||||
|
||||
List<KeystoreCardImport> cardImporters = List.of(new Tapsigner(), new Satochip(), new Satschip());
|
||||
List<KeystoreCardImport> cardImporters = List.of(new Tapsigner(), new Satochip(), new Satschip(), new Keycard());
|
||||
for(KeystoreCardImport importer : cardImporters) {
|
||||
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
|
||||
CardImportPane importPane = new CardImportPane(getMasterController().getWallet(), importer, getMasterController().getRequiredDerivation());
|
||||
CardImportPane importPane = new CardImportPane(getMasterController().getWallet(), importer, getMasterController().getDefaultDerivation(), getMasterController().getRequiredDerivation());
|
||||
if(getMasterController().getRequiredModel() == null || getMasterController().getRequiredModel() == importer.getWalletModel()) {
|
||||
importAccordion.getPanes().add(importPane);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ public class HwUsbDevicesController extends KeystoreImportDetailController {
|
||||
|
||||
public void initializeView(List<Device> devices) {
|
||||
for(Device device : devices) {
|
||||
DevicePane devicePane = new DevicePane(getMasterController().getWallet(), device, devices.size() == 1, getMasterController().getRequiredDerivation());
|
||||
DevicePane devicePane = new DevicePane(getMasterController().getWallet(), device, devices.size() == 1, getMasterController().getDefaultDerivation(), getMasterController().getRequiredDerivation());
|
||||
if(getMasterController().getRequiredModel() == null || getMasterController().getRequiredModel() == device.getModel()) {
|
||||
deviceAccordion.getPanes().add(devicePane);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user