diff --git a/build.gradle b/build.gradle
index 63465f4f..ed3a93d8 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', '--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', '--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/src/main/deploy/auth47.properties b/src/main/deploy/auth47.properties
new file mode 100644
index 00000000..5c9fd070
--- /dev/null
+++ b/src/main/deploy/auth47.properties
@@ -0,0 +1,2 @@
+mime-type=x-scheme-handler/auth47
+description=Auth47 Authentication 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 07af3c3f..e4067c2c 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
\ No newline at end of file
+MimeType=application/psbt;application/bitcoin-transaction;x-scheme-handler/bitcoin;x-scheme-handler/auth47
\ 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 d3bbcd17..27fe379a 100644
--- a/src/main/deploy/package/osx/Info.plist
+++ b/src/main/deploy/package/osx/Info.plist
@@ -45,6 +45,14 @@
bitcoin
+
+ CFBundleURLName
+ com.sparrowwallet.sparrow.auth47
+ CFBundleURLSchemes
+
+ auth47
+
+
UTImportedTypeDeclarations
diff --git a/src/main/deploy/package/windows/main.wxs b/src/main/deploy/package/windows/main.wxs
index 5933cc6b..f3b9e6bf 100644
--- a/src/main/deploy/package/windows/main.wxs
+++ b/src/main/deploy/package/windows/main.wxs
@@ -77,6 +77,16 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java
index d6281e77..7543b552 100644
--- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java
+++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java
@@ -4,6 +4,8 @@ import com.google.common.eventbus.Subscribe;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.address.Address;
+import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
+import com.sparrowwallet.sparrow.net.Auth47;
import com.sparrowwallet.drongo.protocol.BlockHeader;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Transaction;
@@ -27,7 +29,6 @@ import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.concurrent.ScheduledService;
-import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.concurrent.Worker;
import javafx.fxml.FXMLLoader;
@@ -47,6 +48,7 @@ import javafx.stage.Window;
import javafx.util.Duration;
import org.berndpruenster.netlayer.tor.Tor;
import org.controlsfx.control.HyperlinkLabel;
+import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -720,6 +722,14 @@ public class AppServices {
return showAlertDialog(title, content == null ? "See log file (Help menu)" : content, Alert.AlertType.ERROR, buttons);
}
+ public static Optional showSuccessDialog(String title, String content, ButtonType... buttons) {
+ Glyph successGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CHECK_CIRCLE);
+ successGlyph.getStyleClass().add("success");
+ successGlyph.setFontSize(50);
+
+ return showAlertDialog(title, content, Alert.AlertType.INFORMATION, successGlyph, buttons);
+ }
+
public static Optional showAlertDialog(String title, String content, Alert.AlertType alertType, ButtonType... buttons) {
return showAlertDialog(title, content, alertType, null, buttons);
}
@@ -863,6 +873,8 @@ public class AppServices {
Platform.runLater(() -> {
if("bitcoin".equals(uri.getScheme())) {
openBitcoinUri(uri);
+ } else if(("auth47").equals(uri.getScheme())) {
+ openAuth47Uri(uri);
}
});
}
@@ -885,7 +897,7 @@ public class AppServices {
private static void openBitcoinUri(URI uri) {
try {
BitcoinURI bitcoinURI = new BitcoinURI(uri.toString());
- Wallet wallet = selectWallet(null, "pay from");
+ Wallet wallet = selectWallet(null, null, "pay from");
if(wallet != null) {
final Wallet sendingWallet = wallet;
@@ -897,11 +909,30 @@ public class AppServices {
}
}
- private static Wallet selectWallet(ScriptType scriptType, String actionDescription) {
+ public static void openAuth47Uri(URI uri) {
+ try {
+ Auth47 auth47 = new Auth47(uri);
+ Wallet wallet = selectWallet(null, Boolean.TRUE, "authenticate using your payment code");
+
+ if(wallet != null) {
+ try {
+ auth47.sendResponse(wallet);
+ showSuccessDialog("Successful authentication", "Successfully authenticated to " + auth47.getCallback() + ".");
+ } catch(Exception e) {
+ log.error("Error authenticating auth47 URI", e);
+ showErrorDialog("Error authenticating", "Failed to authenticate.\n\n" + e.getMessage());
+ }
+ }
+ } catch(Exception e) {
+ showErrorDialog("Not a valid auth47 URI", e.getMessage());
+ }
+ }
+
+ private static Wallet selectWallet(ScriptType scriptType, Boolean hasPaymentCode, String actionDescription) {
Wallet wallet = null;
- List wallets = get().getOpenWallets().keySet().stream().filter(w -> scriptType == null || w.getScriptType() == scriptType).collect(Collectors.toList());
+ List wallets = get().getOpenWallets().keySet().stream().filter(w -> (scriptType == null || w.getScriptType() == scriptType) && (hasPaymentCode == null || w.hasPaymentCode())).collect(Collectors.toList());
if(wallets.isEmpty()) {
- showErrorDialog("No wallet available", "Open a" + (scriptType == null ? "" : " " + scriptType.getDescription()) + " wallet to " + actionDescription + ".");
+ showErrorDialog("No wallet available", "Open a" + (hasPaymentCode == null ? "" : " software") + (scriptType == null ? "" : " " + scriptType.getDescription()) + " wallet to " + actionDescription + ".");
} else if(wallets.size() == 1) {
wallet = wallets.iterator().next();
} else {
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/Auth47.java b/src/main/java/com/sparrowwallet/sparrow/net/Auth47.java
new file mode 100644
index 00000000..bbbfe745
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/net/Auth47.java
@@ -0,0 +1,246 @@
+package com.sparrowwallet.sparrow.net;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.sparrowwallet.drongo.ExtendedKey;
+import com.sparrowwallet.drongo.KeyPurpose;
+import com.sparrowwallet.drongo.crypto.ChildNumber;
+import com.sparrowwallet.drongo.crypto.ECKey;
+import com.sparrowwallet.drongo.protocol.ScriptType;
+import com.sparrowwallet.drongo.wallet.Wallet;
+import com.sparrowwallet.drongo.wallet.WalletNode;
+import com.sparrowwallet.sparrow.AppServices;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.*;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+
+public class Auth47 {
+ private static final Logger log = LoggerFactory.getLogger(Auth47.class);
+
+ public static final String SCHEME = "auth47";
+ public static final String VERSION = "1.0";
+ public static final String HTTPS_PROTOCOL = "https://";
+ public static final String SRBN_PROTOCOL = "srbn://";
+
+ private final String nonce;
+ private final URL callback;
+ private final String expiry;
+ private boolean srbn;
+ private String srbnName;
+ private String resource;
+
+ public Auth47(URI uri) throws MalformedURLException {
+ this.nonce = uri.getHost();
+
+ Map parameterMap = new LinkedHashMap<>();
+ String query = uri.getRawQuery();
+ if(query == null) {
+ throw new IllegalArgumentException("No callback 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));
+ }
+
+ String strCallback = parameterMap.get("c");
+ if(strCallback == null) {
+ throw new IllegalArgumentException("No callback parameter provided.");
+ }
+ if(strCallback.startsWith(SRBN_PROTOCOL)) {
+ String srbnCallback = HTTPS_PROTOCOL + strCallback.substring(SRBN_PROTOCOL.length());
+ URL srbnUrl = new URL(srbnCallback);
+ this.srbn = true;
+ this.srbnName = srbnUrl.getUserInfo();
+ this.callback = new URL(HTTPS_PROTOCOL + srbnUrl.getHost());
+ } else {
+ this.callback = new URL(strCallback);
+ }
+
+ this.expiry = parameterMap.get("e");
+ this.resource = parameterMap.get("r");
+ if(resource == null) {
+ if(srbn) {
+ this.resource = "srbn";
+ } else if(strCallback.startsWith("http")) {
+ this.resource = strCallback;
+ } else {
+ throw new IllegalArgumentException("Invalid callback parameter (not http/s or srbn): " + strCallback);
+ }
+ }
+ }
+
+ public void sendResponse(Wallet wallet) throws IOException, Auth47Exception {
+ if(!wallet.hasPaymentCode()) {
+ throw new Auth47Exception("A software wallet is required to authenticate.");
+ }
+
+ if(srbn) {
+ sendSorobanResponse(wallet);
+ } else {
+ sendHttpResponse(wallet);
+ }
+ }
+
+ public void sendHttpResponse(Wallet wallet) throws IOException, Auth47Exception {
+ String json = getJsonResponse(wallet);
+
+ send(json);
+ }
+
+ public void sendSorobanResponse(Wallet wallet) throws IOException, Auth47Exception {
+ String json = getJsonResponse(wallet);
+
+ SorobanResponse sorobanResponse = new SorobanResponse(srbnName, json);
+ Gson gson = new GsonBuilder().disableHtmlEscaping().create();
+ String jsonRpc = gson.toJson(sorobanResponse);
+
+ send(jsonRpc);
+ }
+
+ private String getJsonResponse(Wallet wallet) {
+ String challenge = getChallenge();
+ String signature = sign(wallet, challenge);
+
+ Response response = new Response(VERSION, challenge, signature, wallet.getPaymentCode().toString(), null);
+ Gson gson = new GsonBuilder().disableHtmlEscaping().create();
+ return gson.toJson(response);
+ }
+
+ private void send(String json) throws IOException, Auth47Exception {
+ if(log.isDebugEnabled()) {
+ log.debug("Sending " + json + " to " + callback);
+ }
+
+ Proxy proxy = AppServices.getProxy();
+ if(proxy == null && callback.getHost().toLowerCase().endsWith(TorService.TOR_ADDRESS_SUFFIX)) {
+ throw new Auth47Exception("A Tor proxy must be configured to authenticate this resource.");
+ }
+
+ HttpURLConnection connection = proxy == null ? (HttpURLConnection) callback.openConnection() : (HttpURLConnection) callback.openConnection(proxy);
+ connection.setRequestMethod("POST");
+ connection.setRequestProperty("Content-Type", "application/json");
+ connection.setDoOutput(true);
+
+ try(OutputStream os = connection.getOutputStream()) {
+ byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8);
+ os.write(jsonBytes);
+ }
+
+ int statusCode = connection.getResponseCode();
+ if(statusCode == 404) {
+ throw new Auth47Exception("Could not authenticate. Callback URL of " + callback + " returned a 404 response.");
+ }
+
+ 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 " + statusCode + " " + res);
+ }
+
+ if(statusCode < 200 || statusCode >= 300) {
+ throw new Auth47Exception("Could not authenticate. Server returned " + res);
+ }
+ }
+
+ private String sign(Wallet wallet, String challenge) {
+ try {
+ Wallet notificationWallet = wallet.getNotificationWallet();
+ WalletNode notificationNode = notificationWallet.getNode(KeyPurpose.NOTIFICATION);
+ ExtendedKey extendedPrivateKey = notificationWallet.getKeystores().get(0).getBip47ExtendedPrivateKey();
+ List derivation = new ArrayList<>();
+ derivation.add(extendedPrivateKey.getKeyChildNumber());
+ derivation.addAll(notificationNode.getDerivation());
+ ECKey privKey = extendedPrivateKey.getKey(derivation);
+ return privKey.signMessage(challenge, ScriptType.P2PKH);
+ } catch(Exception e) {
+ log.error("Error signing auth47 challenge", e);
+ throw new IllegalStateException("Error signing auth47 challenge: " + e.getMessage(), e);
+ }
+ }
+
+ private String getChallenge() {
+ return SCHEME + "://" + nonce + "?r=" + resource + (expiry == null ? "" : "&e=" + expiry);
+ }
+
+ public URL getCallback() {
+ return callback;
+ }
+
+ private static class Response {
+ public Response(String version, String challenge, String signature, String paymentCode, String address) {
+ this.auth47_response = version;
+ this.challenge = challenge;
+ this.signature = signature;
+ this.nym = paymentCode;
+ this.address = address;
+ }
+
+ public String auth47_response;
+ public String challenge;
+ public String signature;
+ public String nym;
+ public String address;
+ }
+
+ private static class SorobanResponse {
+ public SorobanResponse(String name, String response) {
+ params.add(new SorobanParam(name, response));
+ }
+
+ public String jsonrpc = "2.0";
+ public int id = 0;
+ public String method = "directory.Add";
+ public List params = new ArrayList<>();
+ }
+
+ private static class SorobanParam {
+ public SorobanParam(String name, String entry) {
+ Name = name;
+ Entry = entry;
+ }
+
+ public String Name;
+ public String Entry;
+ public String Mode = "short";
+ }
+
+ public static final class Auth47Exception extends Exception {
+ public Auth47Exception(String message) {
+ super(message);
+ }
+
+ public Auth47Exception(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public Auth47Exception(Throwable cause) {
+ super(cause);
+ }
+
+ public Auth47Exception(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
+ super(message, cause, enableSuppression, writableStackTrace);
+ }
+ }
+}