From 08934d3c3c160f9144dddf6aae2dc8ed21611859 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Mon, 25 Jul 2022 12:48:21 +0200 Subject: [PATCH] implement auth47 authentication through platform uri registration --- build.gradle | 2 +- src/main/deploy/auth47.properties | 2 + src/main/deploy/package/linux/Sparrow.desktop | 2 +- src/main/deploy/package/osx/Info.plist | 8 + src/main/deploy/package/windows/main.wxs | 10 + .../sparrowwallet/sparrow/AppServices.java | 41 ++- .../com/sparrowwallet/sparrow/net/Auth47.java | 246 ++++++++++++++++++ 7 files changed, 304 insertions(+), 7 deletions(-) create mode 100644 src/main/deploy/auth47.properties create mode 100644 src/main/java/com/sparrowwallet/sparrow/net/Auth47.java 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); + } + } +}