add support for lnurl-auth authentication by registering a platform uri handler

This commit is contained in:
Craig Raw 2022-08-04 11:15:17 +02:00
parent 6efe5e4ccc
commit 80fab6df99
10 changed files with 327 additions and 19 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', '--file-associations', 'src/main/deploy/auth47.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', '--file-associations', 'src/main/deploy/lightning.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']

2
drongo

@ -1 +1 @@
Subproject commit ddaf698c1011e5b01c7c3ed1e7693145ba5531ac
Subproject commit 8cdea77562643edf9d460a594178c1f44deeb248

View file

@ -0,0 +1,2 @@
mime-type=x-scheme-handler/lightning
description=LNURL 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;x-scheme-handler/auth47
MimeType=application/psbt;application/bitcoin-transaction;x-scheme-handler/bitcoin;x-scheme-handler/auth47;x-scheme-handler/lightning

View file

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

View file

@ -97,6 +97,16 @@
<RegistryValue Type="string" Value="&quot;[INSTALLDIR]$(var.JpAppName).exe&quot; &quot;%1&quot;" />
</RegistryKey>
</RegistryKey>
<RegistryKey Root="HKCR" Key="lightning" Action="createAndRemoveOnUninstall">
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
<RegistryValue Type="string" Value="URL:LNURL 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>
</Component>
</DirectoryRef>

View file

