implement auth47 authentication through platform uri registration

This commit is contained in:
Craig Raw 2022-07-25 12:48:21 +02:00
parent 192657fa69
commit 08934d3c3c
7 changed files with 304 additions and 7 deletions

View file

@ -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']

View file

@ -0,0 +1,2 @@
mime-type=x-scheme-handler/auth47
description=Auth47 Authentication URI

View file

@ -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
MimeType=application/psbt;application/bitcoin-transaction;x-scheme-handler/bitcoin;x-scheme-handler/auth47

View file

@ -45,6 +45,14 @@
<string>bitcoin</string>
</array>
</dict>
<dict>
<key>CFBundleURLName</key>
<string>com.sparrowwallet.sparrow.auth47</string>
<key>CFBundleURLSchemes</key>
<array>
<string>auth47</string>
</array>
</dict>
</array>
<key>UTImportedTypeDeclarations</key>
<array>

View file

@ -77,6 +77,16 @@
<DirectoryRef Id="TARGETDIR">
<Component Id="RegistryEntries" Guid="{206C911C-56EF-44B8-9257-5FD214427965}">
<RegistryKey Root="HKCR" Key="auth47" Action="createAndRemoveOnUninstall">
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
<RegistryValue Type="string" Value="URL:Auth47 Authentication URI"/>
<RegistryKey Key="DefaultIcon">
<RegistryValue Type="string" Value="$(var.JpAppName).exe" />
</RegistryKey>
<RegistryKey Key="shell\open\command">
<RegistryValue Type="string" Value="&quot;[INSTALLDIR]$(var.JpAppName).exe&quot; &quot;%1&quot;" />
</RegistryKey>
</RegistryKey>
<RegistryKey Root="HKCR" Key="bitcoin" Action="createAndRemoveOnUninstall">
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
<RegistryValue Type="string" Value="URL:Bitcoin Payment URL"/>

View file

@ -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<ButtonType> 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<ButtonType> 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<Wallet> wallets = get().getOpenWallets().keySet().stream().filter(w -> scriptType == null || w.getScriptType() == scriptType).collect(Collectors.toList());
List<Wallet> 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 {

View file

@ -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<String, String> 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<ChildNumber> 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<SorobanParam> 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);
}
}
}