diff --git a/build.gradle b/build.gradle
index ed3a93d8..ed7868d7 100644
--- a/build.gradle
+++ b/build.gradle
@@ -218,7 +218,7 @@ jlink {
appVersion = "${sparrowVersion}"
skipInstaller = os.macOsX || properties.skipInstallers
imageOptions = []
- installerOptions = ['--file-associations', 'src/main/deploy/psbt.properties', '--file-associations', 'src/main/deploy/txn.properties', '--file-associations', 'src/main/deploy/bitcoin.properties', '--file-associations', 'src/main/deploy/auth47.properties', '--license-file', 'LICENSE']
+ installerOptions = ['--file-associations', 'src/main/deploy/psbt.properties', '--file-associations', 'src/main/deploy/txn.properties', '--file-associations', 'src/main/deploy/bitcoin.properties', '--file-associations', 'src/main/deploy/auth47.properties', '--file-associations', 'src/main/deploy/lightning.properties', '--license-file', 'LICENSE']
if(os.windows) {
installerOptions += ['--win-per-user-install', '--win-dir-chooser', '--win-menu', '--win-menu-group', 'Sparrow', '--win-shortcut', '--resource-dir', 'src/main/deploy/package/windows/']
imageOptions += ['--icon', 'src/main/deploy/package/windows/sparrow.ico']
diff --git a/drongo b/drongo
index ddaf698c..8cdea775 160000
--- a/drongo
+++ b/drongo
@@ -1 +1 @@
-Subproject commit ddaf698c1011e5b01c7c3ed1e7693145ba5531ac
+Subproject commit 8cdea77562643edf9d460a594178c1f44deeb248
diff --git a/src/main/deploy/lightning.properties b/src/main/deploy/lightning.properties
new file mode 100644
index 00000000..c021cecb
--- /dev/null
+++ b/src/main/deploy/lightning.properties
@@ -0,0 +1,2 @@
+mime-type=x-scheme-handler/lightning
+description=LNURL URI
\ No newline at end of file
diff --git a/src/main/deploy/package/linux/Sparrow.desktop b/src/main/deploy/package/linux/Sparrow.desktop
index e4067c2c..04fce05c 100644
--- a/src/main/deploy/package/linux/Sparrow.desktop
+++ b/src/main/deploy/package/linux/Sparrow.desktop
@@ -6,4 +6,4 @@ Icon=/opt/sparrow/lib/Sparrow.png
Terminal=false
Type=Application
Categories=Unknown
-MimeType=application/psbt;application/bitcoin-transaction;x-scheme-handler/bitcoin;x-scheme-handler/auth47
\ No newline at end of file
+MimeType=application/psbt;application/bitcoin-transaction;x-scheme-handler/bitcoin;x-scheme-handler/auth47;x-scheme-handler/lightning
\ No newline at end of file
diff --git a/src/main/deploy/package/osx/Info.plist b/src/main/deploy/package/osx/Info.plist
index 27fe379a..388a8d80 100644
--- a/src/main/deploy/package/osx/Info.plist
+++ b/src/main/deploy/package/osx/Info.plist
@@ -53,6 +53,14 @@
auth47
+
+ CFBundleURLName
+ com.sparrowwallet.sparrow.lightning
+ CFBundleURLSchemes
+
+ lightning
+
+
UTImportedTypeDeclarations
diff --git a/src/main/deploy/package/windows/main.wxs b/src/main/deploy/package/windows/main.wxs
index f3b9e6bf..c3b10c64 100644
--- a/src/main/deploy/package/windows/main.wxs
+++ b/src/main/deploy/package/windows/main.wxs
@@ -97,6 +97,16 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java
index 3f1574ff..93146252 100644
--- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java
+++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java
@@ -3,7 +3,16 @@ package com.sparrowwallet.sparrow;
import com.google.common.eventbus.Subscribe;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.drongo.Network;
+import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.address.Address;
+import com.sparrowwallet.drongo.bip47.PaymentCode;
+import com.sparrowwallet.drongo.crypto.ECKey;
+import com.sparrowwallet.drongo.crypto.EncryptionType;
+import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
+import com.sparrowwallet.drongo.crypto.Key;
+import com.sparrowwallet.drongo.policy.PolicyType;
+import com.sparrowwallet.drongo.wallet.*;
+import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.net.Auth47;
import com.sparrowwallet.drongo.protocol.BlockHeader;
@@ -11,10 +20,6 @@ import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.uri.BitcoinURI;
-import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
-import com.sparrowwallet.drongo.wallet.KeystoreSource;
-import com.sparrowwallet.drongo.wallet.Wallet;
-import com.sparrowwallet.drongo.wallet.WalletTransaction;
import com.sparrowwallet.sparrow.control.TextUtils;
import com.sparrowwallet.sparrow.control.TrayManager;
import com.sparrowwallet.sparrow.event.*;
@@ -881,6 +886,8 @@ public class AppServices {
openBitcoinUri(uri);
} else if(("auth47").equals(uri.getScheme())) {
openAuth47Uri(uri);
+ } else if(("lightning").equals(uri.getScheme())) {
+ openLnurlAuthUri(uri);
}
});
}
@@ -903,11 +910,13 @@ public class AppServices {
private static void openBitcoinUri(URI uri) {
try {
BitcoinURI bitcoinURI = new BitcoinURI(uri.toString());
- Wallet wallet = selectWallet(null, null, "pay from");
+ List policyTypes = Arrays.asList(PolicyType.values());
+ List scriptTypes = Arrays.asList(ScriptType.ADDRESSABLE_TYPES);
+ Wallet wallet = selectWallet(policyTypes, scriptTypes, true, false, "pay from", false);
if(wallet != null) {
final Wallet sendingWallet = wallet;
- EventManager.get().post(new SendActionEvent(sendingWallet, new ArrayList<>(sendingWallet.getWalletUtxos().keySet())));
+ EventManager.get().post(new SendActionEvent(sendingWallet, new ArrayList<>(sendingWallet.getWalletUtxos().keySet()), true));
Platform.runLater(() -> EventManager.get().post(new SendPaymentsEvent(sendingWallet, List.of(bitcoinURI.toPayment()))));
}
} catch(Exception e) {
@@ -915,31 +924,97 @@ public class AppServices {
}
}
- public static void openAuth47Uri(URI uri) {
+ private static void openAuth47Uri(URI uri) {
try {
Auth47 auth47 = new Auth47(uri);
- Wallet wallet = selectWallet(null, Boolean.TRUE, "authenticate using your payment code");
+ List scriptTypes = PaymentCode.SEGWIT_SCRIPT_TYPES;
+ Wallet wallet = selectWallet(List.of(PolicyType.SINGLE), scriptTypes, false, true, "login to " + auth47.getCallback().getHost(), true);
if(wallet != null) {
try {
auth47.sendResponse(wallet);
- showSuccessDialog("Successful authentication", "Successfully authenticated to " + auth47.getCallback() + ".");
+ EventManager.get().post(new StatusEvent("Successfully authenticated to " + auth47.getCallback().getHost()));
} catch(Exception e) {
log.error("Error authenticating auth47 URI", e);
showErrorDialog("Error authenticating", "Failed to authenticate.\n\n" + e.getMessage());
}
}
} catch(Exception e) {
+ log.error("Not a valid auth47 URI", e);
showErrorDialog("Not a valid auth47 URI", e.getMessage());
}
}
- private static Wallet selectWallet(ScriptType scriptType, Boolean hasPaymentCode, String actionDescription) {
+ private static void openLnurlAuthUri(URI uri) {
+ try {
+ LnurlAuth lnurlAuth = new LnurlAuth(uri);
+ List scriptTypes = ScriptType.getAddressableScriptTypes(PolicyType.SINGLE);
+ Wallet wallet = selectWallet(List.of(PolicyType.SINGLE), scriptTypes, true, true, lnurlAuth.getLoginMessage(), true);
+
+ if(wallet != null) {
+ if(wallet.isEncrypted()) {
+ Storage storage = AppServices.get().getOpenWallets().get(wallet);
+ Wallet copy = wallet.copy();
+ WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
+ Optional password = dlg.showAndWait();
+ if(password.isPresent()) {
+ Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
+ keyDerivationService.setOnSucceeded(workerStateEvent -> {
+ EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Done"));
+ ECKey encryptionFullKey = keyDerivationService.getValue();
+ Key key = new Key(encryptionFullKey.getPrivKeyBytes(), storage.getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
+ copy.decrypt(key);
+ try {
+ lnurlAuth.sendResponse(copy);
+ EventManager.get().post(new StatusEvent("Successfully authenticated to " + lnurlAuth.getDomain()));
+ } catch(Exception e) {
+ showErrorDialog("Error authenticating", "Failed to authenticate.\n\n" + e.getMessage());
+ } finally {
+ key.clear();
+ encryptionFullKey.clear();
+ password.get().clear();
+ }
+ });
+ keyDerivationService.setOnFailed(workerStateEvent -> {
+ EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Failed"));
+ if(keyDerivationService.getException() instanceof InvalidPasswordException) {
+ Optional optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
+ if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
+ Platform.runLater(() -> openLnurlAuthUri(uri));
+ }
+ } else {
+ log.error("Error deriving wallet key", keyDerivationService.getException());
+ }
+ });
+ EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.START, "Decrypting wallet..."));
+ keyDerivationService.start();
+ }
+ } else {
+ try {
+ lnurlAuth.sendResponse(wallet);
+ EventManager.get().post(new StatusEvent("Successfully authenticated to " + lnurlAuth.getDomain()));
+ } catch(LnurlAuth.LnurlAuthException e) {
+ showErrorDialog("Error authenticating", "Failed to authenticate.\n\n" + e.getMessage());
+ } catch(Exception e) {
+ log.error("Failed to authenticate using LNURL-auth", e);
+ showErrorDialog("Error authenticating", "Failed to authenticate.\n\n" + e.getMessage());
+ }
+ }
+ }
+ } catch(Exception e) {
+ log.error("Not a valid LNURL-auth URI", e);
+ showErrorDialog("Not a valid LNURL-auth URI", e.getMessage());
+ }
+ }
+
+ private static Wallet selectWallet(List policyTypes, List scriptTypes, boolean taprootAllowed, boolean privateKeysRequired, String actionDescription, boolean alwaysAsk) {
Wallet wallet = null;
- List wallets = get().getOpenWallets().keySet().stream().filter(w -> (scriptType == null || w.getScriptType() == scriptType) && (hasPaymentCode == null || w.hasPaymentCode())).collect(Collectors.toList());
+ List wallets = get().getOpenWallets().keySet().stream().filter(w -> w.isValid() && policyTypes.contains(w.getPolicyType()) && scriptTypes.contains(w.getScriptType())
+ && (!privateKeysRequired || w.getKeystores().stream().allMatch(Keystore::hasPrivateKey))).collect(Collectors.toList());
if(wallets.isEmpty()) {
- showErrorDialog("No wallet available", "Open a" + (hasPaymentCode == null ? "" : " software") + (scriptType == null ? "" : " " + scriptType.getDescription()) + " wallet to " + actionDescription + ".");
- } else if(wallets.size() == 1) {
+ boolean taprootOpen = get().getOpenWallets().keySet().stream().anyMatch(w -> w.getScriptType() == ScriptType.P2TR);
+ showErrorDialog("No wallet available", "Open a" + (taprootOpen && !taprootAllowed ? " non-Taproot" : "") + (privateKeysRequired ? " software" : "") + " wallet to " + actionDescription + ".");
+ } else if(wallets.size() == 1 && !alwaysAsk) {
wallet = wallets.iterator().next();
} else {
ChoiceDialog walletChoiceDialog = new ChoiceDialog<>(wallets.iterator().next(), wallets);
diff --git a/src/main/java/com/sparrowwallet/sparrow/event/SendActionEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/SendActionEvent.java
index 971343bc..79a21340 100644
--- a/src/main/java/com/sparrowwallet/sparrow/event/SendActionEvent.java
+++ b/src/main/java/com/sparrowwallet/sparrow/event/SendActionEvent.java
@@ -8,10 +8,16 @@ import java.util.List;
public class SendActionEvent extends FunctionActionEvent {
private final List utxos;
+ private final boolean selectIfEmpty;
public SendActionEvent(Wallet wallet, List utxos) {
+ this(wallet, utxos, false);
+ }
+
+ public SendActionEvent(Wallet wallet, List utxos, boolean selectIfEmpty) {
super(Function.SEND, wallet);
this.utxos = utxos;
+ this.selectIfEmpty = selectIfEmpty;
}
public List getUtxos() {
@@ -20,6 +26,6 @@ public class SendActionEvent extends FunctionActionEvent {
@Override
public boolean selectFunction() {
- return !getUtxos().isEmpty();
+ return selectIfEmpty || !getUtxos().isEmpty();
}
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/LnurlAuth.java b/src/main/java/com/sparrowwallet/sparrow/net/LnurlAuth.java
new file mode 100644
index 00000000..3fcb493e
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/net/LnurlAuth.java
@@ -0,0 +1,204 @@
+package com.sparrowwallet.sparrow.net;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.sparrowwallet.drongo.ExtendedKey;
+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.policy.PolicyType;
+import com.sparrowwallet.drongo.protocol.Bech32;
+import com.sparrowwallet.drongo.protocol.Sha256Hash;
+import com.sparrowwallet.drongo.wallet.Wallet;
+import com.sparrowwallet.sparrow.AppServices;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.*;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.*;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+public class LnurlAuth {
+ private static final Logger log = LoggerFactory.getLogger(LnurlAuth.class);
+
+ public static final ChildNumber LNURL_PURPOSE = new ChildNumber(138, true);
+
+ private final URL url;
+ private final byte[] k1;
+ private final String action;
+
+ public LnurlAuth(URI uri) throws MalformedURLException {
+ String lnurl = uri.getSchemeSpecificPart();
+ Bech32.Bech32Data bech32 = Bech32.decode(lnurl, 2000);
+ byte[] urlBytes = Bech32.convertBits(bech32.data, 0, bech32.data.length, 5, 8, false);
+ String strUrl = new String(urlBytes, StandardCharsets.UTF_8);
+ this.url = new URL(strUrl);
+
+ Map parameterMap = new LinkedHashMap<>();
+ String query = url.getQuery();
+ if(query == null) {
+ throw new IllegalArgumentException("No k1 parameter provided.");
+ }
+
+ if(query.startsWith("?")) {
+ query = query.substring(1);
+ }
+
+ String[] pairs = query.split("&");
+ for(String pair : pairs) {
+ int idx = pair.indexOf("=");
+ if(idx < 0) {
+ continue;
+ }
+ parameterMap.put(pair.substring(0, idx), pair.substring(idx + 1));
+ }
+
+ if(parameterMap.get("tag") == null || !parameterMap.get("tag").toLowerCase(Locale.ROOT).equals("login")) {
+ throw new IllegalArgumentException("Parameter tag was not set to login.");
+ }
+
+ if(parameterMap.get("k1") == null || parameterMap.get("k1").length() != 64) {
+ throw new IllegalArgumentException("Parameter k1 was absent or of incorrect length.");
+ }
+
+ try {
+ this.k1 = Utils.hexToBytes(parameterMap.get("k1"));
+ } catch(Exception e) {
+ throw new IllegalArgumentException("Parameter k1 was not a valid hexadecimal value.");
+ }
+
+ this.action = parameterMap.get("action") == null ? "login" : parameterMap.get("action").toLowerCase(Locale.ROOT);
+ }
+
+ public String getDomain() {
+ return url.getHost();
+ }
+
+ public String getLoginMessage() {
+ String domain = getDomain();
+ switch(action) {
+ case "register":
+ return "register an account on " + domain;
+ case "link":
+ return "link your existing account on " + domain;
+ case "auth":
+ return "authorise " + domain;
+ case "login":
+ default:
+ return "login to " + domain;
+ }
+ }
+
+ public void sendResponse(Wallet wallet) throws LnurlAuthException, IOException {
+ URL callback = getReturnURL(wallet);
+
+ Proxy proxy = AppServices.getProxy();
+ if(proxy == null && callback.getHost().toLowerCase(Locale.ROOT).endsWith(TorService.TOR_ADDRESS_SUFFIX)) {
+ throw new LnurlAuthException("A Tor proxy must be configured to authenticate this resource.");
+ }
+
+ HttpURLConnection connection = proxy == null ? (HttpURLConnection) callback.openConnection() : (HttpURLConnection) callback.openConnection(proxy);
+ connection.setRequestMethod("GET");
+ connection.setRequestProperty("Content-Type", "application/json");
+
+ StringBuilder res = new StringBuilder();
+ try(BufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
+ String responseLine;
+ while((responseLine = br.readLine()) != null) {
+ res.append(responseLine.trim());
+ }
+ }
+
+ if(log.isDebugEnabled()) {
+ log.debug("Received from " + callback + ": " + res);
+ }
+
+ JsonObject result = JsonParser.parseString(res.toString()).getAsJsonObject();
+ String status = result.get("status").getAsString();
+ if("OK".equals(status)) {
+ return;
+ } else if("ERROR".equals(status)) {
+ String reason = result.get("reason").getAsString();
+ throw new LnurlAuthException("Service returned error: " + reason);
+ } else {
+ throw new LnurlAuthException("Service returned unknown response: " + res);
+ }
+ }
+
+ private URL getReturnURL(Wallet wallet) {
+ try {
+ ECKey linkingKey = deriveLinkingKey(wallet);
+ byte[] signature = getSignature(linkingKey);
+ return new URL(url.toString() + "&sig=" + Utils.bytesToHex(signature) + "&key=" + Utils.bytesToHex(linkingKey.getPubKey()));
+ } catch(MalformedURLException e) {
+ throw new IllegalStateException("Malformed return URL", e);
+ }
+ }
+
+ private ECKey deriveLinkingKey(Wallet wallet) {
+ if(wallet.getPolicyType() != PolicyType.SINGLE) {
+ throw new IllegalArgumentException("Only singlesig wallets can authenticate.");
+ }
+
+ if(wallet.isEncrypted()) {
+ throw new IllegalArgumentException("Wallet must be decrypted.");
+ }
+
+ try {
+ ExtendedKey masterPrivateKey = wallet.getKeystores().get(0).getExtendedMasterPrivateKey();
+ ECKey hashingKey = masterPrivateKey.getKey(List.of(LNURL_PURPOSE, ChildNumber.ZERO));
+ byte[] hash = getHmacSha256Hash(hashingKey.getPrivKeyBytes(), getDomain());
+ List pathIndexes = IntStream.range(0, 4).mapToLong(i -> ByteBuffer.wrap(hash, i * 4, 4).getInt() & 0xFFFFFFFFL)
+ .mapToObj(i -> new ChildNumber((int)i)).collect(Collectors.toList());
+
+ List derivationPath = new ArrayList<>();
+ derivationPath.add(LNURL_PURPOSE);
+ derivationPath.addAll(pathIndexes);
+ return masterPrivateKey.getKey(derivationPath);
+ } catch(Exception e) {
+ throw new IllegalStateException("Could not determine linking key", e);
+ }
+ }
+
+ private byte[] getSignature(ECKey linkingKey) {
+ ECDSASignature ecdsaSignature = linkingKey.signEcdsa(Sha256Hash.wrap(k1));
+ return ecdsaSignature.encodeToDER();
+ }
+
+ private static byte[] getHmacSha256Hash(byte[] key, String data) throws NoSuchAlgorithmException, InvalidKeyException {
+ Mac sha256_HMAC = Mac.getInstance("HmacSHA256");
+ SecretKeySpec secret_key = new SecretKeySpec(key, "HmacSHA256");
+ sha256_HMAC.init(secret_key);
+
+ return sha256_HMAC.doFinal(data.getBytes(StandardCharsets.UTF_8));
+ }
+
+ public static final class LnurlAuthException extends Exception {
+ public LnurlAuthException(String message) {
+ super(message);
+ }
+
+ public LnurlAuthException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public LnurlAuthException(Throwable cause) {
+ super(cause);
+ }
+
+ public LnurlAuthException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
+ super(message, cause, enableSuppression, writableStackTrace);
+ }
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/paynym/PayNym.java b/src/main/java/com/sparrowwallet/sparrow/paynym/PayNym.java
index cf34a8c9..04808cd1 100644
--- a/src/main/java/com/sparrowwallet/sparrow/paynym/PayNym.java
+++ b/src/main/java/com/sparrowwallet/sparrow/paynym/PayNym.java
@@ -10,6 +10,9 @@ import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.List;
+import static com.sparrowwallet.drongo.bip47.PaymentCode.SEGWIT_SCRIPT_TYPES;
+import static com.sparrowwallet.drongo.bip47.PaymentCode.V1_SCRIPT_TYPES;
+
public class PayNym {
private static final Logger log = LoggerFactory.getLogger(PayNym.class);
@@ -68,11 +71,11 @@ public class PayNym {
}
public static List getSegwitScriptTypes() {
- return List.of(ScriptType.P2PKH, ScriptType.P2SH_P2WPKH, ScriptType.P2WPKH);
+ return SEGWIT_SCRIPT_TYPES;
}
public static List getV1ScriptTypes() {
- return List.of(ScriptType.P2PKH);
+ return V1_SCRIPT_TYPES;
}
public static PayNym fromString(String strPaymentCode, String nymId, String nymName, boolean segwit, List following, List followers) {