10 changed files with 327 additions and 19 deletions
@ -1 +1 @@ |
|||||
Subproject commit ddaf698c1011e5b01c7c3ed1e7693145ba5531ac |
Subproject commit 8cdea77562643edf9d460a594178c1f44deeb248 |
@ -0,0 +1,2 @@ |
|||||
|
mime-type=x-scheme-handler/lightning |
||||
|
description=LNURL URI |
@ -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<String, String> 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<ChildNumber> 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<ChildNumber> 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); |
||||
|
} |
||||
|
} |
||||
|
} |
Loading…
Reference in new issue