Files
btcpayserver/BTCPayServer/Blazor/VaultBridge/HWIController.cs
Nicolas Dorier 2f26979ed7 Refactor vault (#6678)
* Use Blazor for the Vault code

* Put elements in different file

* Controller abstraction

* Break into VaultElement
2025-04-21 17:09:46 +09:00

308 lines
14 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Blazor.VaultBridge.Elements;
using BTCPayServer.Data;
using BTCPayServer.Hwi;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.JSInterop;
using NBitcoin;
using NBXplorer.DerivationStrategy;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Blazor.VaultBridge;
public abstract class HWIController : VaultController
{
override protected string VaultUri => "http://127.0.0.1:65092/hwi-bridge/v1";
public string CryptoCode { get; set; }
private static bool IsTrezorT(HwiEnumerateEntry deviceEntry)
{
return deviceEntry.Model.Contains("Trezor_T", StringComparison.OrdinalIgnoreCase);
}
private static bool IsTrezorOne(HwiEnumerateEntry deviceEntry)
{
return deviceEntry.Model.Contains("trezor_1", StringComparison.OrdinalIgnoreCase);
}
protected abstract Task Run(VaultBridgeUI ui, HwiClient hwi, HwiDeviceClient device, HDFingerprint fingerprint, BTCPayNetwork network,
CancellationToken cancellationToken);
protected override async Task Run(VaultBridgeUI ui, VaultClient vaultClient, CancellationToken cancellationToken)
{
var networkProviders = ui.ServiceProvider.GetRequiredService<BTCPayNetworkProvider>();
try
{
var network = networkProviders.GetNetwork<BTCPayNetwork>(CryptoCode);
var hwi = new Hwi.HwiClient(network.NBitcoinNetwork)
{
Transport = new VaultHWITransport(vaultClient), IgnoreInvalidNetwork = network.NBitcoinNetwork.ChainName != ChainName.Mainnet
};
ui.ShowFeedback(FeedbackType.Loading, ui.StringLocalizer["Fetching device..."]);
var version = await hwi.GetVersionAsync(cancellationToken);
if (version.Major < 2)
{
ui.ShowFeedback(FeedbackType.Failed,
ui.ViewLocalizer[
"Your BTCPay Server Vault version is outdated. Please <a target=\"_blank\" href=\"https://github.com/btcpayserver/BTCPayServer.Vault/releases/latest\">download</a> the latest version."]);
}
var gettingEntries = hwi.EnumerateEntriesAsync(cancellationToken);
var timeout = Task.Delay(TimeSpan.FromSeconds(7.0), cancellationToken);
var finished = await Task.WhenAny(gettingEntries, timeout);
// Wallets such as Trezor Safe 3 will block EnumerateEntriesAsync until password is set on the device.
// So if we wait for 7 sec and this doesn't returns, let's notify the user to look the hardware.
if (finished == timeout)
ui.ShowFeedback(FeedbackType.Loading, ui.StringLocalizer["Please, enter the passphrase on the device."]);
var entries = await gettingEntries;
var deviceEntry = entries.FirstOrDefault();
if (deviceEntry is null)
{
ui.ShowFeedback(FeedbackType.Failed, ui.StringLocalizer["No device connected."]);
ui.ShowRetry();
return;
}
if (deviceEntry.Model is null)
{
ui.ShowFeedback(FeedbackType.Failed,
ui.StringLocalizer["Unsupported hardware wallet, try to update BTCPay Server Vault"]);
ui.ShowRetry();
return;
}
var device = new HwiDeviceClient(hwi, deviceEntry.DeviceSelector, deviceEntry.Model, deviceEntry.Fingerprint);
ui.ShowFeedback(FeedbackType.Success, ui.StringLocalizer["Device found: {0}", device.GetNiceModelName()]);
HDFingerprint? fingerprint = deviceEntry.Fingerprint;
bool dirtyDevice = false;
if (deviceEntry is { Code: HwiErrorCode.DeviceNotReady })
{
// It seems that this 'if (IsTrezorT(deviceEntry))' can be removed.
// I have not managed to trigger this anymore with latest 2.8.9
// the passphrase is getting asked during EnumerateEntriesAsync
if (IsTrezorT(deviceEntry))
{
ui.ShowFeedback(FeedbackType.Loading, ui.StringLocalizer["Please, enter the passphrase on the device."]);
// The make the trezor T ask for password
await device.GetXPubAsync(new KeyPath("44'"), cancellationToken);
dirtyDevice = true;
}
else if (deviceEntry.NeedsPinSent is true)
{
await device.PromptPinAsync(cancellationToken);
var pinElement = new PinInput(ui);
var pin = await pinElement.GetPin();
if (!await device.SendPinAsync(pin, cancellationToken))
{
ui.ShowFeedback(FeedbackType.Failed, ui.StringLocalizer["Incorrect pin code."]);
ui.ShowRetry();
return;
}
ui.ShowFeedback(FeedbackType.Success, ui.StringLocalizer["Pin code verified."]);
dirtyDevice = true;
}
}
else if (deviceEntry is { Code: HwiErrorCode.DeviceNotInitialized })
{
ui.ShowFeedback(FeedbackType.Failed, ui.StringLocalizer["The device has not been initialized."]);
ui.ShowRetry();
return;
}
if (IsTrezorOne(deviceEntry) && HasPassphraseProtection(deviceEntry))
{
var passwordEl = new Passphrase(ui);
device.Password = await passwordEl.GetPassword();
if (!string.IsNullOrEmpty(device.Password))
{
device = new HwiDeviceClient(hwi, DeviceSelectors.FromDeviceType("trezor", deviceEntry.Path), deviceEntry.Model, null)
{
Password = device.Password
};
fingerprint = null;
}
}
if (dirtyDevice)
{
entries = (await hwi.EnumerateEntriesAsync(cancellationToken)).ToList();
deviceEntry = entries.FirstOrDefault() ?? deviceEntry;
device = new HwiDeviceClient(hwi, deviceEntry.DeviceSelector, deviceEntry.Model, deviceEntry.Fingerprint);
fingerprint = deviceEntry.Fingerprint;
}
if (fingerprint is null)
{
ui.ShowFeedback(FeedbackType.Loading, ui.StringLocalizer["Fetching wallet's fingerprint."]);
fingerprint = (await device.GetXPubAsync(new KeyPath("44'"), ui.CancellationToken)).ExtPubKey.ParentFingerprint;
device = new HwiDeviceClient(hwi, DeviceSelectors.FromFingerprint(fingerprint.Value), deviceEntry.Model, fingerprint)
{
Password = device.Password
};
ui.ShowFeedback(FeedbackType.Success, ui.StringLocalizer["Wallet's fingerprint fetched."]);
}
await Run(ui, hwi, device, fingerprint.Value, network, cancellationToken);
}
catch (HwiException e)
{
var message = e switch
{
{ ErrorCode: HwiErrorCode.ActionCanceled } => ui.StringLocalizer["Action canceled by user"],
_ => ui.StringLocalizer["An unexpected error happened: {0}", $"{e.Message} ({e.ErrorCode})"],
};
ui.ShowFeedback(FeedbackType.Failed, message);
ui.ShowRetry();
}
}
private bool HasPassphraseProtection(HwiEnumerateEntry deviceEntry)
{
if (deviceEntry.NeedsPassphraseSent is true)
return true;
if (deviceEntry.RawData["warnings"] is JArray arr)
{
return arr.Any(e => e.ToString().Contains("passphrase was not provided", StringComparison.OrdinalIgnoreCase));
}
return false;
}
}
public class SignHWIController : HWIController
{
public string StoreId { get; set; }
public string PSBT { get; set; }
protected override async Task Run(VaultBridgeUI ui, HwiClient hwi, HwiDeviceClient device, HDFingerprint fingerprint, BTCPayNetwork network,
CancellationToken cancellationToken)
{
if (!NBitcoin.PSBT.TryParse(PSBT, network.NBitcoinNetwork, out var psbt))
return;
var store = await ui.ServiceProvider.GetRequiredService<StoreRepository>().FindStore(StoreId ?? "");
var handlers = ui.ServiceProvider.GetRequiredService<PaymentMethodHandlerDictionary>();
var pmi = Payments.PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode);
var derivationSettings = store?.GetPaymentMethodConfig<DerivationSchemeSettings>(pmi, handlers);
if (store is null || derivationSettings is null)
return;
ui.ShowFeedback(FeedbackType.Loading, ui.StringLocalizer["Checking if this device can sign the transaction..."]);
// we ensure that the device fingerprint is part of the derivation settings
if (derivationSettings.AccountKeySettings.All(a => a.RootFingerprint != fingerprint))
{
ui.ShowFeedback(FeedbackType.Failed,
ui.StringLocalizer[
"This device can't sign the transaction. (Wrong device, wrong passphrase or wrong device fingerprint in your wallet settings)"]);
ui.ShowRetry();
return;
}
derivationSettings.RebaseKeyPaths(psbt);
// otherwise, let the device check if it can sign anything
var signableInputs = psbt.Inputs
.SelectMany(i => i.HDKeyPaths)
.Where(i => i.Value.MasterFingerprint == fingerprint)
.ToArray();
if (signableInputs.Length > 0)
{
var actualPubKey = (await device.GetXPubAsync(signableInputs[0].Value.KeyPath, cancellationToken)).GetPublicKey();
if (actualPubKey != signableInputs[0].Key)
{
ui.ShowFeedback(FeedbackType.Failed,
ui.StringLocalizer["This device can't sign the transaction. (The wallet keypath in your wallet settings seems incorrect)"]);
ui.ShowRetry();
return;
}
if (derivationSettings.IsMultiSigOnServer)
{
var alreadySigned = psbt.Inputs.Any(a =>
a.PartialSigs.Any(a => a.Key == actualPubKey));
if (alreadySigned)
{
ui.ShowFeedback(FeedbackType.Failed, ui.StringLocalizer["This device already signed PSBT."]);
ui.ShowRetry();
return;
}
}
}
ui.ShowFeedback(FeedbackType.Loading,
ui.StringLocalizer["Please review and confirm the transaction on your device..."]);
psbt = await device.SignPSBTAsync(psbt, cancellationToken);
ui.ShowFeedback(FeedbackType.Loading, ui.StringLocalizer["Transaction signed successfully, proceeding to review..."]);
await ui.JSRuntime.InvokeVoidAsync("vault.setSignedPSBT", cancellationToken, new System.Text.Json.Nodes.JsonObject() { ["psbt"] = psbt.ToBase64() });
}
}
public class GetXPubController : HWIController
{
protected override async Task Run(VaultBridgeUI ui, HwiClient hwi, HwiDeviceClient device, HDFingerprint fingerprint, BTCPayNetwork network,
CancellationToken cancellationToken)
{
var xpubSelect = new XPubSelect(ui, network.NBitcoinNetwork);
var xpubInfo = await xpubSelect.GetXPubSelect();
var scriptPubKeyTypeType = xpubInfo.ToScriptPubKeyType();
ui.ShowFeedback(FeedbackType.Loading, ui.StringLocalizer["Fetching public keys..."]);
KeyPath keyPath = xpubInfo.ToKeyPath().Derive(network.CoinType).Derive(xpubInfo.AccountNumber, true);
BitcoinExtPubKey xpub = await device.GetXPubAsync(keyPath, cancellationToken);
var factory = network.NBXplorerNetwork.DerivationStrategyFactory;
var strategy = factory.CreateDirectDerivationStrategy(xpub, new DerivationStrategyOptions() { ScriptPubKeyType = scriptPubKeyTypeType });
ui.ShowFeedback(FeedbackType.Success, ui.StringLocalizer["Public keys successfully fetched."]);
var firstDepositPath = new KeyPath(0, 0);
var firstDepositAddr =
network.NBXplorerNetwork.CreateAddress(strategy, firstDepositPath, strategy.GetDerivation(firstDepositPath).ScriptPubKey);
ui.ShowFeedback(FeedbackType.Loading,
ui.ViewLocalizer["Please verify that the address displayed on your device is <b>{0}</b>...", firstDepositAddr.ToString()]);
var verif = new VerifyAddress(ui)
{
Device = device,
KeyPath = keyPath.Derive(firstDepositPath),
Address = firstDepositAddr,
ScriptPubKeyType = xpubInfo.ToScriptPubKeyType()
};
if (!await verif.WaitConfirmed())
{
ui.ShowRetry();
return;
}
ui.ShowFeedback(FeedbackType.Loading, ui.StringLocalizer["Saving..."]);
var settings = new DerivationSchemeSettings(strategy, network) { Source = "Vault" };
settings.AccountKeySettings[0].AccountKeyPath = keyPath;
settings.AccountKeySettings[0].RootFingerprint = fingerprint;
string[] mandatoryPrevUtxo = ["trezor", "jade"];
settings.DefaultIncludeNonWitnessUtxo = (device.Model, scriptPubKeyTypeType) switch
{
(_, ScriptPubKeyType.TaprootBIP86) => false,
(_, ScriptPubKeyType.Legacy) => true,
({ } s, _) when mandatoryPrevUtxo.Any(o => s.Contains(o, StringComparison.OrdinalIgnoreCase)) => true,
_ => false,
};
settings.Label = $"{device.GetNiceModelName()} ({fingerprint})";
var handlers = ui.ServiceProvider.GetRequiredService<PaymentMethodHandlerDictionary>();
var handler = handlers.GetBitcoinHandler(network.CryptoCode);
var dataProtector = ui.ServiceProvider.GetRequiredService<IDataProtectionProvider>().CreateProtector("ConfigProtector");
await ui.JSRuntime.InvokeVoidAsync("vault.setXPub", cancellationToken,
new System.Text.Json.Nodes.JsonObject() { ["config"] = dataProtector.ProtectString(JToken.FromObject(settings, handler.Serializer).ToString()) });
}
}