From 1a8d6e5c05ba7a00d8f85e0f1059e1183c498c27 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Mon, 11 Nov 2019 14:22:04 +0900 Subject: [PATCH] Implement BTCPayServer vault derivation scheme import --- BTCPayServer/BTCPayServer.csproj | 6 +- .../Controllers/StoresController.BTCLike.cs | 13 +- BTCPayServer/Controllers/VaultController.cs | 261 ++++++++++++++++ .../Controllers/WalletsController.PSBT.cs | 7 +- BTCPayServer/Controllers/WalletsController.cs | 12 + BTCPayServer/HwiWebSocketTransport.cs | 28 ++ .../WalletViewModels/WalletPSBTViewModel.cs | 1 + .../WalletViewModels/WalletSendVaultModel.cs | 14 + .../Views/Shared/VaultElements.cshtml | 56 ++++ .../Views/Stores/AddDerivationScheme.cshtml | 21 +- ...vationSchemes_HardwareWalletDialogs.cshtml | 45 ++- BTCPayServer/Views/Wallets/WalletPSBT.cshtml | 4 + BTCPayServer/Views/Wallets/WalletSend.cshtml | 3 + .../Views/Wallets/WalletSendVault.cshtml | 56 ++++ BTCPayServer/WebSocketHelper.cs | 120 ++++++++ .../wwwroot/js/StoreAddDerivationScheme.js | 54 +++- BTCPayServer/wwwroot/js/vaultbridge.js | 104 +++++++ BTCPayServer/wwwroot/js/vaultbridge.ui.js | 286 ++++++++++++++++++ BTCPayServer/wwwroot/main/site.css | 12 +- 19 files changed, 1079 insertions(+), 24 deletions(-) create mode 100644 BTCPayServer/Controllers/VaultController.cs create mode 100644 BTCPayServer/HwiWebSocketTransport.cs create mode 100644 BTCPayServer/Models/WalletViewModels/WalletSendVaultModel.cs create mode 100644 BTCPayServer/Views/Shared/VaultElements.cshtml create mode 100644 BTCPayServer/Views/Wallets/WalletSendVault.cshtml create mode 100644 BTCPayServer/WebSocketHelper.cs create mode 100644 BTCPayServer/wwwroot/js/vaultbridge.js create mode 100644 BTCPayServer/wwwroot/js/vaultbridge.ui.js diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 7a876bb89..099c933b8 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -27,12 +27,13 @@ + - + all @@ -201,6 +202,9 @@ $(IncludeRazorContentInPack) + + $(IncludeRazorContentInPack) + $(IncludeRazorContentInPack) diff --git a/BTCPayServer/Controllers/StoresController.BTCLike.cs b/BTCPayServer/Controllers/StoresController.BTCLike.cs index 456eb3d6f..cf4e6c92b 100644 --- a/BTCPayServer/Controllers/StoresController.BTCLike.cs +++ b/BTCPayServer/Controllers/StoresController.BTCLike.cs @@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Mvc; using NBitcoin; using NBXplorer.DerivationStrategy; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace BTCPayServer.Controllers { @@ -119,8 +120,8 @@ namespace BTCPayServer.Controllers return new EmptyResult(); } - - + + private void SetExistingValues(StoreData store, DerivationSchemeViewModel vm) { var derivation = GetExistingDerivationStrategy(vm.CryptoCode, store); @@ -140,8 +141,6 @@ namespace BTCPayServer.Controllers .FirstOrDefault(d => d.PaymentId == id); return existing; } - - [HttpPost] [Route("{storeId}/derivations/{cryptoCode}")] @@ -162,7 +161,7 @@ namespace BTCPayServer.Controllers vm.Network = network; vm.RootKeyPath = network.GetRootKeyPath(); DerivationSchemeSettings strategy = null; - + var wallet = _WalletProvider.GetWallet(network); if (wallet == null) { @@ -246,7 +245,7 @@ namespace BTCPayServer.Controllers var willBeExcluded = !vm.Enabled; var showAddress = // Show addresses if: - // - If the user is testing the hint address in confirmation screen + // - If the user is testing the hint address in confirmation screen (vm.Confirmation && !string.IsNullOrWhiteSpace(vm.HintAddress)) || // - The user is clicking on continue after changing the config (!vm.Confirmation && oldConfig != vm.Config) || @@ -280,7 +279,7 @@ namespace BTCPayServer.Controllers { TempData[WellKnownTempData.SuccessMessage] = $"Derivation settings for {network.CryptoCode} has been modified."; } - return RedirectToAction(nameof(UpdateStore), new {storeId = storeId}); + return RedirectToAction(nameof(UpdateStore), new { storeId = storeId }); } else if (!string.IsNullOrEmpty(vm.HintAddress)) { diff --git a/BTCPayServer/Controllers/VaultController.cs b/BTCPayServer/Controllers/VaultController.cs new file mode 100644 index 000000000..b3a694dd1 --- /dev/null +++ b/BTCPayServer/Controllers/VaultController.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.ModelBinders; +using BTCPayServer.Models; +using BTCPayServer.Models.StoreViewModels; +using BTCPayServer.Payments; +using BTCPayServer.Security; +using BTCPayServer.Services; +using LedgerWallet; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using NBitcoin; +using NBXplorer.DerivationStrategy; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.Controllers +{ + [Route("vault")] + public class VaultController : Controller + { + private readonly IAuthorizationService _authorizationService; + + public VaultController(BTCPayNetworkProvider networks, IAuthorizationService authorizationService) + { + Networks = networks; + _authorizationService = authorizationService; + } + + public BTCPayNetworkProvider Networks { get; } + + [HttpGet] + [Route("{cryptoCode}/xpub")] + [Route("wallets/{walletId}/xpub")] + public async Task VaultBridgeConnection(string cryptoCode = null, + [ModelBinder(typeof(WalletIdModelBinder))] + WalletId walletId = null) + { + if (!HttpContext.WebSockets.IsWebSocketRequest) + return NotFound(); + cryptoCode = cryptoCode ?? walletId.CryptoCode; + using (var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10))) + { + var cancellationToken = cts.Token; + var network = Networks.GetNetwork(cryptoCode); + if (network == null) + return NotFound(); + var websocket = await HttpContext.WebSockets.AcceptWebSocketAsync(); + var hwi = new Hwi.HwiClient(network.NBitcoinNetwork) + { + Transport = new HwiWebSocketTransport(websocket) + }; + Hwi.HwiDeviceClient device = null; + HDFingerprint? fingerprint = null; + var websocketHelper = new WebSocketHelper(websocket); + JObject o = null; + try + { + while (true) + { + var command = await websocketHelper.NextMessageAsync(cancellationToken); + switch (command) + { + case "ask-sign": + if (device == null) + { + await websocketHelper.Send("{ \"error\": \"need-device\"}", cancellationToken); + continue; + } + if (walletId == null) + { + await websocketHelper.Send("{ \"error\": \"invalid-walletId\"}", cancellationToken); + continue; + } + if (fingerprint is null) + { + try + { + fingerprint = (await device.GetXPubAsync(new KeyPath("44'"), cancellationToken)).ExtPubKey.ParentFingerprint; + } + catch (Hwi.HwiException ex) when (ex.ErrorCode == Hwi.HwiErrorCode.DeviceNotReady) + { + await websocketHelper.Send("{ \"error\": \"need-pin\"}", cancellationToken); + continue; + } + } + await websocketHelper.Send("{ \"info\": \"ready\"}", cancellationToken); + o = JObject.Parse(await websocketHelper.NextMessageAsync(cancellationToken)); + var authorization = await _authorizationService.AuthorizeAsync(User, Policies.CanModifyStoreSettings.Key); + if (!authorization.Succeeded) + { + await websocketHelper.Send("{ \"error\": \"not-authorized\"}", cancellationToken); + continue; + } + var psbt = PSBT.Parse(o["psbt"].Value(), network.NBitcoinNetwork); + var derivationSettings = GetDerivationSchemeSettings(walletId); + derivationSettings.RebaseKeyPaths(psbt); + var signing = derivationSettings.GetSigningAccountKeySettings(); + if (signing.GetRootedKeyPath()?.MasterFingerprint != fingerprint) + { + await websocketHelper.Send("{ \"error\": \"wrong-wallet\"}", cancellationToken); + continue; + } + try + { + psbt = await device.SignPSBTAsync(psbt, cancellationToken); + } + catch (Hwi.HwiException ex) when (ex.ErrorCode == Hwi.HwiErrorCode.DeviceNotReady) + { + await websocketHelper.Send("{ \"error\": \"need-pin\"}", cancellationToken); + continue; + } + catch (Hwi.HwiException) + { + await websocketHelper.Send("{ \"error\": \"user-reject\"}", cancellationToken); + continue; + } + o = new JObject(); + o.Add("psbt", psbt.ToBase64()); + await websocketHelper.Send(o.ToString(), cancellationToken); + break; + case "ask-pin": + if (device == null) + { + await websocketHelper.Send("{ \"error\": \"need-device\"}", cancellationToken); + continue; + } + await device.PromptPinAsync(cancellationToken); + await websocketHelper.Send("{ \"info\": \"prompted, please input the pin\"}", cancellationToken); + o = JObject.Parse(await websocketHelper.NextMessageAsync(cancellationToken)); + var pin = (int)o["pinCode"].Value(); + var passphrase = o["passphrase"].Value(); + device.Password = passphrase; + if (await device.SendPinAsync(pin, cancellationToken)) + { + await websocketHelper.Send("{ \"info\": \"the pin is correct\"}", cancellationToken); + } + else + { + await websocketHelper.Send("{ \"error\": \"incorrect-pin\"}", cancellationToken); + continue; + } + break; + case "ask-xpubs": + if (device == null) + { + await websocketHelper.Send("{ \"error\": \"need-device\"}", cancellationToken); + continue; + } + JObject result = new JObject(); + var factory = network.NBXplorerNetwork.DerivationStrategyFactory; + var keyPath = new KeyPath("84'").Derive(network.CoinType).Derive(0, true); + BitcoinExtPubKey xpub = null; + try + { + xpub = await device.GetXPubAsync(keyPath); + } + catch (Hwi.HwiException ex) when (ex.ErrorCode == Hwi.HwiErrorCode.DeviceNotReady) + { + await websocketHelper.Send("{ \"error\": \"need-pin\"}", cancellationToken); + continue; + } + if (fingerprint is null) + { + fingerprint = (await device.GetXPubAsync(new KeyPath("44'"), cancellationToken)).ExtPubKey.ParentFingerprint; + } + result["fingerprint"] = fingerprint.Value.ToString(); + var strategy = factory.CreateDirectDerivationStrategy(xpub, new DerivationStrategyOptions() + { + ScriptPubKeyType = ScriptPubKeyType.Segwit + }); + AddDerivationSchemeToJson("segwit", result, keyPath, xpub, strategy); + keyPath = new KeyPath("49'").Derive(network.CoinType).Derive(0, true); + xpub = await device.GetXPubAsync(keyPath); + strategy = factory.CreateDirectDerivationStrategy(xpub, new DerivationStrategyOptions() + { + ScriptPubKeyType = ScriptPubKeyType.SegwitP2SH + }); + AddDerivationSchemeToJson("segwitWrapped", result, keyPath, xpub, strategy); + keyPath = new KeyPath("44'").Derive(network.CoinType).Derive(0, true); + xpub = await device.GetXPubAsync(keyPath); + strategy = factory.CreateDirectDerivationStrategy(xpub, new DerivationStrategyOptions() + { + ScriptPubKeyType = ScriptPubKeyType.Legacy + }); + AddDerivationSchemeToJson("legacy", result, keyPath, xpub, strategy); + await websocketHelper.Send(result.ToString(), cancellationToken); + break; + case "ask-device": + var devices = (await hwi.EnumerateDevicesAsync(cancellationToken)).ToList(); + device = devices.FirstOrDefault(); + if (device == null) + { + await websocketHelper.Send("{ \"error\": \"no-device\"}", cancellationToken); + continue; + } + fingerprint = device.Fingerprint; + JObject json = new JObject(); + json.Add("model", device.Model.ToString()); + json.Add("fingerprint", device.Fingerprint?.ToString()); + await websocketHelper.Send(json.ToString(), cancellationToken); + break; + } + } + } + catch (Exception ex) + { + JObject obj = new JObject(); + obj.Add("error", "unknown-error"); + obj.Add("details", ex.ToString()); + try + { + await websocketHelper.Send(obj.ToString(), cancellationToken); + } + catch { } + } + finally + { + await websocketHelper.DisposeAsync(cancellationToken); + } + } + return new EmptyResult(); + } + + public StoreData CurrentStore + { + get + { + return HttpContext.GetStoreData(); + } + } + + private DerivationSchemeSettings GetDerivationSchemeSettings(WalletId walletId) + { + var paymentMethod = CurrentStore + .GetSupportedPaymentMethods(Networks) + .OfType() + .FirstOrDefault(p => p.PaymentId.PaymentType == Payments.PaymentTypes.BTCLike && p.PaymentId.CryptoCode == walletId.CryptoCode); + return paymentMethod; + } + + private void AddDerivationSchemeToJson(string propertyName, JObject result, KeyPath keyPath, BitcoinExtPubKey xpub, DerivationStrategyBase strategy) + { + result.Add(new JProperty(propertyName, new JObject() + { + new JProperty("strategy", strategy.ToString()), + new JProperty("accountKey", xpub.ToString()), + new JProperty("keyPath", keyPath.ToString()), + })); + } + } +} diff --git a/BTCPayServer/Controllers/WalletsController.PSBT.cs b/BTCPayServer/Controllers/WalletsController.PSBT.cs index faf9fd54c..ffc38b99d 100644 --- a/BTCPayServer/Controllers/WalletsController.PSBT.cs +++ b/BTCPayServer/Controllers/WalletsController.PSBT.cs @@ -60,7 +60,7 @@ namespace BTCPayServer.Controllers vm.Decoded = psbt.ToString(); vm.PSBT = psbt.ToBase64(); } - return View(vm ?? new WalletPSBTViewModel()); + return View(vm ?? new WalletPSBTViewModel() { CryptoCode = walletId.CryptoCode }); } [HttpPost] [Route("{walletId}/psbt")] @@ -88,6 +88,8 @@ namespace BTCPayServer.Controllers vm.PSBT = psbt.ToBase64(); vm.FileName = vm.UploadedPSBTFile?.FileName; return View(vm); + case "vault": + return ViewVault(walletId, psbt); case "ledger": return ViewWalletSendLedger(psbt); case "update": @@ -156,7 +158,8 @@ namespace BTCPayServer.Controllers private async Task FetchTransactionDetails(DerivationSchemeSettings derivationSchemeSettings, WalletPSBTReadyViewModel vm, BTCPayNetwork network) { var psbtObject = PSBT.Parse(vm.PSBT, network.NBitcoinNetwork); - psbtObject = await UpdatePSBT(derivationSchemeSettings, psbtObject, network) ?? psbtObject; + if (!psbtObject.IsAllFinalized()) + psbtObject = await UpdatePSBT(derivationSchemeSettings, psbtObject, network) ?? psbtObject; IHDKey signingKey = null; RootedKeyPath signingKeyPath = null; try diff --git a/BTCPayServer/Controllers/WalletsController.cs b/BTCPayServer/Controllers/WalletsController.cs index 201bb88a3..48de86cb0 100644 --- a/BTCPayServer/Controllers/WalletsController.cs +++ b/BTCPayServer/Controllers/WalletsController.cs @@ -449,6 +449,8 @@ namespace BTCPayServer.Controllers switch (command) { + case "vault": + return ViewVault(walletId, psbt.PSBT); case "ledger": return ViewWalletSendLedger(psbt.PSBT, psbt.ChangeAddress); case "seed": @@ -463,6 +465,16 @@ namespace BTCPayServer.Controllers } + private IActionResult ViewVault(WalletId walletId, PSBT psbt) + { + return View("WalletSendVault", new WalletSendVaultModel() + { + WalletId = walletId.ToString(), + PSBT = psbt.ToBase64(), + WebsocketPath = this.Url.Action(nameof(VaultController.VaultBridgeConnection), "Vault", new { walletId = walletId.ToString() }) + }); + } + private IActionResult RedirectToWalletPSBT(WalletId walletId, PSBT psbt, string fileName = null) { var vm = new PostRedirectViewModel() diff --git a/BTCPayServer/HwiWebSocketTransport.cs b/BTCPayServer/HwiWebSocketTransport.cs new file mode 100644 index 000000000..f06713ae7 --- /dev/null +++ b/BTCPayServer/HwiWebSocketTransport.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer +{ + public class HwiWebSocketTransport : Hwi.Transports.ITransport + { + private readonly WebSocketHelper _webSocket; + + public HwiWebSocketTransport(WebSocket webSocket) + { + _webSocket = new WebSocketHelper(webSocket); + } + public async Task SendCommandAsync(string[] arguments, CancellationToken cancel) + { + JObject request = new JObject(); + request.Add("params", new JArray(arguments)); + await _webSocket.Send(request.ToString(), cancel); + return await _webSocket.NextMessageAsync(cancel); + } + } +} diff --git a/BTCPayServer/Models/WalletViewModels/WalletPSBTViewModel.cs b/BTCPayServer/Models/WalletViewModels/WalletPSBTViewModel.cs index 05f79dce4..1619ea109 100644 --- a/BTCPayServer/Models/WalletViewModels/WalletPSBTViewModel.cs +++ b/BTCPayServer/Models/WalletViewModels/WalletPSBTViewModel.cs @@ -10,6 +10,7 @@ namespace BTCPayServer.Models.WalletViewModels { public class WalletPSBTViewModel { + public string CryptoCode { get; set; } public string Decoded { get; set; } string _FileName; public string FileName diff --git a/BTCPayServer/Models/WalletViewModels/WalletSendVaultModel.cs b/BTCPayServer/Models/WalletViewModels/WalletSendVaultModel.cs new file mode 100644 index 000000000..1c8698ca7 --- /dev/null +++ b/BTCPayServer/Models/WalletViewModels/WalletSendVaultModel.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BTCPayServer.Models.WalletViewModels +{ + public class WalletSendVaultModel + { + public string WalletId { get; set; } + public string PSBT { get; set; } + public string WebsocketPath { get; set; } + } +} diff --git a/BTCPayServer/Views/Shared/VaultElements.cshtml b/BTCPayServer/Views/Shared/VaultElements.cshtml new file mode 100644 index 000000000..4cc766adb --- /dev/null +++ b/BTCPayServer/Views/Shared/VaultElements.cshtml @@ -0,0 +1,56 @@ + diff --git a/BTCPayServer/Views/Stores/AddDerivationScheme.cshtml b/BTCPayServer/Views/Stores/AddDerivationScheme.cshtml index ea93cf0a1..e0782411a 100644 --- a/BTCPayServer/Views/Stores/AddDerivationScheme.cshtml +++ b/BTCPayServer/Views/Stores/AddDerivationScheme.cshtml @@ -9,6 +9,14 @@ .hw-fields { display: none; } + + .pin-button { + height: 135px; + margin-top: 20px; + background: white; + border: solid lightgray 4px; + cursor: pointer; + } } @@ -45,11 +53,14 @@ + + + + + diff --git a/BTCPayServer/Views/Wallets/WalletPSBT.cshtml b/BTCPayServer/Views/Wallets/WalletPSBT.cshtml index 3123baa6c..f1046ff27 100644 --- a/BTCPayServer/Views/Wallets/WalletPSBT.cshtml +++ b/BTCPayServer/Views/Wallets/WalletPSBT.cshtml @@ -29,6 +29,7 @@

Decoded PSBT

+
diff --git a/BTCPayServer/Views/Wallets/WalletSendVault.cshtml b/BTCPayServer/Views/Wallets/WalletSendVault.cshtml new file mode 100644 index 000000000..b392ce2f4 --- /dev/null +++ b/BTCPayServer/Views/Wallets/WalletSendVault.cshtml @@ -0,0 +1,56 @@ +@model WalletSendVaultModel +@{ + Layout = "../Shared/_NavLayout.cshtml"; + ViewData["Title"] = "Manage wallet"; + ViewData.SetActivePageAndTitle(WalletsNavPages.Send); +} + +

Sign the transaction with BTCPayServer Vault

+ +
+
+ + + + + +
+
+
+ + + +@section Scripts +{ + + + +} diff --git a/BTCPayServer/WebSocketHelper.cs b/BTCPayServer/WebSocketHelper.cs new file mode 100644 index 000000000..7cf071d08 --- /dev/null +++ b/BTCPayServer/WebSocketHelper.cs @@ -0,0 +1,120 @@ +using System; +using NBXplorer; +using System.Collections.Generic; +using System.Linq; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace BTCPayServer +{ + public class WebSocketHelper + { + private readonly WebSocket _Socket; + public WebSocket Socket + { + get + { + return _Socket; + } + } + + public WebSocketHelper(WebSocket socket) + { + _Socket = socket; + var buffer = new byte[ORIGINAL_BUFFER_SIZE]; + _Buffer = new ArraySegment(buffer, 0, buffer.Length); + } + + const int ORIGINAL_BUFFER_SIZE = 1024 * 5; + const int MAX_BUFFER_SIZE = 1024 * 1024 * 5; + + ArraySegment _Buffer; + + UTF8Encoding UTF8 = new UTF8Encoding(false, true); + public async Task NextMessageAsync(CancellationToken cancellation) + { + var buffer = _Buffer; + var array = _Buffer.Array; + var originalSize = _Buffer.Array.Length; + var newSize = _Buffer.Array.Length; + while (true) + { + var message = await Socket.ReceiveAsync(buffer, cancellation); + if (message.MessageType == WebSocketMessageType.Close) + { + await CloseSocketAndThrow(WebSocketCloseStatus.NormalClosure, "Close message received from the peer", cancellation); + break; + } + if (message.MessageType != WebSocketMessageType.Text) + { + await CloseSocketAndThrow(WebSocketCloseStatus.InvalidMessageType, "Only Text is supported", cancellation); + break; + } + if (message.EndOfMessage) + { + buffer = new ArraySegment(array, 0, buffer.Offset + message.Count); + try + { + var o = UTF8.GetString(buffer.Array, 0, buffer.Count); + if (newSize != originalSize) + { + Array.Resize(ref array, originalSize); + } + return o; + } + catch (Exception ex) + { + await CloseSocketAndThrow(WebSocketCloseStatus.InvalidPayloadData, $"Invalid payload: {ex.Message}", cancellation); + } + } + else + { + if (buffer.Count - message.Count <= 0) + { + newSize *= 2; + if (newSize > MAX_BUFFER_SIZE) + await CloseSocketAndThrow(WebSocketCloseStatus.MessageTooBig, "Message is too big", cancellation); + Array.Resize(ref array, newSize); + buffer = new ArraySegment(array, buffer.Offset, newSize - buffer.Offset); + } + + buffer = buffer.Slice(message.Count, buffer.Count - message.Count); + } + } + throw new InvalidOperationException("Should never happen"); + } + + private async Task CloseSocketAndThrow(WebSocketCloseStatus status, string description, CancellationToken cancellation) + { + var array = _Buffer.Array; + if (array.Length != ORIGINAL_BUFFER_SIZE) + Array.Resize(ref array, ORIGINAL_BUFFER_SIZE); + await Socket.CloseSocket(status, description, cancellation); + throw new WebSocketException($"The socket has been closed ({status}: {description})"); + } + + public async Task Send(string evt, CancellationToken cancellation = default) + { + var bytes = UTF8.GetBytes(evt); + using (var cts = new CancellationTokenSource(5000)) + { + using (var cts2 = CancellationTokenSource.CreateLinkedTokenSource(cancellation)) + { + await Socket.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, cts2.Token); + } + } + } + + public async Task DisposeAsync(CancellationToken cancellation) + { + try + { + await Socket.CloseSocket(WebSocketCloseStatus.NormalClosure, "Disposing NotificationServer", cancellation); + } + catch { } + finally { try { Socket.Dispose(); } catch { } } + } + } +} diff --git a/BTCPayServer/wwwroot/js/StoreAddDerivationScheme.js b/BTCPayServer/wwwroot/js/StoreAddDerivationScheme.js index 2cb171d93..89066cfc0 100644 --- a/BTCPayServer/wwwroot/js/StoreAddDerivationScheme.js +++ b/BTCPayServer/wwwroot/js/StoreAddDerivationScheme.js @@ -1,5 +1,4 @@ -function initLedger(){ - +function initLedger() { var ledgerDetected = false; var loc = window.location, new_uri; @@ -90,12 +89,57 @@ $(document).ready(function () { var ledgerInit = false; - $(".check-for-ledger").on("click", function(){ - if(!ledgerInit){ + $(".check-for-ledger").on("click", function () { + if (!ledgerInit) { initLedger(); } ledgerInit = true; }); - + + function show(id, category) { + $("." + category).css("display", "none"); + $("#" + id).css("display", "block"); + } + + var websocketPath = $("#WebsocketPath").text(); + var loc = window.location, ws_uri; + if (loc.protocol === "https:") { + ws_uri = "wss:"; + } else { + ws_uri = "ws:"; + } + ws_uri += "//" + loc.host; + ws_uri += websocketPath; + + function displayXPubs(xpubs) { + $("#vault-dropdown").css("display", "block"); + $("#vault-dropdown .dropdown-item").click(function () { + var id = $(this).attr('id').replace("vault-", ""); + var xpub = xpubs[id]; + $("#DerivationScheme").val(xpub.strategy); + $("#RootFingerprint").val(xpubs.fingerprint); + $("#AccountKey").val(xpub.accountKey); + $("#Source").val("Vault"); + $("#DerivationSchemeFormat").val("BTCPay"); + $("#KeyPath").val(xpub.keyPath); + $(".modal").modal('hide'); + $(".hw-fields").show(); + }); + } + + var vaultInit = false; + $(".check-for-vault").on("click", async function () { + if (vaultInit) + return; + vaultInit = true; + + var html = $("#VaultConnection").html(); + $("#vaultPlaceholder").html(html); + + var vaultUI = new vaultui.VaultBridgeUI(ws_uri); + if (await vaultUI.askForXPubs()) { + displayXPubs(vaultUI.xpubs); + } + }); }); diff --git a/BTCPayServer/wwwroot/js/vaultbridge.js b/BTCPayServer/wwwroot/js/vaultbridge.js new file mode 100644 index 000000000..4f13d2a17 --- /dev/null +++ b/BTCPayServer/wwwroot/js/vaultbridge.js @@ -0,0 +1,104 @@ +var vault = (function () { + /** @param {WebSocket} websocket + */ + function VaultBridge(websocket) { + var self = this; + /** + * @type {WebSocket} + */ + this.socket = websocket; + this.onerror = function (error) { }; + this.onbackendmessage = function (json) { }; + + /** + * @returns {Promise} + */ + this.waitBackendMessage = function () { + return new Promise(function (resolve, reject) { + self.nextResolveBackendMessage = resolve; + }); + }; + this.socket.onmessage = function (event) { + if (typeof event.data === "string") { + var jsonObject = JSON.parse(event.data); + if (jsonObject.hasOwnProperty("params")) { + var request = new XMLHttpRequest(); + request.onreadystatechange = function () { + if (request.readyState == 4 && request.status == 200) { + self.socket.send(request.responseText); + } + if (request.readyState == 4 && request.status == 0) { + self.onerror(vault.errors.notRunning); + } + if (request.readyState == 4 && request.status == 401) { + self.onerror(vault.errors.denied); + } + }; + request.open('POST', 'http://localhost:65092/hwi-bridge/v1'); + request.send(JSON.stringify(jsonObject)); + } + else { + self.onbackendmessage(jsonObject); + if (self.nextResolveBackendMessage) + self.nextResolveBackendMessage(jsonObject); + } + } + }; + } + + /** + * @param {string} ws_uri + * @returns {Promise} + */ + function connectToBackendSocket(ws_uri) { + return new Promise(function (resolve, reject) { + var supportWebSocket = "WebSocket" in window && window.WebSocket.CLOSING === 2; + if (!supportWebSocket) { + reject(vault.errors.socketNotSupported); + return; + } + var socket = new WebSocket(ws_uri); + socket.onerror = function (error) { + console.warn(error); + reject(vault.errors.socketError); + }; + socket.onopen = function () { + resolve(new vault.VaultBridge(socket)); + }; + }); + } + + /** + * @returns {Promise} + */ + function askVaultPermission() { + return new Promise(function (resolve, reject) { + var request = new XMLHttpRequest(); + request.onreadystatechange = function () { + if (request.readyState == 4 && request.status == 200) { + resolve(); + } + if (request.readyState == 4 && request.status == 0) { + reject(vault.errors.notRunning); + } + if (request.readyState == 4 && request.status == 401) { + reject(vault.errors.denied); + } + }; + request.open('GET', 'http://localhost:65092/hwi-bridge/v1/request-permission'); + request.send(); + }); + } + + return { + errors: { + notRunning: "NotRunning", + denied: "Denied", + socketNotSupported: "SocketNotSupported", + socketError: "SocketError", + }, + askVaultPermission: askVaultPermission, + connectToBackendSocket: connectToBackendSocket, + VaultBridge: VaultBridge + }; +})(); diff --git a/BTCPayServer/wwwroot/js/vaultbridge.ui.js b/BTCPayServer/wwwroot/js/vaultbridge.ui.js new file mode 100644 index 000000000..b273ba453 --- /dev/null +++ b/BTCPayServer/wwwroot/js/vaultbridge.ui.js @@ -0,0 +1,286 @@ +/// +/// file: vaultbridge.js + +var vaultui = (function () { + + /** + * @param {string} type + * @param {string} txt + * @param {string} category + * @param {string} id + */ + function VaultFeedback(type, txt, category, id) { + var self = this; + this.type = type; + this.txt = txt; + this.category = category; + this.id = id; + /** + * @param {string} str + * @param {string} by + */ + this.replace = function (str, by) { + return new VaultFeedback(self.type, self.txt.replace(str, by), self.category, self.id); + }; + } + + var VaultFeedbacks = { + vaultLoading: new VaultFeedback("?", "Checking BTCPayServer Vault is running...", "vault-feedback1", "vault-loading"), + vaultDenied: new VaultFeedback("failed", "The user declined access to the vault.", "vault-feedback1", "vault-denied"), + vaultGranted: new VaultFeedback("ok", "Access to vault granted by owner.", "vault-feedback1", "vault-granted"), + noVault: new VaultFeedback("failed", "BTCPayServer Vault does not seems running, you can download it on Github.", "vault-feedback1", "no-vault"), + noWebsockets: new VaultFeedback("failed", "Web sockets are not supported by the browser.", "vault-feedback1", "no-websocket"), + errorWebsockets: new VaultFeedback("failed", "Error of the websocket while connecting to the backend.", "vault-feedback1", "error-websocket"), + bridgeConnected: new VaultFeedback("ok", "BTCPayServer successfully connected to the vault.", "vault-feedback1", "bridge-connected"), + noDevice: new VaultFeedback("failed", "No device connected.", "vault-feedback2", "no-device"), + fetchingDevice: new VaultFeedback("?", "Fetching device...", "vault-feedback2", "fetching-device"), + deviceFound: new VaultFeedback("ok", "Device found: {{0}}", "vault-feedback2", "device-selected"), + fetchingXpubs: new VaultFeedback("?", "Fetching public keys...", "vault-feedback3", "fetching-xpubs"), + fetchedXpubs: new VaultFeedback("ok", "Public keys successfully fetched.", "vault-feedback3", "xpubs-fetched"), + unexpectedError: new VaultFeedback("failed", "An unexpected error happened.", "vault-feedback3", "unknown-error"), + needPin: new VaultFeedback("?", "Enter the pin.", "vault-feedback3", "need-pin"), + incorrectPin: new VaultFeedback("failed", "Incorrect pin code.", "vault-feedback3", "incorrect-pin"), + invalidPasswordConfirmation: new VaultFeedback("failed", "Invalid password confirmation.", "vault-feedback3", "invalid-password-confirm"), + wrongWallet: new VaultFeedback("failed", "This device can't sign the transaction.", "vault-feedback3", "wrong-wallet"), + needPassphrase: new VaultFeedback("?", "Enter the passphrase.", "vault-feedback3", "need-passphrase"), + signingTransaction: new VaultFeedback("?", "Signing the transaction...", "vault-feedback3", "ask-signing"), + signingRejected: new VaultFeedback("failed", "The user refused to sign the transaction", "vault-feedback3", "user-reject"), + }; + + /** + * @param {string} backend_uri + */ + function VaultBridgeUI(backend_uri) { + /** + * @type {VaultBridgeUI} + */ + var self = this; + this.backend_uri = backend_uri; + /** + * @type {vault.VaultBridge} + */ + this.bridge = null; + + /** + * @type {string} + */ + this.psbt = null; + + this.xpubs = null; + /** + * @param {VaultFeedback} feedback + */ + function show(feedback) { + var icon = $(".vault-feedback." + feedback.category + " " + ".vault-feedback-icon"); + icon.removeClass(); + icon.addClass("vault-feedback-icon"); + if (feedback.type == "?") { + icon.addClass("fa fa-question-circle feedback-icon-loading"); + } + else if (feedback.type == "ok") { + icon.addClass("fa fa-check-circle feedback-icon-success"); + } + else if (feedback.type == "failed") { + icon.addClass("fa fa-times-circle feedback-icon-failed"); + } + var content = $(".vault-feedback." + feedback.category + " " + ".vault-feedback-content"); + content.html(feedback.txt); + } + function showError(json) { + if (json.hasOwnProperty("error")) { + for (var key in VaultFeedbacks) { + if (VaultFeedbacks.hasOwnProperty(key) && VaultFeedbacks[key].id == json.error) { + show(VaultFeedbacks[key]); + if (json.hasOwnProperty("details")) + console.warn(json.details); + return; + } + } + show(VaultFeedbacks.unexpectedError); + if (json.hasOwnProperty("details")) + console.warn(json.details); + } + } + async function needRetry(json) { + if (json.hasOwnProperty("error")) { + var handled = false; + if (json.error === "need-device") { + handled = true; + if (await self.askForDevice()) + return true; + } + if (json.error === "need-pin") { + handled = true; + if (await self.askForPin()) + return true; + } + if (!handled) { + showError(json); + } + } + return false; + } + + this.ensureConnectedToBackend = async function () { + if (!self.bridge) { + $("#vault-dropdown").css("display", "none"); + show(VaultFeedbacks.vaultLoading); + try { + await vault.askVaultPermission(); + } catch (ex) { + if (ex == vault.errors.notRunning) + show(VaultFeedbacks.noVault); + else if (ex == vault.errors.denied) + show(VaultFeedbacks.vaultDenied); + return false; + } + show(VaultFeedbacks.vaultGranted); + try { + self.bridge = await vault.connectToBackendSocket(self.backend_uri); + show(VaultFeedbacks.bridgeConnected); + } catch (ex) { + if (ex == vault.errors.socketNotSupported) + show(VaultFeedbacks.noWebsockets); + if (ex == vault.errors.socketError) + show(VaultFeedbacks.errorWebsockets); + return false; + } + } + return true; + }; + this.askForDevice = async function () { + if (!await self.ensureConnectedToBackend()) + return false; + show(VaultFeedbacks.fetchingDevice); + self.bridge.socket.send("ask-device"); + var json = await self.bridge.waitBackendMessage(); + if (json.hasOwnProperty("error")) { + showError(json); + return false; + } + show(VaultFeedbacks.deviceFound.replace("{{0}}", json.model)); + return true; + }; + this.askForXPubs = async function () { + if (!await self.ensureConnectedToBackend()) + return false; + show(VaultFeedbacks.fetchingXpubs); + self.bridge.socket.send("ask-xpubs"); + var json = await self.bridge.waitBackendMessage(); + if (json.hasOwnProperty("error")) { + if (await needRetry(json)) + return await self.askForXPubs(); + return false; + } + show(VaultFeedbacks.fetchedXpubs); + self.xpubs = json; + return true; + }; + + /** + * @returns {Promise} + */ + this.getUserEnterPin = function () { + show(VaultFeedbacks.needPin); + $("#pin-input").css("display", "block"); + $("#vault-confirm").css("display", "block"); + return new Promise(function (resolve, reject) { + var pinCode = ""; + $("#vault-confirm").click(async function () { + $("#pin-input").css("display", "none"); + $("#vault-confirm").css("display", "none"); + $(this).unbind(); + $(".pin-button").unbind(); + $("#pin-display-delete").unbind(); + resolve(pinCode); + }); + $("#pin-display-delete").click(function () { + pinCode = ""; + $("#pin-display").val(""); + }); + $(".pin-button").click(function () { + var id = $(this).attr('id').replace("pin-", ""); + pinCode = pinCode + id; + $("#pin-display").val($("#pin-display").val() + "*"); + }); + }); + }; + + /** + * @returns {Promise} + */ + this.getUserPassphrase = function () { + show(VaultFeedbacks.needPassphrase); + $("#passphrase-input").css("display", "block"); + $("#vault-confirm").css("display", "block"); + return new Promise(function (resolve, reject) { + $("#vault-confirm").click(async function () { + var passphrase = $("#Password").val(); + if (passphrase !== $("#PasswordConfirmation").val()) { + show("invalid-password-confirm"); + return; + } + $("#passphrase-input").css("display", "none"); + $("#vault-confirm").css("display", "none"); + $(this).unbind(); + resolve(passphrase); + }); + }); + }; + + /** + * @returns {Promise} + */ + this.askForPin = async function () { + if (!await self.ensureConnectedToBackend()) + return false; + + self.bridge.socket.send("ask-pin"); + var json = await self.bridge.waitBackendMessage(); + if (json.hasOwnProperty("error")) { + if (await needRetry(json)) + return await self.askForPin(); + return false; + } + + var pinCode = await self.getUserEnterPin(); + var passphrase = await self.getUserPassphrase(); + self.bridge.socket.send(JSON.stringify({ pinCode: pinCode, passphrase: passphrase })); + var json = await self.bridge.waitBackendMessage(); + if (json.hasOwnProperty("error")) { + showError(json); + return false; + } + return true; + } + + /** + * @returns {Promise} + */ + this.askSignPSBT = async function (args) { + if (!await self.ensureConnectedToBackend()) + return false; + show(VaultFeedbacks.signingTransaction); + self.bridge.socket.send("ask-sign"); + var json = await self.bridge.waitBackendMessage(); + if (json.hasOwnProperty("error")) { + if (await needRetry(json)) + return await self.askSignPSBT(args); + return false; + } + self.bridge.socket.send(JSON.stringify(args)); + json = await self.bridge.waitBackendMessage(); + if (json.hasOwnProperty("error")) { + if (await needRetry(json)) + return await self.askSignPSBT(args); + return false; + } + self.psbt = json.psbt; + return true; + }; + } + return { + VaultFeedback: VaultFeedback, + VaultBridgeUI: VaultBridgeUI + }; +})(); diff --git a/BTCPayServer/wwwroot/main/site.css b/BTCPayServer/wwwroot/main/site.css index 08e06b308..bce831524 100644 --- a/BTCPayServer/wwwroot/main/site.css +++ b/BTCPayServer/wwwroot/main/site.css @@ -124,5 +124,15 @@ a.nav-link:hover { } pre { - color: var(--btcpay-preformatted-text-color); + color: var(--btcpay-preformatted-text-color); +} + +.feedback-icon-loading { + color: orange; +} +.feedback-icon-success { + color: green; +} +.feedback-icon-failed { + color: red; }