initialize and import tapsigner as keystore

This commit is contained in:
Craig Raw 2023-01-25 14:19:22 +02:00
parent 7c64d689fd
commit 6b59ff60ad
37 changed files with 1050 additions and 24 deletions

View file

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

@ -1 +1 @@
Subproject commit c642f7414a35c9ad69439687ba8724b76d9a6fb1 Subproject commit 2168c56de924dd7bff5458b32f9a60a6c79066a1

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
package com.sparrowwallet.sparrow.io;
import javafx.beans.property.StringProperty;
public interface CardImport extends ImportExport {
StringProperty messageProperty();
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.io.ckcard;
public class CardBackup extends CardResponse {
byte[] data;
}

View file

@ -0,0 +1,7 @@
package com.sparrowwallet.sparrow.io.ckcard;
import java.util.List;
public class CardCerts {
List<byte[]> cert_chain;
}

View file

@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.io.ckcard;
public class CardChange extends CardResponse {
boolean success;
}

View file

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

View file

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

View file

@ -0,0 +1,7 @@
package com.sparrowwallet.sparrow.io.ckcard;
public class CardRead {
byte[] sig;
byte[] pubkey;
byte[] card_nonce;
}

View file

@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.io.ckcard;
public class CardResponse {
byte[] card_nonce;
}

View file

@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.io.ckcard;
public class CardSetup extends CardResponse {
int slot;
}

View file

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

View file

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

View file

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

View file

@ -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 +
'}';
}
}

View file

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

View file

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

View file

@ -0,0 +1,8 @@
package com.sparrowwallet.sparrow.io.ckcard;
import java.math.BigInteger;
public class CardWait {
boolean success;
BigInteger auth_delay;
}

View file

@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.io.ckcard;
public class CardXpub {
byte[] xpub;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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" />

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB