Craig Raw
3 years ago
7 changed files with 304 additions and 7 deletions
@ -0,0 +1,2 @@ |
|||||
|
mime-type=x-scheme-handler/auth47 |
||||
|
description=Auth47 Authentication URI |
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
Loading…
Reference in new issue