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) {