@ -3,7 +3,16 @@ package com.sparrowwallet.sparrow;
import com.google.common.eventbus.Subscribe;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.crypto.EncryptionType;
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
import com.sparrowwallet.drongo.crypto.Key;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.net.Auth47;
import com.sparrowwallet.drongo.protocol.BlockHeader;
@ -11,10 +20,6 @@ import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletTransaction;
import com.sparrowwallet.sparrow.control.TextUtils;
import com.sparrowwallet.sparrow.control.TrayManager;
import com.sparrowwallet.sparrow.event.*;
@ -881,6 +886,8 @@ public class AppServices {
openBitcoinUri(uri);
} else if(("auth47").equals(uri.getScheme())) {
openAuth47Uri(uri);
} else if(("lightning").equals(uri.getScheme())) {
openLnurlAuthUri(uri);
}
});
}
@ -903,11 +910,13 @@ public class AppServices {
private static void openBitcoinUri(URI uri) {
try {
BitcoinURI bitcoinURI = new BitcoinURI(uri.toString());
Wallet wallet = selectWallet(null, null, "pay from");
List<PolicyType> policyTypes = Arrays.asList(PolicyType.values());
List<ScriptType> scriptTypes = Arrays.asList(ScriptType.ADDRESSABLE_TYPES);
Wallet wallet = selectWallet(policyTypes, scriptTypes, true, false, "pay from", false);
if(wallet != null) {
final Wallet sendingWallet = wallet;
EventManager.get().post(new SendActionEvent(sendingWallet, new ArrayList<>(sendingWallet.getWalletUtxos().keySet())));
EventManager.get().post(new SendActionEvent(sendingWallet, new ArrayList<>(sendingWallet.getWalletUtxos().keySet()), true));
Platform.runLater(() -> EventManager.get().post(new SendPaymentsEvent(sendingWallet, List.of(bitcoinURI.toPayment()))));
}
} catch(Exception e) {
@ -915,31 +924,97 @@ public class AppServices {
}
}
public static void openAuth47Uri(URI uri) {
private static void openAuth47Uri(URI uri) {
try {
Auth47 auth47 = new Auth47(uri);
Wallet wallet = selectWallet(null, Boolean.TRUE, "authenticate using your payment code");
List<ScriptType> scriptTypes = PaymentCode.SEGWIT_SCRIPT_TYPES;
Wallet wallet = selectWallet(List.of(PolicyType.SINGLE), scriptTypes, false, true, "login to " + auth47.getCallback().getHost(), true);
if(wallet != null) {
try {
auth47.sendResponse(wallet);
showSuccessDialog("Successful authentication", "Successfully authenticated to " + auth47.getCallback() + ".");
EventManager.get().post(new StatusEvent("Successfully authenticated to " + auth47.getCallback().getHost()));
} catch(Exception e) {
log.error("Error authenticating auth47 URI", e);
showErrorDialog("Error authenticating", "Failed to authenticate.\n\n" + e.getMessage());
}
}
} catch(Exception e) {
log.error("Not a valid auth47 URI", e);
showErrorDialog("Not a valid auth47 URI", e.getMessage());
}
}
private static Wallet selectWallet(ScriptType scriptType, Boolean hasPaymentCode, String actionDescription) {
private static void openLnurlAuthUri(URI uri) {
try {
LnurlAuth lnurlAuth = new LnurlAuth(uri);
List<ScriptType> scriptTypes = ScriptType.getAddressableScriptTypes(PolicyType.SINGLE);
Wallet wallet = selectWallet(List.of(PolicyType.SINGLE), scriptTypes, true, true, lnurlAuth.getLoginMessage(), true);
if(wallet != null) {
if(wallet.isEncrypted()) {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
Wallet copy = wallet.copy();
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Done"));
ECKey encryptionFullKey = keyDerivationService.getValue();
Key key = new Key(encryptionFullKey.getPrivKeyBytes(), storage.getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
copy.decrypt(key);
try {
lnurlAuth.sendResponse(copy);
EventManager.get().post(new StatusEvent("Successfully authenticated to " + lnurlAuth.getDomain()));
} catch(Exception e) {
showErrorDialog("Error authenticating", "Failed to authenticate.\n\n" + e.getMessage());
} finally {
key.clear();
encryptionFullKey.clear();
password.get().clear();
}
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Failed"));
if(keyDerivationService.getException() instanceof InvalidPasswordException) {
Optional<ButtonType> optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
Platform.runLater(() -> openLnurlAuthUri(uri));
}
} else {
log.error("Error deriving wallet key", keyDerivationService.getException());
}
});
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.START, "Decrypting wallet..."));
keyDerivationService.start();
}
} else {
try {
lnurlAuth.sendResponse(wallet);
EventManager.get().post(new StatusEvent("Successfully authenticated to " + lnurlAuth.getDomain()));
} catch(LnurlAuth.LnurlAuthException e) {
showErrorDialog("Error authenticating", "Failed to authenticate.\n\n" + e.getMessage());
} catch(Exception e) {
log.error("Failed to authenticate using LNURL-auth", e);
showErrorDialog("Error authenticating", "Failed to authenticate.\n\n" + e.getMessage());
}
}
}
} catch(Exception e) {
log.error("Not a valid LNURL-auth URI", e);
showErrorDialog("Not a valid LNURL-auth URI", e.getMessage());
}
}
private static Wallet selectWallet(List<PolicyType> policyTypes, List<ScriptType> scriptTypes, boolean taprootAllowed, boolean privateKeysRequired, String actionDescription, boolean alwaysAsk) {
Wallet wallet = null;
List<Wallet> wallets = get().getOpenWallets().keySet().stream().filter(w -> (scriptType == null || w.getScriptType() == scriptType) && (hasPaymentCode == null || w.hasPaymentCode())).collect(Collectors.toList());
List<Wallet> wallets = get().getOpenWallets().keySet().stream().filter(w -> w.isValid() && policyTypes.contains(w.getPolicyType()) && scriptTypes.contains(w.getScriptType())
&& (!privateKeysRequired || w.getKeystores().stream().allMatch(Keystore::hasPrivateKey))).collect(Collectors.toList());
if(wallets.isEmpty()) {
showErrorDialog("No wallet available", "Open a" + (hasPaymentCode == null ? "" : " software") + (scriptType == null ? "" : " " + scriptType.getDescription()) + " wallet to " + actionDescription + ".");
} else if(wallets.size() == 1) {
boolean taprootOpen = get().getOpenWallets().keySet().stream().anyMatch(w -> w.getScriptType() == ScriptType.P2TR);
showErrorDialog("No wallet available", "Open a" + (taprootOpen && !taprootAllowed ? " non-Taproot" : "") + (privateKeysRequired ? " software" : "") + " wallet to " + actionDescription + ".");
} else if(wallets.size() == 1 && !alwaysAsk) {
wallet = wallets.iterator().next();
} else {
ChoiceDialog<Wallet> walletChoiceDialog = new ChoiceDialog<>(wallets.iterator().next(), wallets);

View file

@ -8,10 +8,16 @@ import java.util.List;
public class SendActionEvent extends FunctionActionEvent {
private final List<BlockTransactionHashIndex> utxos;
private final boolean selectIfEmpty;
public SendActionEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos) {
this(wallet, utxos, false);
}
public SendActionEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, boolean selectIfEmpty) {
super(Function.SEND, wallet);
this.utxos = utxos;
this.selectIfEmpty = selectIfEmpty;
}
public List<BlockTransactionHashIndex> getUtxos() {
@ -20,6 +26,6 @@ public class SendActionEvent extends FunctionActionEvent {
@Override
public boolean selectFunction() {
return !getUtxos().isEmpty();
return selectIfEmpty || !getUtxos().isEmpty();
}
}

View file

@ -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);
}
}
}

View file

@ -10,6 +10,9 @@ import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.List;
import static com.sparrowwallet.drongo.bip47.PaymentCode.SEGWIT_SCRIPT_TYPES;
import static com.sparrowwallet.drongo.bip47.PaymentCode.V1_SCRIPT_TYPES;
public class PayNym {
private static final Logger log = LoggerFactory.getLogger(PayNym.class);
@ -68,11 +71,11 @@ public class PayNym {
}
public static List<ScriptType> getSegwitScriptTypes() {
return List.of(ScriptType.P2PKH, ScriptType.P2SH_P2WPKH, ScriptType.P2WPKH);
return SEGWIT_SCRIPT_TYPES;
}
public static List<ScriptType> getV1ScriptTypes() {
return List.of(ScriptType.P2PKH);
return V1_SCRIPT_TYPES;
}
public static PayNym fromString(String strPaymentCode, String nymId, String nymName, boolean segwit, List<PayNym> following, List<PayNym> followers) {