From 176e440195f975253cdfeab08636b1a897bf78a5 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Tue, 31 Jan 2023 09:30:53 +0200 Subject: [PATCH] unseal satscard functionality added to sweep private key dialog --- build.gradle | 4 +- .../sparrow/control/DevicePane.java | 61 +++++++++++++++++- .../sparrow/control/DeviceUnsealDialog.java | 32 +++++++++ .../control/PrivateKeySweepDialog.java | 21 +++++- .../sparrow/event/DeviceUnsealedEvent.java | 22 +++++++ .../com/sparrowwallet/sparrow/io/CardApi.java | 55 +++++++++++++++- .../sparrow/io/ckcard/CardProtocol.java | 27 +++++++- .../sparrow/io/ckcard/CardStatus.java | 14 +++- .../sparrow/io/ckcard/CardUnseal.java | 15 +++++ .../sparrow/io/ckcard/CkCardApi.java | 48 ++++++++++++++ src/main/resources/image/satscard.png | Bin 0 -> 3758 bytes src/main/resources/image/satscard@2x.png | Bin 0 -> 4335 bytes src/main/resources/image/satscard@3x.png | Bin 0 -> 5635 bytes 13 files changed, 291 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/sparrow/control/DeviceUnsealDialog.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/event/DeviceUnsealedEvent.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardUnseal.java create mode 100644 src/main/resources/image/satscard.png create mode 100644 src/main/resources/image/satscard@2x.png create mode 100644 src/main/resources/image/satscard@3x.png diff --git a/build.gradle b/build.gradle index cb6a7b77..a4761cca 100644 --- a/build.gradle +++ b/build.gradle @@ -161,7 +161,8 @@ run { "--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow", "--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow", "--add-opens=java.base/java.net=com.sparrowwallet.sparrow", - "--add-opens=java.base/java.io=com.google.gson"] + "--add-opens=java.base/java.io=com.google.gson", + "--add-opens=java.smartcardio/sun.security.smartcardio=com.sparrowwallet.sparrow"] if(os.macOsX) { applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow-large.png", @@ -210,6 +211,7 @@ jlink { "--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow", "--add-opens=java.base/java.net=com.sparrowwallet.sparrow", "--add-opens=java.base/java.io=com.google.gson", + "--add-opens=java.smartcardio/sun.security.smartcardio=com.sparrowwallet.sparrow", "--add-reads=com.sparrowwallet.merged.module=java.desktop", "--add-reads=com.sparrowwallet.merged.module=java.sql", "--add-reads=com.sparrowwallet.merged.module=com.sparrowwallet.sparrow", diff --git a/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java b/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java index 3b2699bb..91a701d9 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java @@ -5,6 +5,7 @@ import com.sparrowwallet.drongo.ExtendedKey; import com.sparrowwallet.drongo.KeyDerivation; import com.sparrowwallet.drongo.OutputDescriptor; import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.crypto.ECKey; import com.sparrowwallet.drongo.policy.Policy; import com.sparrowwallet.drongo.policy.PolicyType; import com.sparrowwallet.drongo.protocol.ScriptType; @@ -65,6 +66,7 @@ public class DevicePane extends TitledDescriptionPane { private Button displayAddressButton; private Button signMessageButton; private Button discoverKeystoresButton; + private Button unsealButton; private final SimpleStringProperty passphrase = new SimpleStringProperty(""); private final SimpleStringProperty pin = new SimpleStringProperty(""); @@ -199,6 +201,32 @@ public class DevicePane extends TitledDescriptionPane { buttonBox.getChildren().addAll(setPassphraseButton, discoverKeystoresButton); } + public DevicePane(Device device, boolean defaultDevice) { + super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png"); + this.deviceOperation = DeviceOperation.UNSEAL; + this.wallet = null; + this.psbt = null; + this.outputDescriptor = null; + this.keyDerivation = null; + this.message = null; + this.device = device; + this.defaultDevice = defaultDevice; + this.availableAccounts = null; + + setDefaultStatus(); + showHideLink.setVisible(false); + + createUnsealButton(); + + initialise(device); + + messageProperty.addListener((observable, oldValue, newValue) -> { + Platform.runLater(() -> setDescription(newValue)); + }); + + buttonBox.getChildren().add(unsealButton); + } + private void initialise(Device device) { if(device.isNeedsPinSent()) { unlockButton.setDefaultButton(defaultDevice); @@ -342,6 +370,17 @@ public class DevicePane extends TitledDescriptionPane { discoverKeystoresButton.setVisible(false); } + private void createUnsealButton() { + unsealButton = new Button("Unseal"); + unsealButton.setAlignment(Pos.CENTER_RIGHT); + unsealButton.setOnAction(event -> { + unsealButton.setDisable(true); + unseal(); + }); + unsealButton.managedProperty().bind(unsealButton.visibleProperty()); + unsealButton.setVisible(false); + } + private void unlock(Device device) { if(device.getModel().requiresPinPrompt()) { promptPin(); @@ -816,6 +855,22 @@ public class DevicePane extends TitledDescriptionPane { getXpubsService.start(); } + private void unseal() { + if(device.isCard()) { + try { + CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get()); + Service unsealService = cardApi.getUnsealService(messageProperty); + handleCardOperation(unsealService, unsealButton, "Unseal", event -> { + EventManager.get().post(new DeviceUnsealedEvent(unsealService.getValue(), cardApi.getDefaultScriptType())); + }); + } catch(Exception e) { + log.error("Unseal Error: " + e.getMessage(), e); + setError("Unseal Error", e.getMessage()); + unsealButton.setDisable(false); + } + } + } + private void showOperationButton() { if(deviceOperation.equals(DeviceOperation.IMPORT)) { if(defaultDevice) { @@ -842,6 +897,10 @@ public class DevicePane extends TitledDescriptionPane { discoverKeystoresButton.setDefaultButton(defaultDevice); discoverKeystoresButton.setVisible(true); showHideLink.setVisible(false); + } else if(deviceOperation.equals(DeviceOperation.UNSEAL)) { + unsealButton.setDefaultButton(defaultDevice); + unsealButton.setVisible(true); + showHideLink.setVisible(false); } } @@ -959,6 +1018,6 @@ public class DevicePane extends TitledDescriptionPane { } public enum DeviceOperation { - IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE, DISCOVER_KEYSTORES; + IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE, DISCOVER_KEYSTORES, UNSEAL; } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/DeviceUnsealDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/DeviceUnsealDialog.java new file mode 100644 index 00000000..034bfd57 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/DeviceUnsealDialog.java @@ -0,0 +1,32 @@ +package com.sparrowwallet.sparrow.control; + +import com.google.common.eventbus.Subscribe; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.event.DeviceUnsealedEvent; +import com.sparrowwallet.sparrow.io.Device; + +import java.util.List; + +public class DeviceUnsealDialog extends DeviceDialog { + public DeviceUnsealDialog(List operationFingerprints) { + super(operationFingerprints); + EventManager.get().register(this); + setOnCloseRequest(event -> { + EventManager.get().unregister(this); + }); + } + + @Override + protected DevicePane getDevicePane(Device device, boolean defaultDevice) { + return new DevicePane(device, defaultDevice); + } + + @Subscribe + public void deviceUnsealed(DeviceUnsealedEvent event) { + setResult(new UnsealedKey(event.getPrivateKey(), event.getScriptType())); + } + + public record UnsealedKey(ECKey privateKey, ScriptType scriptType) {} +} diff --git a/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java index 3b8372be..6438ad8b 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java @@ -14,6 +14,7 @@ import com.sparrowwallet.drongo.psbt.PSBTInput; import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; +import com.sparrowwallet.sparrow.io.CardApi; import com.sparrowwallet.sparrow.net.ElectrumServer; import javafx.application.Platform; import javafx.collections.FXCollections; @@ -42,7 +43,7 @@ import tornadofx.control.Form; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -96,6 +97,14 @@ public class PrivateKeySweepDialog extends Dialog { HBox.setHgrow(key, Priority.ALWAYS); keyField.getInputs().add(keyBox); + if(CardApi.isReaderAvailable()) { + VBox cardButtonBox = new VBox(5); + Button cardKey = new Button("", getGlyph(FontAwesome5.Glyph.WIFI)); + cardKey.setOnAction(event -> unsealPrivateKey()); + cardButtonBox.getChildren().add(cardKey); + keyBox.getChildren().add(cardButtonBox); + } + Field keyScriptTypeField = new Field(); keyScriptTypeField.setText("Script Type:"); keyScriptType = new ComboBox<>(); @@ -279,6 +288,16 @@ public class PrivateKeySweepDialog extends Dialog { } } + private void unsealPrivateKey() { + DeviceUnsealDialog deviceUnsealDialog = new DeviceUnsealDialog(Collections.emptyList()); + Optional optPrivateKey = deviceUnsealDialog.showAndWait(); + if(optPrivateKey.isPresent()) { + DeviceUnsealDialog.UnsealedKey unsealedKey = optPrivateKey.get(); + key.setText(unsealedKey.privateKey().getPrivateKeyEncoded().toBase58()); + keyScriptType.setValue(unsealedKey.scriptType()); + } + } + private void createTransaction() { try { DumpedPrivateKey privateKey = getPrivateKey(); diff --git a/src/main/java/com/sparrowwallet/sparrow/event/DeviceUnsealedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/DeviceUnsealedEvent.java new file mode 100644 index 00000000..b8507442 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/DeviceUnsealedEvent.java @@ -0,0 +1,22 @@ +package com.sparrowwallet.sparrow.event; + +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.protocol.ScriptType; + +public class DeviceUnsealedEvent { + private final ECKey privateKey; + private final ScriptType scriptType; + + public DeviceUnsealedEvent(ECKey privateKey, ScriptType scriptType) { + this.privateKey = privateKey; + this.scriptType = scriptType; + } + + public ECKey getPrivateKey() { + return privateKey; + } + + public ScriptType getScriptType() { + return scriptType; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java b/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java index b7a3f3a4..318f9d3a 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java @@ -1,6 +1,8 @@ package com.sparrowwallet.sparrow.io; +import com.google.common.base.Throwables; import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.crypto.ECKey; import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.wallet.Keystore; @@ -13,7 +15,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.smartcardio.CardException; +import javax.smartcardio.CardTerminals; import javax.smartcardio.TerminalFactory; +import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.Collections; import java.util.List; @@ -45,6 +50,8 @@ public abstract class CardApi { public abstract WalletModel getCardType() throws CardException; + public abstract ScriptType getDefaultScriptType(); + public abstract Service getAuthDelayService() throws CardException; public abstract boolean requiresBackup() throws CardException; @@ -63,6 +70,8 @@ public abstract class CardApi { public abstract Service getSignMessageService(String message, ScriptType scriptType, List derivation, StringProperty messageProperty); + public abstract Service getUnsealService(StringProperty messageProperty); + public abstract void disconnect(); public static boolean isReaderAvailable() { @@ -70,9 +79,53 @@ public abstract class CardApi { TerminalFactory tf = TerminalFactory.getDefault(); return !tf.terminals().list().isEmpty(); } catch(Exception e) { - log.error("Error detecting card terminals", e); + Throwable cause = Throwables.getRootCause(e); + if(cause.getMessage().equals("SCARD_E_NO_SERVICE")) { + recoverNoService(); + } else { + log.error("Error detecting card terminals", e); + } } return false; } + + private static void recoverNoService() { + try { + Class pcscterminal = Class.forName("sun.security.smartcardio.PCSCTerminals"); + Field contextId = pcscterminal.getDeclaredField("contextId"); + contextId.setAccessible(true); + + if(contextId.getLong(pcscterminal) != 0L) + { + // First get a new context value + Class pcsc = Class.forName("sun.security.smartcardio.PCSC"); + Method SCardEstablishContext = pcsc.getDeclaredMethod( + "SCardEstablishContext", + Integer.TYPE); + SCardEstablishContext.setAccessible(true); + + Field SCARD_SCOPE_USER = pcsc.getDeclaredField("SCARD_SCOPE_USER"); + SCARD_SCOPE_USER.setAccessible(true); + + long newId = ((Long)SCardEstablishContext.invoke(pcsc, + new Object[] { SCARD_SCOPE_USER.getInt(pcsc) } + )); + contextId.setLong(pcscterminal, newId); + + + // Then clear the terminals in cache + TerminalFactory factory = TerminalFactory.getDefault(); + CardTerminals terminals = factory.terminals(); + Field fieldTerminals = pcscterminal.getDeclaredField("terminals"); + fieldTerminals.setAccessible(true); + Class classMap = Class.forName("java.util.Map"); + Method clearMap = classMap.getDeclaredMethod("clear"); + + clearMap.invoke(fieldTerminals.get(terminals)); + } + } catch(Exception e) { + log.error("Failed to recover card service", e); + } + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardProtocol.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardProtocol.java index 96ca9c70..628d0c01 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardProtocol.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardProtocol.java @@ -188,6 +188,16 @@ public class CardProtocol { return gson.fromJson(backup, CardBackup.class); } + public CardUnseal unseal(String cvc) throws CardException { + CardStatus status = getStatus(); + + Map args = new HashMap<>(); + args.put("slot", status.getSlot()); + + JsonObject unseal = sendAuth("unseal", args, cvc); + return gson.fromJson(unseal, CardUnseal.class); + } + public void disconnect() throws CardException { cardTransport.disconnect(); } @@ -206,11 +216,18 @@ public class CardProtocol { } private JsonObject sendAuth(String cmd, Map args, String cvc) throws CardException { - addAuth(cmd, args, cvc); - return send(cmd, args); + byte[] sessionKey = addAuth(cmd, args, cvc); + JsonObject jsonObject = send(cmd, args); + + if(jsonObject.get("privkey") != null) { + byte[] privKeyBytes = Utils.hexToBytes(jsonObject.get("privkey").getAsString()); + jsonObject.add("privkey", new JsonPrimitive(Utils.bytesToHex(Utils.xor(sessionKey, privKeyBytes)))); + } + + return jsonObject; } - public void addAuth(String cmd, Map args, String cvc) throws CardException { + public byte[] addAuth(String cmd, Map args, String cvc) throws CardException { if(cvc.length() < 6 || cvc.length() > 32) { throw new IllegalArgumentException("CVC cannot be of length " + cvc.length()); } @@ -235,6 +252,10 @@ public class CardProtocol { } else if(cmd.equals("change") && args.get("data") instanceof byte[] dataBytes) { args.put("data", Utils.xor(dataBytes, Arrays.copyOf(sessionKey, dataBytes.length))); } + + return sessionKey; + } else { + throw new IllegalStateException("Native library libsecp256k1 required but not enabled"); } } catch(NativeSecp256k1Util.AssertFailException e) { throw new RuntimeException(e); diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardStatus.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardStatus.java index 26f6e7e0..2d133696 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardStatus.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardStatus.java @@ -17,12 +17,14 @@ public class CardStatus extends CardResponse { boolean tapsigner; List path; BigInteger num_backups; + List slots; + String addr; byte[] pubkey; BigInteger auth_delay; boolean testnet; public boolean isInitialized() { - return path != null; + return getCardType() != WalletModel.TAPSIGNER || path != null; } public String getIdentifier() { @@ -47,6 +49,14 @@ public class CardStatus extends CardResponse { return tapsigner ? WalletModel.TAPSIGNER : WalletModel.SATSCARD; } + public int getSlot() { + if(slots == null || slots.isEmpty()) { + return 0; + } + + return slots.get(0).intValue(); + } + @Override public String toString() { return "CardStatus{" + @@ -56,6 +66,8 @@ public class CardStatus extends CardResponse { ", tapsigner=" + tapsigner + ", path=" + path + ", num_backups=" + num_backups + + ", slots=" + slots + + ", addr='" + addr + '\'' + ", pubkey=" + Arrays.toString(pubkey) + ", auth_delay=" + auth_delay + ", testnet=" + testnet + diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardUnseal.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardUnseal.java new file mode 100644 index 00000000..67a8885d --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardUnseal.java @@ -0,0 +1,15 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +import com.sparrowwallet.drongo.crypto.ECKey; + +public class CardUnseal extends CardResponse { + int slot; + byte[] privkey; + byte[] pubkey; + byte[] master_pk; + byte[] chain_code; + + public ECKey getPrivateKey() { + return ECKey.fromPrivate(privkey); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CkCardApi.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CkCardApi.java index 0b90ec04..2fb4f811 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CkCardApi.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CkCardApi.java @@ -61,6 +61,11 @@ public class CkCardApi extends CardApi { return cardStatus.getCardType(); } + @Override + public ScriptType getDefaultScriptType() { + return ScriptType.P2WPKH; + } + CardStatus getStatus() throws CardException { CardStatus cardStatus = cardProtocol.getStatus(); if(cardType != null && cardStatus.getCardType() != cardType) { @@ -239,6 +244,16 @@ public class CkCardApi extends CardApi { } } + @Override + public Service getUnsealService(StringProperty messageProperty) { + return new UnsealService(messageProperty); + } + + ECKey unseal() throws CardException { + CardUnseal cardUnseal = cardProtocol.unseal(cvc); + return cardUnseal.getPrivateKey(); + } + @Override public void disconnect() { try { @@ -302,6 +317,10 @@ public class CkCardApi extends CardApi { @Override protected PSBT call() throws Exception { CardStatus cardStatus = getStatus(); + if(cardStatus.getCardType() != WalletModel.TAPSIGNER) { + throw new IllegalStateException("Please use a " + WalletModel.TAPSIGNER.toDisplayString() + " to sign transactions."); + } + checkWait(cardStatus, new SimpleIntegerProperty(), messageProperty); sign(wallet, psbt); @@ -359,6 +378,10 @@ public class CkCardApi extends CardApi { @Override protected String call() throws Exception { CardStatus cardStatus = getStatus(); + if(cardStatus.getCardType() != WalletModel.TAPSIGNER) { + throw new IllegalStateException("Please use a " + WalletModel.TAPSIGNER.toDisplayString() + " to sign messages."); + } + checkWait(cardStatus, new SimpleIntegerProperty(), messageProperty); return signMessage(message, scriptType, derivation); @@ -366,4 +389,29 @@ public class CkCardApi extends CardApi { }; } } + + public class UnsealService extends Service { + private final StringProperty messageProperty; + + public UnsealService(StringProperty messageProperty) { + this.messageProperty = messageProperty; + } + + @Override + protected Task createTask() { + return new Task<>() { + @Override + protected ECKey call() throws Exception { + CardStatus cardStatus = getStatus(); + if(cardStatus.getCardType() != WalletModel.SATSCARD) { + throw new IllegalStateException("Please use a " + WalletModel.SATSCARD.toDisplayString() + " to unseal private keys."); + } + + checkWait(cardStatus, new SimpleIntegerProperty(), messageProperty); + + return unseal(); + } + }; + } + } } diff --git a/src/main/resources/image/satscard.png b/src/main/resources/image/satscard.png new file mode 100644 index 0000000000000000000000000000000000000000..cafed8481b50a54828e1203f39ca4ddf293f03bc GIT binary patch literal 3758 zcmdT{dsGzX6`uu4fC?Id79rAgfds*Q%45=Z0T2WQDpxDAz#Cz1KVR^SNUC`YysqPUN}Wmt#RA$7dWsbc zc>{_C{vcKu^b;EmL4~Yv7rluUr6xuVZihXQd0)Z(n^IKFnC%*iz2v0D24@EE{5p}xuSTThMR!kg`iA2~(>H3N)tGEoua7ELxC%Rxv*62 z912{47!`mGv1_wQ&98(oP&8)H;EwGkzVmk5wY$ORw}EMx)|P8bI7Z`Ir(=%QxiA@@ z83B6Y$CC$5F=i;jwQ{~QGUvkF&}Ir_R_YK~?fe@2(9~%qVxa^>q+GFB3Oc1wn*cfm z6o>{dgf$`oBGd`RPA~tT)KOUZ_e`8UowNJO*{e_Xq0HXIKX3-P!Dmw7P_2Bj-oM+X z**$ZEyQya8H_8kIeHl~f*#?4^BQn0H<+&&@_P}hwYz+cCAnt2(<@09D$b|yi#fO=f z1MAM8i_QNZSa-e~7DSGwFuhBf)Fi2wdpg_LQaVd&jsg{_C3IE=7yz!3<)Q8wQ*M%T zbl9B0XX)@T_FV4TCFkf+fy0d!qcNuzAu~LM0yQi~MPd|@#0kI&&Hgn(XUV$haeX~e zWl9P--suQEE~m26SFx3;DH1qABHA8OdbI8;5^yM1G zkCs3|tdWE7hOgKVX|aSA#A!e<(`4OQUsX%B!_Q0`bxXzJuOf)6>(@C8O`e*42bqn+AbL``2wQ zi1UIT{^$JfZl7ILx5FgrxlE(nuOAykVbRaxFEm~m`S{3^RP1m}>@m7+!-kU1HSd?C zR&*A`N9-G1ZauL{!D@+0+IM?$&&s}noPmX(#&GMCLNzC^3_tuN^r62pm-r%TaBcs% zusO6dM=J{3(b(9Ss~!+%{35$OB=zamd;MpBInl7CzU{A&EMn(p%d$Hcd^#1}_X^VQDWYcJL-)c@YD_3!mvT(>b}-MZ4#A5S%C?^nE) zHPG6Yk*ZBItupl;Jl$6Dd1HR>Q1;WOIyyR*`E;*YQ&861 zfA=rVQM+m`?}m6ezG*rv^wGkgpq@C@5ZP7G-U07l8`~%8dmB;pCqOiMAZG9F_uBq- z{d#@Xh31RYsb5(+2Qr59vn#{9B7^?U&8|EaQ;XCdGP7ja(OCmaw@&!}wmVl|)mg;d zs2H-=_sO%S(#CGH{d7

(tB99RK=_-Va{Xe(`c&=;>3{(_YyPSm;>Q^sz^W``g=% zH(Q^{m)=vnCg>fzMec!f6f5snEh#(LhE9~dJNBsLMCa2!-PKTR`a-CKwczU2x{1%? z)1gn+5AZiXo(_Nho5XXx8?{L}`u#zWwjp`opUfqDFclPbuJ}@OZCD2LTT7Xmp*Xqf Hm2Lk8ApFB( literal 0 HcmV?d00001 diff --git a/src/main/resources/image/satscard@2x.png b/src/main/resources/image/satscard@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ec5686f145a3ad6aa48de730eb30c67aa6fba151 GIT binary patch literal 4335 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 CVbsF_ literal 0 HcmV?d00001 diff --git a/src/main/resources/image/satscard@3x.png b/src/main/resources/image/satscard@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..4440c1c5e3804612b752b8c03f0e7027f904fa3f GIT binary patch literal 5635 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 literal 0 HcmV?d00001