mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2024-11-02 20:36:44 +00:00
initialize and import tapsigner as keystore
This commit is contained in:
parent
7c64d689fd
commit
6b59ff60ad
37 changed files with 1050 additions and 24 deletions
|
@ -493,6 +493,8 @@ extraJavaModuleInfo {
|
||||||
}
|
}
|
||||||
module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') {
|
module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') {
|
||||||
exports('co.nstant.in.cbor')
|
exports('co.nstant.in.cbor')
|
||||||
|
exports('co.nstant.in.cbor.model')
|
||||||
|
exports('co.nstant.in.cbor.builder')
|
||||||
}
|
}
|
||||||
module('nightjar-0.2.34.jar', 'com.sparrowwallet.nightjar', '0.2.34') {
|
module('nightjar-0.2.34.jar', 'com.sparrowwallet.nightjar', '0.2.34') {
|
||||||
requires('com.google.common')
|
requires('com.google.common')
|
||||||
|
|
2
drongo
2
drongo
|
@ -1 +1 @@
|
||||||
Subproject commit c642f7414a35c9ad69439687ba8724b76d9a6fb1
|
Subproject commit 2168c56de924dd7bff5458b32f9a60a6c79066a1
|
|
@ -0,0 +1,148 @@
|
||||||
|
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.wallet.Keystore;
|
||||||
|
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||||
|
import com.sparrowwallet.sparrow.EventManager;
|
||||||
|
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
|
||||||
|
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||||
|
import com.sparrowwallet.sparrow.io.KeystoreCardImport;
|
||||||
|
import com.sparrowwallet.sparrow.io.ckcard.CardAuthorizationException;
|
||||||
|
import javafx.beans.property.SimpleStringProperty;
|
||||||
|
import javafx.concurrent.Service;
|
||||||
|
import javafx.concurrent.Task;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.Control;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.Priority;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
import org.controlsfx.control.textfield.CustomPasswordField;
|
||||||
|
import org.controlsfx.glyphfont.Glyph;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class CardImportPane extends TitledDescriptionPane {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CardImportPane.class);
|
||||||
|
|
||||||
|
private final KeystoreCardImport importer;
|
||||||
|
private final List<ChildNumber> derivation;
|
||||||
|
protected Button importButton;
|
||||||
|
private final SimpleStringProperty pin = new SimpleStringProperty("");
|
||||||
|
private final SimpleStringProperty errorText = new SimpleStringProperty("");
|
||||||
|
|
||||||
|
public CardImportPane(Wallet wallet, KeystoreCardImport importer, KeyDerivation requiredDerivation) {
|
||||||
|
super(importer.getName(), "Keystore import", importer.getKeystoreImportDescription(getAccount(wallet, requiredDerivation)), "image/" + importer.getWalletModel().getType() + ".png");
|
||||||
|
this.importer = importer;
|
||||||
|
this.derivation = requiredDerivation == null ? wallet.getScriptType().getDefaultDerivation() : requiredDerivation.getDerivation();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Control createButton() {
|
||||||
|
importButton = new Button("Tap");
|
||||||
|
Glyph tapGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WIFI);
|
||||||
|
tapGlyph.setFontSize(12);
|
||||||
|
importButton.setGraphic(tapGlyph);
|
||||||
|
importButton.setAlignment(Pos.CENTER_RIGHT);
|
||||||
|
importButton.setOnAction(event -> {
|
||||||
|
importCard();
|
||||||
|
});
|
||||||
|
return importButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void importCard() {
|
||||||
|
if(pin.get().isEmpty()) {
|
||||||
|
setDescription("Enter PIN code");
|
||||||
|
setContent(getPinEntry());
|
||||||
|
setExpanded(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CardImportService cardImportService = new CardImportService(importer, pin.get(), derivation);
|
||||||
|
cardImportService.messageProperty().addListener((observable, oldValue, newValue) -> {
|
||||||
|
setDescription(newValue);
|
||||||
|
});
|
||||||
|
cardImportService.setOnSucceeded(event -> {
|
||||||
|
EventManager.get().post(new KeystoreImportEvent(cardImportService.getValue()));
|
||||||
|
});
|
||||||
|
cardImportService.setOnFailed(event -> {
|
||||||
|
log.error("Error importing keystore from card", event.getSource().getException());
|
||||||
|
Throwable rootCause = Throwables.getRootCause(event.getSource().getException());
|
||||||
|
if(rootCause instanceof CardAuthorizationException) {
|
||||||
|
setError("Import Error", "Incorrect PIN code, try again:");
|
||||||
|
} else {
|
||||||
|
setError("Import Error", rootCause.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cardImportService.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void setError(String title, String detail) {
|
||||||
|
super.setError(title, null);
|
||||||
|
errorText.set(detail);
|
||||||
|
setContent(getPinEntry());
|
||||||
|
setExpanded(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Node getPinEntry() {
|
||||||
|
VBox vBox = new VBox();
|
||||||
|
|
||||||
|
if(!errorText.get().isEmpty()) {
|
||||||
|
Node contextBox = getContentBox(errorText.get());
|
||||||
|
if(contextBox instanceof HBox hBox && hBox.getPrefHeight() == 60) {
|
||||||
|
hBox.setPrefHeight(50);
|
||||||
|
}
|
||||||
|
vBox.getChildren().add(contextBox);
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomPasswordField pinField = new ViewPasswordField();
|
||||||
|
pinField.setPromptText("PIN Code");
|
||||||
|
pinField.setText(pin.get());
|
||||||
|
importButton.setDefaultButton(true);
|
||||||
|
pin.bind(pinField.textProperty());
|
||||||
|
HBox.setHgrow(pinField, Priority.ALWAYS);
|
||||||
|
|
||||||
|
HBox contentBox = new HBox();
|
||||||
|
contentBox.setAlignment(Pos.TOP_RIGHT);
|
||||||
|
contentBox.setSpacing(20);
|
||||||
|
contentBox.getChildren().add(pinField);
|
||||||
|
contentBox.setPadding(new Insets(errorText.get().isEmpty() ? 10 : 0, 30, 10, 30));
|
||||||
|
contentBox.setPrefHeight(50);
|
||||||
|
|
||||||
|
vBox.getChildren().add(contentBox);
|
||||||
|
|
||||||
|
return vBox;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CardImportService extends Service<Keystore> {
|
||||||
|
private final KeystoreCardImport cardImport;
|
||||||
|
private final String pin;
|
||||||
|
private final List<ChildNumber> derivation;
|
||||||
|
|
||||||
|
public CardImportService(KeystoreCardImport cardImport, String pin, List<ChildNumber> derivation) {
|
||||||
|
this.cardImport = cardImport;
|
||||||
|
this.pin = pin;
|
||||||
|
this.derivation = derivation;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Task<Keystore> createTask() {
|
||||||
|
return new Task<>() {
|
||||||
|
@Override
|
||||||
|
protected Keystore call() throws Exception {
|
||||||
|
cardImport.messageProperty().addListener((observable, oldValue, newValue) -> {
|
||||||
|
updateMessage(newValue);
|
||||||
|
});
|
||||||
|
return cardImport.getKeystore(pin, derivation);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -34,17 +34,4 @@ public class FileKeystoreImportPane extends FileImportPane {
|
||||||
EventManager.get().post(new KeystoreImportEvent(keystore));
|
EventManager.get().post(new KeystoreImportEvent(keystore));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int getAccount(Wallet wallet, KeyDerivation requiredDerivation) {
|
|
||||||
if(wallet == null || requiredDerivation == null) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int account = wallet.getScriptType().getAccount(requiredDerivation.getDerivationPath());
|
|
||||||
if(account < 0) {
|
|
||||||
account = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return account;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
package com.sparrowwallet.sparrow.control;
|
package com.sparrowwallet.sparrow.control;
|
||||||
|
|
||||||
|
import com.sparrowwallet.drongo.KeyDerivation;
|
||||||
|
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||||
import com.sparrowwallet.sparrow.AppServices;
|
import com.sparrowwallet.sparrow.AppServices;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.geometry.Insets;
|
import javafx.geometry.Insets;
|
||||||
|
@ -159,4 +161,17 @@ public class TitledDescriptionPane extends TitledPane {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static int getAccount(Wallet wallet, KeyDerivation requiredDerivation) {
|
||||||
|
if(wallet == null || requiredDerivation == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int account = wallet.getScriptType().getAccount(requiredDerivation.getDerivationPath());
|
||||||
|
if(account < 0) {
|
||||||
|
account = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return account;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,10 +36,18 @@ public class WalletIcon extends StackPane {
|
||||||
|
|
||||||
private final Storage storage;
|
private final Storage storage;
|
||||||
private final ObjectProperty<Wallet> walletProperty = new SimpleObjectProperty<>();
|
private final ObjectProperty<Wallet> walletProperty = new SimpleObjectProperty<>();
|
||||||
|
private final Keystore keystore;
|
||||||
|
private final Glyph fallbackIcon;
|
||||||
|
|
||||||
public WalletIcon(Storage storage, Wallet wallet) {
|
public WalletIcon(Storage storage, Wallet wallet) {
|
||||||
|
this(storage, wallet, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public WalletIcon(Storage storage, Wallet wallet, Keystore keystore, Glyph fallbackIcon) {
|
||||||
super();
|
super();
|
||||||
this.storage = storage;
|
this.storage = storage;
|
||||||
|
this.keystore = keystore;
|
||||||
|
this.fallbackIcon = fallbackIcon;
|
||||||
setPrefSize(WIDTH, HEIGHT);
|
setPrefSize(WIDTH, HEIGHT);
|
||||||
walletProperty.addListener((observable, oldValue, newValue) -> {
|
walletProperty.addListener((observable, oldValue, newValue) -> {
|
||||||
refresh();
|
refresh();
|
||||||
|
@ -58,8 +66,8 @@ public class WalletIcon extends StackPane {
|
||||||
} else {
|
} else {
|
||||||
Platform.runLater(() -> addWalletIcon(walletId));
|
Platform.runLater(() -> addWalletIcon(walletId));
|
||||||
}
|
}
|
||||||
} else if(wallet.getKeystores().size() == 1) {
|
} else if(this.keystore != null || wallet.getKeystores().size() == 1) {
|
||||||
Keystore keystore = wallet.getKeystores().get(0);
|
Keystore keystore = this.keystore == null ? wallet.getKeystores().get(0) : this.keystore;
|
||||||
if(keystore.getSource() == KeystoreSource.HW_USB || keystore.getSource() == KeystoreSource.HW_AIRGAPPED) {
|
if(keystore.getSource() == KeystoreSource.HW_USB || keystore.getSource() == KeystoreSource.HW_AIRGAPPED) {
|
||||||
WalletModel walletModel = keystore.getWalletModel();
|
WalletModel walletModel = keystore.getWalletModel();
|
||||||
|
|
||||||
|
@ -96,7 +104,10 @@ public class WalletIcon extends StackPane {
|
||||||
}
|
}
|
||||||
|
|
||||||
if(getChildren().isEmpty()) {
|
if(getChildren().isEmpty()) {
|
||||||
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WALLET);
|
Glyph glyph = fallbackIcon;
|
||||||
|
if(glyph == null) {
|
||||||
|
glyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WALLET);
|
||||||
|
}
|
||||||
glyph.setFontSize(10.0);
|
glyph.setFontSize(10.0);
|
||||||
getChildren().add(glyph);
|
getChildren().add(glyph);
|
||||||
}
|
}
|
||||||
|
|
|
@ -82,7 +82,8 @@ public class FontAwesome5 extends GlyphFont {
|
||||||
USER_PLUS('\uf234'),
|
USER_PLUS('\uf234'),
|
||||||
USER_SLASH('\uf506'),
|
USER_SLASH('\uf506'),
|
||||||
WALLET('\uf555'),
|
WALLET('\uf555'),
|
||||||
WEIGHT('\uf496');
|
WEIGHT('\uf496'),
|
||||||
|
WIFI('\uf1eb');
|
||||||
|
|
||||||
private final char ch;
|
private final char ch;
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
package com.sparrowwallet.sparrow.io;
|
||||||
|
|
||||||
|
import javafx.beans.property.StringProperty;
|
||||||
|
|
||||||
|
public interface CardImport extends ImportExport {
|
||||||
|
StringProperty messageProperty();
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.sparrowwallet.sparrow.io;
|
||||||
|
|
||||||
|
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||||
|
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface KeystoreCardImport extends CardImport {
|
||||||
|
Keystore getKeystore(String pin, List<ChildNumber> derivation) throws ImportException;
|
||||||
|
String getKeystoreImportDescription(int account);
|
||||||
|
}
|
|
@ -0,0 +1,84 @@
|
||||||
|
package com.sparrowwallet.sparrow.io.ckcard;
|
||||||
|
|
||||||
|
import com.sparrowwallet.drongo.ExtendedKey;
|
||||||
|
import com.sparrowwallet.drongo.KeyDerivation;
|
||||||
|
import com.sparrowwallet.drongo.Utils;
|
||||||
|
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||||
|
import com.sparrowwallet.drongo.protocol.Base58;
|
||||||
|
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||||
|
import com.sparrowwallet.drongo.wallet.KeystoreSource;
|
||||||
|
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||||
|
import javafx.beans.property.StringProperty;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import javax.smartcardio.CardException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class CardApi {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CardApi.class);
|
||||||
|
|
||||||
|
private final CardProtocol cardProtocol;
|
||||||
|
private String cvc;
|
||||||
|
|
||||||
|
public CardApi(String cvc) throws CardException {
|
||||||
|
this.cardProtocol = new CardProtocol();
|
||||||
|
this.cvc = cvc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void initialize() throws CardException {
|
||||||
|
cardProtocol.verify();
|
||||||
|
cardProtocol.setup(cvc, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
CardStatus getStatus() throws CardException {
|
||||||
|
return cardProtocol.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
void checkWait(CardStatus cardStatus, StringProperty messageProperty) throws CardException {
|
||||||
|
if(cardStatus.auth_delay != null) {
|
||||||
|
int delay = cardStatus.auth_delay.intValue();
|
||||||
|
while(delay > 0) {
|
||||||
|
messageProperty.set("Auth delay, waiting " + delay + "s...");
|
||||||
|
CardWait cardWait = cardProtocol.authWait();
|
||||||
|
if(cardWait.success) {
|
||||||
|
delay = cardWait.auth_delay == null ? 0 : cardWait.auth_delay.intValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDerivation(List<ChildNumber> derivation) throws CardException {
|
||||||
|
cardProtocol.derive(cvc, derivation);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Keystore getKeystore() throws CardException {
|
||||||
|
CardStatus cardStatus = cardProtocol.getStatus();
|
||||||
|
|
||||||
|
CardXpub masterXpub = cardProtocol.xpub(cvc, true);
|
||||||
|
ExtendedKey masterXpubkey = ExtendedKey.fromDescriptor(Base58.encodeChecked(masterXpub.xpub));
|
||||||
|
String masterFingerprint = Utils.bytesToHex(masterXpubkey.getKey().getFingerprint());
|
||||||
|
|
||||||
|
KeyDerivation keyDerivation = new KeyDerivation(masterFingerprint, cardStatus.getDerivation());
|
||||||
|
|
||||||
|
CardXpub derivedXpub = cardProtocol.xpub(cvc, false);
|
||||||
|
ExtendedKey derivedXpubkey = ExtendedKey.fromDescriptor(Base58.encodeChecked(derivedXpub.xpub));
|
||||||
|
|
||||||
|
Keystore keystore = new Keystore();
|
||||||
|
keystore.setLabel(WalletModel.TAPSIGNER.toDisplayString());
|
||||||
|
keystore.setKeyDerivation(keyDerivation);
|
||||||
|
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
|
||||||
|
keystore.setExtendedPublicKey(derivedXpubkey);
|
||||||
|
keystore.setWalletModel(WalletModel.TAPSIGNER);
|
||||||
|
|
||||||
|
return keystore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void disconnect() {
|
||||||
|
try {
|
||||||
|
cardProtocol.disconnect();
|
||||||
|
} catch(CardException e) {
|
||||||
|
log.warn("Error disconnecting from card reader", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.sparrowwallet.sparrow.io.ckcard;
|
||||||
|
|
||||||
|
import javax.smartcardio.CardException;
|
||||||
|
|
||||||
|
public class CardAuthorizationException extends CardException {
|
||||||
|
public CardAuthorizationException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CardAuthorizationException(Throwable cause) {
|
||||||
|
super(cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CardAuthorizationException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.sparrowwallet.sparrow.io.ckcard;
|
||||||
|
|
||||||
|
public class CardBackup extends CardResponse {
|
||||||
|
byte[] data;
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package com.sparrowwallet.sparrow.io.ckcard;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class CardCerts {
|
||||||
|
List<byte[]> cert_chain;
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.sparrowwallet.sparrow.io.ckcard;
|
||||||
|
|
||||||
|
public class CardChange extends CardResponse {
|
||||||
|
boolean success;
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.sparrowwallet.sparrow.io.ckcard;
|
||||||
|
|
||||||
|
import com.sparrowwallet.drongo.crypto.ECDSASignature;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
public class CardDerive extends CardResponse {
|
||||||
|
byte[] sig;
|
||||||
|
byte[] chain_code;
|
||||||
|
byte[] master_pubkey;
|
||||||
|
byte[] pubkey;
|
||||||
|
|
||||||
|
public ECDSASignature getSignature() {
|
||||||
|
BigInteger r = new BigInteger(1, Arrays.copyOfRange(sig, 0, 32));
|
||||||
|
BigInteger s = new BigInteger(1, Arrays.copyOfRange(sig, 32, 64));
|
||||||
|
return new ECDSASignature(r, s);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,266 @@
|
||||||
|
package com.sparrowwallet.sparrow.io.ckcard;
|
||||||
|
|
||||||
|
import com.google.gson.*;
|
||||||
|
import com.sparrowwallet.drongo.Utils;
|
||||||
|
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||||
|
import com.sparrowwallet.drongo.crypto.ECDSASignature;
|
||||||
|
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||||
|
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||||
|
import org.bitcoin.NativeSecp256k1;
|
||||||
|
import org.bitcoin.NativeSecp256k1Util;
|
||||||
|
import org.bitcoin.Secp256k1Context;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import javax.smartcardio.CardException;
|
||||||
|
import java.lang.reflect.Type;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.security.SignatureException;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
public class CardProtocol {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CardProtocol.class);
|
||||||
|
|
||||||
|
public static final byte[] OPENDIME_HEADER = "OPENDIME".getBytes(StandardCharsets.UTF_8);
|
||||||
|
public static final List<byte[]> FACTORY_ROOT_KEYS = List.of(Utils.hexToBytes("03028a0e89e70d0ec0d932053a89ab1da7d9182bdc6d2f03e706ee99517d05d9e1"));
|
||||||
|
public static final int MAX_PATH_DEPTH = 8;
|
||||||
|
|
||||||
|
private final CardTransport cardTransport;
|
||||||
|
private final Gson gson;
|
||||||
|
|
||||||
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
|
||||||
|
private byte[] cardPubkey;
|
||||||
|
private byte[] lastCardNonce;
|
||||||
|
|
||||||
|
public CardProtocol() throws CardException {
|
||||||
|
this.cardTransport = new CardTransport();
|
||||||
|
this.gson = new GsonBuilder().registerTypeAdapter(byte[].class, new ByteArrayToHexTypeAdapter()).create();
|
||||||
|
}
|
||||||
|
|
||||||
|
public CardStatus getStatus() throws CardException {
|
||||||
|
JsonObject status = send("status");
|
||||||
|
CardStatus cardStatus = gson.fromJson(status, CardStatus.class);
|
||||||
|
if(cardPubkey == null) {
|
||||||
|
cardPubkey = cardStatus.pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cardStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CardCerts getCerts() throws CardException {
|
||||||
|
JsonObject certs = send("certs");
|
||||||
|
return gson.fromJson(certs, CardCerts.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void verify() throws CardException {
|
||||||
|
CardCerts cardCerts = getCerts();
|
||||||
|
|
||||||
|
byte[] userNonce = getNonce();
|
||||||
|
byte[] cardNonce = lastCardNonce;
|
||||||
|
JsonObject certs = send("check", Map.of("nonce", userNonce));
|
||||||
|
CardSignature cardSignature = gson.fromJson(certs, CardSignature.class);
|
||||||
|
Sha256Hash verificationData = getVerificationData(cardNonce, userNonce, new byte[0]);
|
||||||
|
ECDSASignature ecdsaSignature = cardSignature.getSignature();
|
||||||
|
if(!ecdsaSignature.verify(verificationData.getBytes(), cardPubkey)) {
|
||||||
|
throw new CardException("Card authentication failure: Provided signature did not match public key");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] pubkey = cardPubkey;
|
||||||
|
for(byte[] cert : cardCerts.cert_chain) {
|
||||||
|
Sha256Hash pubkeyHash = Sha256Hash.of(pubkey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ECKey recoveredKey = ECKey.signedHashToKey(pubkeyHash, cert, false);
|
||||||
|
pubkey = recoveredKey.getPubKey();
|
||||||
|
} catch(SignatureException e) {
|
||||||
|
throw new CardException("Card signature error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] rootPubKey = pubkey;
|
||||||
|
if(FACTORY_ROOT_KEYS.stream().noneMatch(key -> Arrays.equals(key, rootPubKey))) {
|
||||||
|
throw new CardException("Card authentication failure: Could not verify to root certificate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public CardRead read(String cvc) throws CardException {
|
||||||
|
Map<String, Object> args = new HashMap<>();
|
||||||
|
args.put("nonce", getNonce());
|
||||||
|
|
||||||
|
JsonObject read = sendAuth("read", args, cvc);
|
||||||
|
return gson.fromJson(read, CardRead.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CardSetup setup(String cvc, byte[] entropy) throws CardException {
|
||||||
|
if(entropy == null) {
|
||||||
|
entropy = Sha256Hash.hashTwice(secureRandom.generateSeed(128));
|
||||||
|
}
|
||||||
|
|
||||||
|
if(entropy.length != 32) {
|
||||||
|
throw new IllegalArgumentException("Invalid entropy length of " + entropy.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> args = new HashMap<>();
|
||||||
|
args.put("chain_code", entropy);
|
||||||
|
JsonObject setup = sendAuth("new", args, cvc);
|
||||||
|
return gson.fromJson(setup, CardSetup.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CardWait authWait() throws CardException {
|
||||||
|
JsonObject wait = send("wait");
|
||||||
|
return gson.fromJson(wait, CardWait.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CardXpub xpub(String cvc, boolean master) throws CardException {
|
||||||
|
Map<String, Object> args = new HashMap<>();
|
||||||
|
args.put("master", master);
|
||||||
|
|
||||||
|
JsonObject xpub = sendAuth("xpub", args, cvc);
|
||||||
|
return gson.fromJson(xpub, CardXpub.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CardDerive derive(String cvc, List<ChildNumber> path) throws CardException {
|
||||||
|
if(path.stream().anyMatch(childNumber -> !childNumber.isHardened())) {
|
||||||
|
throw new IllegalArgumentException("Derivation path cannot contain unhardened components");
|
||||||
|
}
|
||||||
|
|
||||||
|
if(path.size() > MAX_PATH_DEPTH) {
|
||||||
|
throw new IllegalArgumentException("Derivation path cannot have more than " + MAX_PATH_DEPTH + " components");
|
||||||
|
}
|
||||||
|
|
||||||
|
if(lastCardNonce == null || cardPubkey == null) {
|
||||||
|
getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] userNonce = getNonce();
|
||||||
|
Map<String, Object> args = new HashMap<>();
|
||||||
|
args.put("path", path.stream().map(cn -> Integer.toUnsignedLong(cn.i())).collect(Collectors.toList()));
|
||||||
|
args.put("nonce", userNonce);
|
||||||
|
|
||||||
|
JsonObject derive = sendAuth("derive", args, cvc);
|
||||||
|
return gson.fromJson(derive, CardDerive.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CardSign sign(String cvc, List<ChildNumber> subpath, Sha256Hash digest) throws CardException {
|
||||||
|
if(subpath.stream().anyMatch(ChildNumber::isHardened)) {
|
||||||
|
throw new IllegalArgumentException("Derivation path cannot contain hardened components");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> args = new HashMap<>();
|
||||||
|
args.put("subpath", subpath.stream().map(cn -> Integer.toUnsignedLong(cn.i())).collect(Collectors.toList()));
|
||||||
|
args.put("digest", digest.getBytes());
|
||||||
|
|
||||||
|
for(int attempt = 0; attempt < 5; attempt++) {
|
||||||
|
try {
|
||||||
|
JsonObject sign = sendAuth("sign", args, cvc);
|
||||||
|
CardSign cardSign = gson.fromJson(sign, CardSign.class);
|
||||||
|
if(!cardSign.getSignature().verify(digest.getBytes(), cardSign.pubkey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cardSign;
|
||||||
|
} catch(CardUnluckyNumberException e) {
|
||||||
|
log.debug("Got unlucky number signing, trying again...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new CardSignFailedException("Failed to sign digest after 5 tries.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public CardChange change(String currentCvc, String newCvc) throws CardException {
|
||||||
|
if(newCvc.length() < 6 || newCvc.length() > 32) {
|
||||||
|
throw new IllegalArgumentException("CVC cannot be of length " + newCvc.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> args = new HashMap<>();
|
||||||
|
args.put("data", newCvc.getBytes(StandardCharsets.UTF_8));
|
||||||
|
JsonObject change = sendAuth("change", args, currentCvc);
|
||||||
|
return gson.fromJson(change, CardChange.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CardBackup backup(String cvc) throws CardException {
|
||||||
|
JsonObject backup = sendAuth("backup", new HashMap<>(), cvc);
|
||||||
|
return gson.fromJson(backup, CardBackup.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void disconnect() throws CardException {
|
||||||
|
cardTransport.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject send(String cmd) throws CardException {
|
||||||
|
return send(cmd, Collections.emptyMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject send(String cmd, Map<String, Object> args) throws CardException {
|
||||||
|
JsonObject jsonObject = cardTransport.send(cmd, args);
|
||||||
|
if(jsonObject.get("card_nonce") != null) {
|
||||||
|
lastCardNonce = gson.fromJson(jsonObject, CardResponse.class).card_nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject sendAuth(String cmd, Map<String, Object> args, String cvc) throws CardException {
|
||||||
|
addAuth(cmd, args, cvc);
|
||||||
|
return send(cmd, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addAuth(String cmd, Map<String, Object> args, String cvc) throws CardException {
|
||||||
|
if(cvc.length() < 6 || cvc.length() > 32) {
|
||||||
|
throw new IllegalArgumentException("CVC cannot be of length " + cvc.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
if(lastCardNonce == null || cardPubkey == null) {
|
||||||
|
getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ECKey ephemeralKey = new ECKey(secureRandom);
|
||||||
|
if(Secp256k1Context.isEnabled()) {
|
||||||
|
byte[] sessionKey = NativeSecp256k1.createECDHSecret(ephemeralKey.getPrivKeyBytes(), cardPubkey);
|
||||||
|
byte[] md = Sha256Hash.hash(Utils.concat(lastCardNonce, cmd.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
byte[] mask = Arrays.copyOf(Utils.xor(sessionKey, md), cvc.length());
|
||||||
|
byte[] xcvc = Utils.xor(cvc.getBytes(StandardCharsets.UTF_8), mask);
|
||||||
|
|
||||||
|
args.put("epubkey", ephemeralKey.getPubKey());
|
||||||
|
args.put("xcvc", xcvc);
|
||||||
|
|
||||||
|
if(cmd.equals("sign") && args.get("digest") instanceof byte[] digestBytes) {
|
||||||
|
args.put("digest", Utils.xor(digestBytes, sessionKey));
|
||||||
|
} else if(cmd.equals("change") && args.get("data") instanceof byte[] dataBytes) {
|
||||||
|
args.put("data", Utils.xor(dataBytes, Arrays.copyOf(sessionKey, dataBytes.length)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(NativeSecp256k1Util.AssertFailException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Sha256Hash getVerificationData(byte[] cardNonce, byte[] userNonce, byte[] commandData) {
|
||||||
|
byte[] data = new byte[40 + commandData.length];
|
||||||
|
System.arraycopy(OPENDIME_HEADER, 0, data, 0, 8);
|
||||||
|
System.arraycopy(cardNonce, 0, data, 8, 16);
|
||||||
|
System.arraycopy(userNonce, 0, data, 24, 16);
|
||||||
|
System.arraycopy(commandData, 0, data, 40, commandData.length);
|
||||||
|
return Sha256Hash.of(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] getNonce() {
|
||||||
|
byte[] nonce = new byte[16];
|
||||||
|
secureRandom.nextBytes(nonce);
|
||||||
|
return nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class ByteArrayToHexTypeAdapter implements JsonSerializer<byte[]>, JsonDeserializer<byte[]> {
|
||||||
|
public byte[] deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||||
|
return Utils.hexToBytes(json.getAsString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonElement serialize(byte[] src, Type typeOfSrc, JsonSerializationContext context) {
|
||||||
|
return new JsonPrimitive(Utils.bytesToHex(src));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package com.sparrowwallet.sparrow.io.ckcard;
|
||||||
|
|
||||||
|
public class CardRead {
|
||||||
|
byte[] sig;
|
||||||
|
byte[] pubkey;
|
||||||
|
byte[] card_nonce;
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.sparrowwallet.sparrow.io.ckcard;
|
||||||
|
|
||||||
|
public class CardResponse {
|
||||||
|
byte[] card_nonce;
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.sparrowwallet.sparrow.io.ckcard;
|
||||||
|
|
||||||
|
public class CardSetup extends CardResponse {
|
||||||
|
int slot;
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
package com.sparrowwallet.sparrow.io.ckcard;
|
||||||
|
|
||||||
|
import com.sparrowwallet.drongo.crypto.ECDSASignature;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
public class CardSign extends CardResponse {
|
||||||
|
int slot;
|
||||||
|
byte[] sig;
|
||||||
|
byte[] pubkey;
|
||||||
|
|
||||||
|
public ECDSASignature getSignature() {
|
||||||
|
if(sig != null) {
|
||||||
|
BigInteger r = new BigInteger(1, Arrays.copyOfRange(sig, 0, 32));
|
||||||
|
BigInteger s = new BigInteger(1, Arrays.copyOfRange(sig, 32, 64));
|
||||||
|
return new ECDSASignature(r, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.sparrowwallet.sparrow.io.ckcard;
|
||||||
|
|
||||||
|
import javax.smartcardio.CardException;
|
||||||
|
|
||||||
|
public class CardSignFailedException extends CardException {
|
||||||
|
public CardSignFailedException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CardSignFailedException(Throwable cause) {
|
||||||
|
super(cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CardSignFailedException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.sparrowwallet.sparrow.io.ckcard;
|
||||||
|
|
||||||
|
import com.sparrowwallet.drongo.crypto.ECDSASignature;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
public class CardSignature extends CardResponse {
|
||||||
|
byte[] auth_sig;
|
||||||
|
|
||||||
|
public ECDSASignature getSignature() {
|
||||||
|
BigInteger r = new BigInteger(1, Arrays.copyOfRange(auth_sig, 0, 32));
|
||||||
|
BigInteger s = new BigInteger(1, Arrays.copyOfRange(auth_sig, 32, 64));
|
||||||
|
return new ECDSASignature(r, s);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
package com.sparrowwallet.sparrow.io.ckcard;
|
||||||
|
|
||||||
|
import com.google.common.io.BaseEncoding;
|
||||||
|
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||||
|
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
public class CardStatus extends CardResponse {
|
||||||
|
int proto;
|
||||||
|
String ver;
|
||||||
|
BigInteger birth;
|
||||||
|
Boolean tapsigner;
|
||||||
|
List<BigInteger> path;
|
||||||
|
BigInteger num_backups;
|
||||||
|
byte[] pubkey;
|
||||||
|
BigInteger auth_delay;
|
||||||
|
boolean testnet;
|
||||||
|
|
||||||
|
public boolean isInitialized() {
|
||||||
|
return path != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getIdentifier() {
|
||||||
|
byte[] pubkeyHash = Sha256Hash.hash(pubkey);
|
||||||
|
String base32 = BaseEncoding.base32().encode(Arrays.copyOfRange(pubkeyHash, 8, 32));
|
||||||
|
return base32.replaceAll("(.{5})", "$1-");
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ChildNumber> getDerivation() {
|
||||||
|
if(isInitialized()) {
|
||||||
|
return path.stream().map(i -> new ChildNumber(i.intValue())).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "CardStatus{" +
|
||||||
|
"proto=" + proto +
|
||||||
|
", ver='" + ver + '\'' +
|
||||||
|
", birth=" + birth +
|
||||||
|
", tapsigner=" + tapsigner +
|
||||||
|
", path=" + path +
|
||||||
|
", num_backups=" + num_backups +
|
||||||
|
", pubkey=" + Arrays.toString(pubkey) +
|
||||||
|
", auth_delay=" + auth_delay +
|
||||||
|
", testnet=" + testnet +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,163 @@
|
||||||
|
package com.sparrowwallet.sparrow.io.ckcard;
|
||||||
|
|
||||||
|
import co.nstant.in.cbor.CborBuilder;
|
||||||
|
import co.nstant.in.cbor.CborDecoder;
|
||||||
|
import co.nstant.in.cbor.CborEncoder;
|
||||||
|
import co.nstant.in.cbor.CborException;
|
||||||
|
import co.nstant.in.cbor.builder.ArrayBuilder;
|
||||||
|
import co.nstant.in.cbor.builder.MapBuilder;
|
||||||
|
import co.nstant.in.cbor.model.*;
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonPrimitive;
|
||||||
|
import com.sparrowwallet.drongo.Utils;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import javax.smartcardio.*;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class CardTransport {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CardTransport.class);
|
||||||
|
|
||||||
|
public static final String APPID = "f0436f696e6b697465434152447631";
|
||||||
|
private static final int CBOR_CLA = 0x00;
|
||||||
|
private static final int CBOR_INS = 0xCB;
|
||||||
|
private static final int SW_OKAY = 0x9000;
|
||||||
|
|
||||||
|
private final Card connection;
|
||||||
|
|
||||||
|
public CardTransport() throws CardException {
|
||||||
|
TerminalFactory tf = TerminalFactory.getDefault();
|
||||||
|
List<CardTerminal> terminals = tf.terminals().list();
|
||||||
|
if(terminals.isEmpty()) {
|
||||||
|
throw new IllegalStateException("No reader connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
CardTerminal cardTerminal = (CardTerminal)terminals.get(0);
|
||||||
|
connection = cardTerminal.connect("*");
|
||||||
|
|
||||||
|
CardChannel cardChannel = connection.getBasicChannel();
|
||||||
|
ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(0, 0xA4, 4, 0, Utils.hexToBytes(APPID.toUpperCase())));
|
||||||
|
if(resp.getSW() != SW_OKAY) {
|
||||||
|
throw new CardException("Card returned response of " + resp.getSW());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonObject send(String cmd, Map<String, Object> args) throws CardException {
|
||||||
|
Map<String, Object> sendMap = new LinkedHashMap<>();
|
||||||
|
sendMap.put("cmd", cmd);
|
||||||
|
sendMap.putAll(args);
|
||||||
|
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
MapBuilder<CborBuilder> cborBuilder = new CborBuilder().addMap();
|
||||||
|
for(Map.Entry<String, Object> entry : sendMap.entrySet()) {
|
||||||
|
if(entry.getValue() instanceof String strValue) {
|
||||||
|
cborBuilder.put(entry.getKey(), strValue);
|
||||||
|
} else if(entry.getValue() instanceof byte[] byteValue) {
|
||||||
|
cborBuilder.put(entry.getKey(), byteValue);
|
||||||
|
} else if(entry.getValue() instanceof Long longValue) {
|
||||||
|
cborBuilder.put(entry.getKey(), longValue);
|
||||||
|
} else if(entry.getValue() instanceof Boolean booleanValue) {
|
||||||
|
cborBuilder.put(entry.getKey(), booleanValue);
|
||||||
|
} else if(entry.getValue() instanceof List<?> listValue) {
|
||||||
|
ArrayBuilder<MapBuilder<CborBuilder>> arrayBuilder = cborBuilder.putArray(entry.getKey());
|
||||||
|
for(Object value : listValue) {
|
||||||
|
if(value instanceof String strValue) {
|
||||||
|
arrayBuilder.add(strValue);
|
||||||
|
} else if(value instanceof byte[] byteValue) {
|
||||||
|
arrayBuilder.add(byteValue);
|
||||||
|
} else if(value instanceof Long longValue) {
|
||||||
|
arrayBuilder.add(longValue);
|
||||||
|
} else if(value instanceof Boolean booleanValue) {
|
||||||
|
arrayBuilder.add(booleanValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
arrayBuilder.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new CborEncoder(baos).encode(cborBuilder.end().build());
|
||||||
|
byte[] sendBytes = baos.toByteArray();
|
||||||
|
|
||||||
|
CardChannel cardChannel = connection.getBasicChannel();
|
||||||
|
ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(CBOR_CLA, CBOR_INS, 0, 0, sendBytes));
|
||||||
|
|
||||||
|
if(resp.getSW() != SW_OKAY) {
|
||||||
|
throw new CardException("Received error SW value " + resp.getSW());
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteArrayInputStream bais = new ByteArrayInputStream(resp.getData());
|
||||||
|
List<DataItem> dataItems = new CborDecoder(bais).decode();
|
||||||
|
for(DataItem dataItem : dataItems) {
|
||||||
|
if(dataItem instanceof co.nstant.in.cbor.model.Map map) {
|
||||||
|
JsonObject result = new JsonObject();
|
||||||
|
for(DataItem key : map.getKeys()) {
|
||||||
|
String strKey = key.toString();
|
||||||
|
result.add(strKey, getJsonElement(map.get(key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if(result.get("error") != null) {
|
||||||
|
String msg = result.get("error").getAsString();
|
||||||
|
int code = result.get("code") == null ? 500 : result.get("code").getAsInt();
|
||||||
|
String message = code + " on " + cmd + ": " + msg;
|
||||||
|
if(code == 205) {
|
||||||
|
throw new CardUnluckyNumberException(message);
|
||||||
|
} else if(code == 401) {
|
||||||
|
throw new CardAuthorizationException(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new CardException(code + " on " + cmd + ": " + msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(CborException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonElement getJsonElement(DataItem dataItem) {
|
||||||
|
if(dataItem instanceof UnicodeString strValue) {
|
||||||
|
return new JsonPrimitive(strValue.toString());
|
||||||
|
} else if(dataItem instanceof ByteString byteString) {
|
||||||
|
return new JsonPrimitive(Utils.bytesToHex(byteString.getBytes()));
|
||||||
|
} else if(dataItem instanceof UnsignedInteger unsignedInteger) {
|
||||||
|
return new JsonPrimitive(unsignedInteger.getValue());
|
||||||
|
} else if(dataItem instanceof SimpleValue simpleValue) {
|
||||||
|
return new JsonPrimitive(simpleValue.getValue() == SimpleValueType.TRUE.getValue());
|
||||||
|
} else if(dataItem instanceof Array array) {
|
||||||
|
JsonArray jsonArray = new JsonArray();
|
||||||
|
for(DataItem item : array.getDataItems()) {
|
||||||
|
jsonArray.add(getJsonElement(item));
|
||||||
|
}
|
||||||
|
return jsonArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalArgumentException("Cannot convert dataItem of type " + dataItem.getClass() + "to JsonElement");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void disconnect() throws CardException {
|
||||||
|
connection.disconnect(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isReaderAvailable() {
|
||||||
|
try {
|
||||||
|
TerminalFactory tf = TerminalFactory.getDefault();
|
||||||
|
return !tf.terminals().list().isEmpty();
|
||||||
|
} catch(Exception e) {
|
||||||
|
log.error("Error detecting card terminals", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.sparrowwallet.sparrow.io.ckcard;
|
||||||
|
|
||||||
|
import javax.smartcardio.CardException;
|
||||||
|
|
||||||
|
public class CardUnluckyNumberException extends CardException {
|
||||||
|
public CardUnluckyNumberException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CardUnluckyNumberException(Throwable cause) {
|
||||||
|
super(cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CardUnluckyNumberException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
package com.sparrowwallet.sparrow.io.ckcard;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
|
public class CardWait {
|
||||||
|
boolean success;
|
||||||
|
BigInteger auth_delay;
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.sparrowwallet.sparrow.io.ckcard;
|
||||||
|
|
||||||
|
public class CardXpub {
|
||||||
|
byte[] xpub;
|
||||||
|
}
|
|
@ -0,0 +1,68 @@
|
||||||
|
package com.sparrowwallet.sparrow.io.ckcard;
|
||||||
|
|
||||||
|
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||||
|
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||||
|
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||||
|
import com.sparrowwallet.sparrow.io.KeystoreCardImport;
|
||||||
|
import com.sparrowwallet.sparrow.io.ImportException;
|
||||||
|
import javafx.beans.property.SimpleStringProperty;
|
||||||
|
import javafx.beans.property.StringProperty;
|
||||||
|
|
||||||
|
import javax.smartcardio.CardException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class CkCard implements KeystoreCardImport {
|
||||||
|
private final StringProperty messageProperty = new SimpleStringProperty("");
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Keystore getKeystore(String pin, List<ChildNumber> derivation) throws ImportException {
|
||||||
|
if(pin.length() < 6) {
|
||||||
|
throw new ImportException("PIN too short");
|
||||||
|
}
|
||||||
|
|
||||||
|
if(pin.length() > 32) {
|
||||||
|
throw new ImportException("PIN too long");
|
||||||
|
}
|
||||||
|
|
||||||
|
CardApi cardApi = null;
|
||||||
|
try {
|
||||||
|
cardApi = new CardApi(pin);
|
||||||
|
CardStatus cardStatus = cardApi.getStatus();
|
||||||
|
if(!cardStatus.isInitialized()) {
|
||||||
|
cardApi.initialize();
|
||||||
|
cardStatus = cardApi.getStatus();
|
||||||
|
}
|
||||||
|
cardApi.checkWait(cardStatus, messageProperty);
|
||||||
|
|
||||||
|
if(!derivation.equals(cardStatus.getDerivation())) {
|
||||||
|
cardApi.setDerivation(derivation);
|
||||||
|
}
|
||||||
|
return cardApi.getKeystore();
|
||||||
|
} catch(CardException e) {
|
||||||
|
throw new ImportException(e);
|
||||||
|
} finally {
|
||||||
|
if(cardApi != null) {
|
||||||
|
cardApi.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getKeystoreImportDescription(int account) {
|
||||||
|
return "Import the keystore from your Tapsigner by placing it on the card reader.";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "Tapsigner";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public WalletModel getWalletModel() {
|
||||||
|
return WalletModel.TAPSIGNER;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StringProperty messageProperty() {
|
||||||
|
return messageProperty;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,29 +1,36 @@
|
||||||
package com.sparrowwallet.sparrow.keystoreimport;
|
package com.sparrowwallet.sparrow.keystoreimport;
|
||||||
|
|
||||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||||
|
import com.sparrowwallet.sparrow.control.CardImportPane;
|
||||||
import com.sparrowwallet.sparrow.control.FileKeystoreImportPane;
|
import com.sparrowwallet.sparrow.control.FileKeystoreImportPane;
|
||||||
import com.sparrowwallet.sparrow.control.TitledDescriptionPane;
|
import com.sparrowwallet.sparrow.control.TitledDescriptionPane;
|
||||||
import com.sparrowwallet.sparrow.io.*;
|
import com.sparrowwallet.sparrow.io.*;
|
||||||
|
import com.sparrowwallet.sparrow.io.ckcard.CkCard;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
import javafx.scene.control.Accordion;
|
import javafx.scene.control.Accordion;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import javax.smartcardio.TerminalFactory;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class HwAirgappedController extends KeystoreImportDetailController {
|
public class HwAirgappedController extends KeystoreImportDetailController {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(HwAirgappedController.class);
|
||||||
|
|
||||||
@FXML
|
@FXML
|
||||||
private Accordion importAccordion;
|
private Accordion importAccordion;
|
||||||
|
|
||||||
public void initializeView() {
|
public void initializeView() {
|
||||||
List<KeystoreFileImport> importers = Collections.emptyList();
|
List<KeystoreFileImport> fileImporters = Collections.emptyList();
|
||||||
if(getMasterController().getWallet().getPolicyType().equals(PolicyType.SINGLE)) {
|
if(getMasterController().getWallet().getPolicyType().equals(PolicyType.SINGLE)) {
|
||||||
importers = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY());
|
fileImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY());
|
||||||
} else if(getMasterController().getWallet().getPolicyType().equals(PolicyType.MULTI)) {
|
} else if(getMasterController().getWallet().getPolicyType().equals(PolicyType.MULTI)) {
|
||||||
importers = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Jade(), new KeystoneMultisig(), new PassportMultisig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY());
|
fileImporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Jade(), new KeystoneMultisig(), new PassportMultisig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY());
|
||||||
}
|
}
|
||||||
|
|
||||||
for(KeystoreFileImport importer : importers) {
|
for(KeystoreFileImport importer : fileImporters) {
|
||||||
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
|
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
|
||||||
FileKeystoreImportPane importPane = new FileKeystoreImportPane(getMasterController().getWallet(), importer, getMasterController().getRequiredDerivation());
|
FileKeystoreImportPane importPane = new FileKeystoreImportPane(getMasterController().getWallet(), importer, getMasterController().getRequiredDerivation());
|
||||||
if(getMasterController().getRequiredModel() == null || getMasterController().getRequiredModel() == importer.getWalletModel()) {
|
if(getMasterController().getRequiredModel() == null || getMasterController().getRequiredModel() == importer.getWalletModel()) {
|
||||||
|
@ -32,6 +39,30 @@ public class HwAirgappedController extends KeystoreImportDetailController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<KeystoreCardImport> cardImporters = Collections.emptyList();
|
||||||
|
if(isReaderAvailable()) {
|
||||||
|
cardImporters = List.of(new CkCard());
|
||||||
|
}
|
||||||
|
for(KeystoreCardImport importer : cardImporters) {
|
||||||
|
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
|
||||||
|
CardImportPane importPane = new CardImportPane(getMasterController().getWallet(), importer, getMasterController().getRequiredDerivation());
|
||||||
|
if(getMasterController().getRequiredModel() == null || getMasterController().getRequiredModel() == importer.getWalletModel()) {
|
||||||
|
importAccordion.getPanes().add(importPane);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
importAccordion.getPanes().sort(Comparator.comparing(o -> ((TitledDescriptionPane) o).getTitle()));
|
importAccordion.getPanes().sort(Comparator.comparing(o -> ((TitledDescriptionPane) o).getTitle()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static boolean isReaderAvailable() {
|
||||||
|
try {
|
||||||
|
TerminalFactory tf = TerminalFactory.getDefault();
|
||||||
|
return !tf.terminals().list().isEmpty();
|
||||||
|
} catch(Exception e) {
|
||||||
|
log.error("Error detecting card terminals", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ import javafx.application.Platform;
|
||||||
import javafx.event.ActionEvent;
|
import javafx.event.ActionEvent;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
import javafx.fxml.Initializable;
|
import javafx.fxml.Initializable;
|
||||||
|
import javafx.scene.Node;
|
||||||
import javafx.scene.control.*;
|
import javafx.scene.control.*;
|
||||||
import javafx.scene.input.Clipboard;
|
import javafx.scene.input.Clipboard;
|
||||||
import javafx.scene.input.ClipboardContent;
|
import javafx.scene.input.ClipboardContent;
|
||||||
|
@ -306,7 +307,11 @@ public class KeystoreController extends WalletFormController implements Initiali
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Glyph getTypeIcon(Keystore keystore) {
|
private Node getTypeIcon(Keystore keystore) {
|
||||||
|
return new WalletIcon(getWalletForm().getStorage(), getWalletForm().getWallet(), keystore, getDefaultTypeIcon(keystore));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Glyph getDefaultTypeIcon(Keystore keystore) {
|
||||||
switch (keystore.getSource()) {
|
switch (keystore.getSource()) {
|
||||||
case HW_USB:
|
case HW_USB:
|
||||||
return new Glyph(FontAwesome5Brands.FONT_NAME, FontAwesome5Brands.Glyph.USB);
|
return new Glyph(FontAwesome5Brands.FONT_NAME, FontAwesome5Brands.Glyph.USB);
|
||||||
|
|
|
@ -49,4 +49,5 @@ open module com.sparrowwallet.sparrow {
|
||||||
requires net.coobird.thumbnailator;
|
requires net.coobird.thumbnailator;
|
||||||
requires com.github.hervegirod;
|
requires com.github.hervegirod;
|
||||||
requires com.sparrowwallet.toucan;
|
requires com.sparrowwallet.toucan;
|
||||||
|
requires java.smartcardio;
|
||||||
}
|
}
|
|
@ -23,7 +23,7 @@
|
||||||
<Form fx:id="keystoreForm" GridPane.columnIndex="0" GridPane.rowIndex="0">
|
<Form fx:id="keystoreForm" GridPane.columnIndex="0" GridPane.rowIndex="0">
|
||||||
<Fieldset inputGrow="SOMETIMES" text="">
|
<Fieldset inputGrow="SOMETIMES" text="">
|
||||||
<Field text="Type:">
|
<Field text="Type:">
|
||||||
<Label fx:id="type" graphicTextGap="8"/>
|
<Label fx:id="type" graphicTextGap="5"/>
|
||||||
<Button fx:id="viewSeedButton" text="View Seed..." graphicTextGap="5" onAction="#showPrivate">
|
<Button fx:id="viewSeedButton" text="View Seed..." graphicTextGap="5" onAction="#showPrivate">
|
||||||
<graphic>
|
<graphic>
|
||||||
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="KEY" />
|
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="KEY" />
|
||||||
|
|
8
src/main/resources/image/tapsigner-icon-invert.svg
Normal file
8
src/main/resources/image/tapsigner-icon-invert.svg
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="15px" height="15px" viewBox="0 0 15 15" version="1.1">
|
||||||
|
<g id="surface1">
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(81.568627%,81.176471%,81.176471%);fill-opacity:1;" d="M 3.128906 2.40625 L 11.730469 2.40625 C 12.167969 2.40625 12.523438 2.78125 12.523438 3.246094 L 12.523438 11.753906 C 12.523438 12.21875 12.167969 12.59375 11.730469 12.59375 L 3.128906 12.59375 C 2.6875 12.59375 2.332031 12.21875 2.332031 11.753906 L 2.332031 3.246094 C 2.332031 2.78125 2.6875 2.40625 3.128906 2.40625 Z M 3.128906 2.40625 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(23.137255%,23.137255%,23.137255%);fill-opacity:1;" d="M 4.773438 9.8125 L 4.773438 6.101562 L 3.042969 6.101562 L 3.042969 5.175781 L 7.699219 5.175781 L 7.699219 6.101562 L 5.96875 6.101562 L 5.96875 9.8125 C 5.96875 9.8125 4.773438 9.8125 4.773438 9.8125 Z M 4.773438 9.8125 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(23.137255%,23.137255%,23.137255%);fill-opacity:1;" d="M 8.203125 9.308594 L 10.804688 9.308594 C 11.15625 9.308594 11.296875 9.0625 11.296875 8.660156 C 11.296875 8.414062 11.246094 8.167969 10.777344 8.019531 L 8.9375 7.410156 C 8.378906 7.222656 8.136719 6.90625 8.136719 6.261719 C 8.136719 5.601562 8.457031 5.175781 9.195312 5.175781 L 11.71875 5.175781 L 11.71875 5.679688 L 9.308594 5.679688 C 8.957031 5.679688 8.785156 5.84375 8.785156 6.277344 C 8.785156 6.527344 8.808594 6.773438 9.222656 6.910156 L 10.980469 7.496094 C 11.597656 7.699219 11.945312 7.933594 11.945312 8.660156 C 11.945312 9.289062 11.695312 9.8125 10.980469 9.8125 L 8.203125 9.8125 Z M 8.203125 9.308594 "/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.7 KiB |
8
src/main/resources/image/tapsigner-icon.svg
Normal file
8
src/main/resources/image/tapsigner-icon.svg
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="15px" height="15px" viewBox="0 0 15 15" version="1.1">
|
||||||
|
<g id="surface1">
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(23.137255%,23.137255%,23.137255%);fill-opacity:1;" d="M 3.128906 2.40625 L 11.730469 2.40625 C 12.167969 2.40625 12.523438 2.78125 12.523438 3.246094 L 12.523438 11.753906 C 12.523438 12.21875 12.167969 12.59375 11.730469 12.59375 L 3.128906 12.59375 C 2.6875 12.59375 2.332031 12.21875 2.332031 11.753906 L 2.332031 3.246094 C 2.332031 2.78125 2.6875 2.40625 3.128906 2.40625 Z M 3.128906 2.40625 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(89.411765%,89.411765%,89.411765%);fill-opacity:1;" d="M 4.773438 9.8125 L 4.773438 6.101562 L 3.042969 6.101562 L 3.042969 5.175781 L 7.699219 5.175781 L 7.699219 6.101562 L 5.96875 6.101562 L 5.96875 9.8125 C 5.96875 9.8125 4.773438 9.8125 4.773438 9.8125 Z M 4.773438 9.8125 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(89.411765%,89.411765%,89.411765%);fill-opacity:1;" d="M 8.203125 9.308594 L 10.804688 9.308594 C 11.15625 9.308594 11.296875 9.0625 11.296875 8.660156 C 11.296875 8.414062 11.246094 8.167969 10.777344 8.019531 L 8.9375 7.410156 C 8.378906 7.222656 8.136719 6.90625 8.136719 6.261719 C 8.136719 5.601562 8.457031 5.175781 9.195312 5.175781 L 11.71875 5.175781 L 11.71875 5.679688 L 9.308594 5.679688 C 8.957031 5.679688 8.785156 5.84375 8.785156 6.277344 C 8.785156 6.527344 8.808594 6.773438 9.222656 6.910156 L 10.980469 7.496094 C 11.597656 7.699219 11.945312 7.933594 11.945312 8.660156 C 11.945312 9.289062 11.695312 9.8125 10.980469 9.8125 L 8.203125 9.8125 Z M 8.203125 9.308594 "/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/main/resources/image/tapsigner.png
Normal file
BIN
src/main/resources/image/tapsigner.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2 KiB |
BIN
src/main/resources/image/tapsigner@2x.png
Normal file
BIN
src/main/resources/image/tapsigner@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.8 KiB |
BIN
src/main/resources/image/tapsigner@3x.png
Normal file
BIN
src/main/resources/image/tapsigner@3x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.3 KiB |
Loading…
Reference in a new issue