Ensure the seed isn't leaking to user with can sign permission (#7354)
What changed, and why it matters
This commit fixes a permission leak in BTCPay Server's hot wallet handling. Previously, users who only had permission to sign transactions ('can sign') could also see the wallet's secret seed phrase, which should be restricted to store administrators. The change introduces a new 'HotwalletSafe' gatekeeper that separates 'can sign' from 'can see seed' and stops the seed from being passed through web forms or displayed to lower-privileged users.
Review the HotwalletSafe authorization logic to confirm CanSee and CanSign checks cannot be bypassed, verify that no other controllers still read WellknownMetadataKeys.MasterHDKey directly, and ensure the removed antiforgery token change is accompanied by a working CSRF token in the calling UI.
Security signals we found
Privilege boundary added between transaction signing and seed visibility
Hot wallet seed no longer passed through client-side form fields
Centralized key retrieval in new HotwalletSafe service
Removal of IgnoreAntiforgeryToken from update-labels endpoint
HTML encoding/localization fix for user-supplied label/message in status message
Evidence from the diff
The patch centralizes hot-wallet key access through a new HotwalletSafe service. It replaces ad-hoc checks that combined CanModifyStoreSettings with CanSignWalletTransactions and exposed the MasterHDKey/seed in view models and hidden form fields. Now, signing with a hot wallet uses a UseHotWallet flag and retrieves the extended key server-side via HotwalletSafe.TryUnlock, while the seed viewing UI is gated on a separate CanSee permission (CanModifyStoreSettings). The commit also removes the [IgnoreAntiforgeryToken] attribute from the update-labels endpoint and fixes an HTML-encoding/localization issue in a status message.
Changed components
BTCPayServer.Plugins.Wallets.Controllers.UIStoreOnChainWalletsControllerBTCPayServer.Plugins.Wallets.Controllers.UIWalletsControllerBTCPayServer.Plugins.Wallets.Controllers.UIWalletsController.PSBTBTCPayServer.Plugins.Wallets.HotwalletSafeWalletSettings.cshtmlWalletPSBTDecoded.cshtmlSignWithSeedViewModelWalletPSBTReadyViewModelDerivationSchemeViewModelWalletSettingsViewModelInspect captured patch +149 / −118
diff --git a/BTCPayServer.Tests/PayJoinTests.cs b/BTCPayServer.Tests/PayJoinTests.cs
index 039a9e4..12fcb1a 100644
--- a/BTCPayServer.Tests/PayJoinTests.cs
+++ b/BTCPayServer.Tests/PayJoinTests.cs
@@ -480,7 +480,7 @@ namespace BTCPayServer.Tests
changeIndex = i;
}
- var derivationSchemeSettings = alice.GetController<UIWalletsController>().GetDerivationSchemeSettings(new WalletId(alice.StoreId, "BTC"));
+ var derivationSchemeSettings = alice.GetController<UIWalletsController>().GetDerivationSchemeSettings("BTC");
var signingAccount = derivationSchemeSettings.GetFirstAccountKeySettings();
psbt.SignAll(derivationSchemeSettings.AccountDerivation, alice.GenerateWalletResponseV.AccountHDKey, signingAccount.GetRootedKeyPath());
using var fakeServer = new FakeServer();
diff --git a/BTCPayServer/Models/StoreViewModels/DerivationSchemeViewModel.cs b/BTCPayServer/Models/StoreViewModels/DerivationSchemeViewModel.cs
index eff6f4f..e6d8d9b 100644
--- a/BTCPayServer/Models/StoreViewModels/DerivationSchemeViewModel.cs
+++ b/BTCPayServer/Models/StoreViewModels/DerivationSchemeViewModel.cs
@@ -1,7 +1,6 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http;
-using NBitcoin;
namespace BTCPayServer.Models.StoreViewModels
{
@@ -36,15 +35,5 @@ namespace BTCPayServer.Models.StoreViewModels
public bool CanCreateNewColdWallet { get; set; }
public bool SupportSegwit { get; set; }
public bool SupportTaproot { get; set; }
- public RootedKeyPath GetAccountKeypath()
- {
- if (KeyPath != null && RootFingerprint != null &&
- NBitcoin.KeyPath.TryParse(KeyPath, out var p) &&
- HDFingerprint.TryParse(RootFingerprint, out var fp))
- {
- return new RootedKeyPath(fp, p);
- }
- return null;
- }
}
}
diff --git a/BTCPayServer/Models/StoreViewModels/WalletSettingsViewModel.cs b/BTCPayServer/Models/StoreViewModels/WalletSettingsViewModel.cs
index 3c52db1..280fb08 100644
--- a/BTCPayServer/Models/StoreViewModels/WalletSettingsViewModel.cs
+++ b/BTCPayServer/Models/StoreViewModels/WalletSettingsViewModel.cs
@@ -24,7 +24,7 @@ namespace BTCPayServer.Models.StoreViewModels
public bool IsMultiSig => AccountKeys.Count > 1;
public List<WalletSettingsAccountKeyViewModel> AccountKeys { get; set; } = new();
- public bool NBXSeedAvailable { get; set; }
+ public bool CanSeeSeed { get; set; }
public string StoreName { get; set; }
public string UriScheme { get; set; }
diff --git a/BTCPayServer/Models/WalletViewModels/SignWithSeedViewModel.cs b/BTCPayServer/Models/WalletViewModels/SignWithSeedViewModel.cs
index 49e6d9c..7c33f79 100644
--- a/BTCPayServer/Models/WalletViewModels/SignWithSeedViewModel.cs
+++ b/BTCPayServer/Models/WalletViewModels/SignWithSeedViewModel.cs
@@ -12,6 +12,8 @@ namespace BTCPayServer.Models.WalletViewModels
[Display(Name = "BIP39 Seed (12/24 word mnemonic phrase) or HD private key (xprv...)")]
public string SeedOrKey { get; set; }
+ public bool UseHotWallet { get; set; }
+
[Display(Name = "Optional seed passphrase")]
public string Passphrase { get; set; }
diff --git a/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs b/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs
index 65400e3..3dd71bb 100644
--- a/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs
+++ b/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs
@@ -8,6 +8,7 @@ namespace BTCPayServer.Models.WalletViewModels
{
public SigningContextModel SigningContext { get; set; } = new SigningContextModel();
public string SigningKey { get; set; }
+ public bool UseHotWallet { get; set; }
public string SigningKeyPath { get; set; }
public class DestinationViewModel
diff --git a/BTCPayServer/Plugins/Wallets/Controllers/UIStoreOnChainWalletsController.cs b/BTCPayServer/Plugins/Wallets/Controllers/UIStoreOnChainWalletsController.cs
index ca3d979..d92a72b 100644
--- a/BTCPayServer/Plugins/Wallets/Controllers/UIStoreOnChainWalletsController.cs
+++ b/BTCPayServer/Plugins/Wallets/Controllers/UIStoreOnChainWalletsController.cs
@@ -27,7 +27,6 @@ using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.Localization;
using NBitcoin;
using NBitcoin.DataEncoders;
-using NBXplorer;
using NBXplorer.Models;
using Newtonsoft.Json.Linq;
@@ -48,7 +47,8 @@ public class UIStoreOnChainWalletsController(
WalletFileParsers onChainWalletParsers,
EventAggregator eventAggregator,
IHtmlHelper html,
- IStringLocalizer stringLocalizer)
+ IStringLocalizer stringLocalizer,
+ HotwalletSafe hotwalletSafe)
: Controller
{
private readonly IDataProtector _dataProtector = dataProtector.CreateProtector("ConfigProtector");
@@ -70,15 +70,9 @@ public class UIStoreOnChainWalletsController(
return checkResult;
}
- var derivation = GetExistingDerivationStrategy(vm.CryptoCode, store);
- vm.DerivationScheme = derivation?.AccountDerivation.ToString();
-
var perm = await CanUseHotWallet();
- var canAccessSeedMaterial =
- (await authorizationService.AuthorizeAsync(User, vm.StoreId, Policies.CanModifyStoreSettings)).Succeeded;
vm.SetPermission(perm);
- vm.CanGenerateNewWallet = canAccessSeedMaterial && (vm.CanUseHotWallet || vm.CanCreateNewColdWallet);
-
+ vm.CanGenerateNewWallet = perm.CanCreateHotWallet;
return View(nameof(SetupWallet), vm);
}
@@ -461,12 +455,10 @@ public class UIStoreOnChainWalletsController(
var storeBlob = store.GetStoreBlob();
var excludeFilters = storeBlob.GetExcludedPaymentMethods();
var perm = await CanUseHotWallet();
- var canAccessSeedMaterial =
- (await authorizationService.AuthorizeAsync(User, storeId, Policies.CanModifyStoreSettings)).Succeeded;
- var client = explorerProvider.GetExplorerClient(network);
var handler = paymentMethodHandlerDictionary.GetBitcoinHandler(cryptoCode);
+ var hw = await hotwalletSafe.TryUnlock(User, new WalletId(storeId, cryptoCode));
var vm = new WalletSettingsViewModel
{
StoreId = storeId,
@@ -482,11 +474,7 @@ public class UIStoreOnChainWalletsController(
KeyPath = derivation.GetFirstAccountKeySettings().AccountKeyPath?.ToString(),
UriScheme = network.NBitcoinNetwork.UriScheme,
Label = derivation.Label,
- NBXSeedAvailable = canAccessSeedMaterial &&
- derivation.IsHotWallet &&
- perm.CanCreateHotWallet &&
- !string.IsNullOrEmpty(await client.GetMetadataAsync<string>(derivation.AccountDerivation,
- WellknownMetadataKeys.MasterHDKey)),
+ CanSeeSeed = hw?.CanSee is true,
AccountKeys = (derivation.AccountKeySettings ?? [])
.Select(e => new WalletSettingsAccountKeyViewModel
{
@@ -638,24 +626,13 @@ public class UIStoreOnChainWalletsController(
return checkResult;
}
- var derivation = GetExistingDerivationStrategy(cryptoCode, store);
- if (derivation == null)
- {
- return NotFound();
- }
-
- if (!(await CanUseHotWallet()).CanCreateHotWallet)
- return NotFound();
-
- var client = explorerProvider.GetExplorerClient(network);
- if (await GetSeed(client, derivation) != null)
+ var hw = await hotwalletSafe.TryUnlock(User, new WalletId(storeId, cryptoCode));
+ if (hw is { CanSee: true, Mnemonic: not null })
{
- var mnemonic = await client.GetMetadataAsync<string>(derivation.AccountDerivation,
- WellknownMetadataKeys.Mnemonic, cancellationToken);
var recoveryVm = new RecoverySeedBackupViewModel
{
CryptoCode = cryptoCode,
- Mnemonic = mnemonic,
+ Mnemonic = hw.Mnemonic,
IsStored = true,
RequireConfirm = false,
ReturnUrl = Url.Action(nameof(WalletSettings), new { storeId, cryptoCode })
@@ -784,13 +761,6 @@ public class UIStoreOnChainWalletsController(
return store.GetPaymentMethodConfig<DerivationSchemeSettings>(PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode), paymentMethodHandlerDictionary);
}
- private async Task<string> GetSeed(ExplorerClient client, DerivationSchemeSettings derivation)
- {
- return derivation.IsHotWallet &&
- await client.GetMetadataAsync<string>(derivation.AccountDerivation, WellknownMetadataKeys.MasterHDKey) is { } seed &&
- !string.IsNullOrEmpty(seed) ? seed : null;
- }
-
private async Task<WalletCreationPermissions> CanUseHotWallet()
{
return await authorizationService.CanUseHotWallet(policiesSettings, User);
diff --git a/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.PSBT.cs b/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.PSBT.cs
index 1d783d8..3fb9aed 100644
--- a/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.PSBT.cs
+++ b/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.PSBT.cs
@@ -102,22 +102,16 @@ namespace BTCPayServer.Controllers
return await WalletPSBT(walletId, vm, "decode");
}
- var derivationScheme = GetDerivationSchemeSettings(walletId);
- if (await CanAutoSignWithHotWallet(walletId, derivationScheme))
+ var hw = await hotwalletSafe.TryUnlock(User, walletId);
+ if (hw?.CanSign is true)
{
- var extKey = await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode)
- .GetMetadataAsync<string>(derivationScheme.AccountDerivation,
- WellknownMetadataKeys.MasterHDKey);
- if (extKey != null)
+ return await SignWithSeed(walletId, new SignWithSeedViewModel
{
- return await SignWithSeed(walletId, new SignWithSeedViewModel
- {
- SeedOrKey = extKey,
- SigningContext = vm.SigningContext,
- ReturnUrl = vm.ReturnUrl,
- BackUrl = vm.BackUrl
- });
- }
+ UseHotWallet = true,
+ SigningContext = vm.SigningContext,
+ ReturnUrl = vm.ReturnUrl,
+ BackUrl = vm.BackUrl
+ });
}
return View("WalletSigningOptions", new WalletSigningOptionsModel
{
@@ -141,11 +135,7 @@ namespace BTCPayServer.Controllers
ReturnUrl = returnUrl ?? referer,
CryptoCode = network.CryptoCode
};
-
- var derivationSchemeSettings = GetDerivationSchemeSettings(walletId);
- if (derivationSchemeSettings == null)
- return NotFound();
- vm.NBXSeedAvailable = await CanAutoSignWithHotWallet(walletId, derivationSchemeSettings);
+ vm.NBXSeedAvailable = await CanAutoSignWithHotWallet(walletId);
return View(vm);
}
@@ -173,7 +163,7 @@ namespace BTCPayServer.Controllers
if (derivationSchemeSettings == null)
return NotFound();
- vm.NBXSeedAvailable = await CanAutoSignWithHotWallet(walletId, derivationSchemeSettings);
+ vm.NBXSeedAvailable = await CanAutoSignWithHotWallet(walletId);
vm.BackUrl ??= HttpContext.Request.GetTypedHeaders().Referer?.AbsolutePath;
vm.SigningContext.PSBT = vm.PSBT;
@@ -268,6 +258,7 @@ namespace BTCPayServer.Controllers
psbtObject = await ExplorerClientProvider.UpdatePSBT(derivationSchemeSettings, psbtObject) ?? psbtObject;
IHDKey signingKey = null;
RootedKeyPath signingKeyPath = null;
+
try
{
signingKey = new BitcoinExtPubKey(vm.SigningKey, network.NBitcoinNetwork);
@@ -275,7 +266,7 @@ namespace BTCPayServer.Controllers
catch { }
try
{
- signingKey ??= new BitcoinExtKey(vm.SigningKey, network.NBitcoinNetwork);
+ signingKey ??= await GetSigningExtKey(walletId, vm, network);
}
catch { }
@@ -498,7 +489,9 @@ namespace BTCPayServer.Controllers
{
EnforceLowR = vm.SigningContext.EnforceLowR is not false
};
- var extKey = ExtKey.Parse(vm.SigningKey, network.NBitcoinNetwork);
+ var extKey = await GetSigningExtKey(walletId, vm, network);
+ if (extKey is null)
+ throw new Exception("Invalid signing key");
proposedPayjoin = proposedPayjoin.SignAll(derivationSchemeSettings.AccountDerivation,
extKey,
RootedKeyPath.Parse(vm.SigningKeyPath));
@@ -635,6 +628,8 @@ namespace BTCPayServer.Controllers
}
}
+
+
private IActionResult FilePSBT(PSBT psbt, string fileName)
{
return File(psbt.ToBytes(), "application/octet-stream", fileName);
@@ -666,12 +661,5 @@ namespace BTCPayServer.Controllers
ReturnUrl = vm.ReturnUrl
});
}
-
- private async Task<bool> CanAutoSignWithHotWallet(WalletId walletId, DerivationSchemeSettings derivationSchemeSettings)
- {
- return derivationSchemeSettings?.IsHotWallet is true &&
- (await authorizationService.AuthorizeAsync(User, walletId.StoreId, WalletPolicies.CanSignWalletTransactions)).Succeeded &&
- await CanUseHotWallet();
- }
}
}
diff --git a/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs b/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs
index 41b1655..362e652 100644
--- a/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs
+++ b/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs
@@ -6,13 +6,13 @@ using System.Globalization;
using System.Linq;
using System.Net.Mime;
using System.Text;
+using System.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.BIP78.Sender;
-using BTCPayServer.Blazor;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
@@ -69,7 +69,6 @@ namespace BTCPayServer.Controllers
IFeeProviderFactory feeRateProvider,
BTCPayWalletProvider walletProvider,
WalletReceiveService walletReceiveService,
- SettingsRepository settingsRepository,
DelayedTransactionBroadcaster broadcaster,
PayjoinClient payjoinClient,
IServiceProvider serviceProvider,
@@ -82,7 +81,8 @@ namespace BTCPayServer.Controllers
IStringLocalizer stringLocalizer,
TransactionLinkProviders transactionLinkProviders,
InvoiceRepository invoiceRepository,
- DisplayFormatter displayFormatter)
+ DisplayFormatter displayFormatter,
+ HotwalletSafe hotwalletSafe)
: Controller
{
private StoreRepository Repository { get; } = repo;
@@ -889,12 +889,6 @@ namespace BTCPayServer.Controllers
await conn.ExecuteAsync("REFRESH MATERIALIZED VIEW wallets_history;");
}
- private async Task<bool> CanUseHotWallet()
- {
- var policies = await settingsRepository.GetSettingAsync<PoliciesSettings>();
- return (await authorizationService.CanUseHotWallet(policies, User)).CanCreateHotWallet;
- }
-
[HttpGet("{walletId}/send")]
[Authorize(Policy = WalletPolicies.CanCreateWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> WalletSend(
@@ -902,9 +896,8 @@ namespace BTCPayServer.Controllers
string? defaultDestination = null, string? defaultAmount = null, string[]? bip21 = null,
[FromQuery] string? returnUrl = null)
{
- var store = await Repository.FindStore(walletId.StoreId);
var paymentMethod = GetDerivationSchemeSettings(walletId);
- if (paymentMethod == null || store is null)
+ if (paymentMethod == null)
return NotFound();
var network = this.NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
if (network == null || network.ReadonlyWallet)
@@ -943,7 +936,7 @@ namespace BTCPayServer.Controllers
}
var recommendedFeesAsync = GetRecommendedFees(network, feeRateProvider);
var balance = walletProvider.GetWallet(network).GetBalance(paymentMethod.AccountDerivation);
- model.NBXSeedAvailable = await GetSeed(walletId, network) != null;
+ model.NBXSeedAvailable = await CanAutoSignWithHotWallet(walletId);
var Balance = await balance;
model.CurrentBalance = (Balance.Available ?? Balance.Total).GetValue(network);
if (Balance.Immature is null)
@@ -972,6 +965,9 @@ namespace BTCPayServer.Controllers
return View(model);
}
+ private async Task<bool> CanAutoSignWithHotWallet(WalletId walletId)
+ => (await hotwalletSafe.TryUnlock(User, walletId))?.CanSign is true;
+
public record FiatRate(decimal Rate, string Fiat);
private async Task<FiatRate> FetchRate(WalletId walletId)
{
@@ -1023,19 +1019,6 @@ namespace BTCPayServer.Controllers
return options.ToArray();
}
- private async Task<string?> GetSeed(WalletId walletId, BTCPayNetwork network)
- {
- return await CanUseHotWallet() &&
- GetDerivationSchemeSettings(walletId) is DerivationSchemeSettings s &&
- s.IsHotWallet &&
- ExplorerClientProvider.GetExplorerClient(network) is ExplorerClient client &&
- await client.GetMetadataAsync<string>(s.AccountDerivation, WellknownMetadataKeys.MasterHDKey) is
- string seed &&
- !string.IsNullOrEmpty(seed)
- ? seed
- : null;
- }
-
[HttpPost("{walletId}/send")]
[Authorize(Policy = WalletPolicies.CanCreateWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> WalletSend(
@@ -1060,7 +1043,7 @@ namespace BTCPayServer.Controllers
if (network == null || network.ReadonlyWallet)
return NotFound();
- vm.NBXSeedAvailable = await GetSeed(walletId, network) != null;
+ vm.NBXSeedAvailable = await CanAutoSignWithHotWallet(walletId);
if (!string.IsNullOrEmpty(bip21))
{
vm.Outputs?.Clear();
@@ -1400,12 +1383,23 @@ namespace BTCPayServer.Controllers
{
if (!string.IsNullOrEmpty(uriBuilder.Label) || !string.IsNullOrEmpty(uriBuilder.Message))
{
- TempData.SetStatusMessageModel(new StatusMessageModel
+ var html = (HtmlEncoder.Default.Encode(uriBuilder.Label ?? ""), HtmlEncoder.Default.Encode(uriBuilder.Message ?? "")) switch
{
- Severity = StatusMessageModel.StatusSeverity.Info,
- Html =
- $"Payment {(string.IsNullOrEmpty(uriBuilder.Label) ? string.Empty : $" to <strong>{uriBuilder.Label}</strong>")} {(string.IsNullOrEmpty(uriBuilder.Message) ? string.Empty : $" for <strong>{uriBuilder.Message}</strong>")}"
- });
+ ({ Length: > 0 } label, { Length: > 0 } message) =>
+ StringLocalizer["Payment to <strong>{0}</strong> for <strong>{1}</strong>",label, message],
+ ({ Length: > 0 } label, _) =>
+ StringLocalizer["Payment to <strong>{0}</strong>", label],
+ (_, { Length: > 0 } message) =>
+ StringLocalizer["Payment for <strong>{0}</strong>", message],
+ _ => null
+ };
+
+ if (html != null)
+ TempData.SetStatusMessageModel(new StatusMessageModel
+ {
+ Severity = StatusMessageModel.StatusSeverity.Info,
+ Html = html
+ });
}
}
@@ -1497,6 +1491,7 @@ namespace BTCPayServer.Controllers
FormParameters =
{
{ "SigningKey", vm.SigningKey },
+ { "UseHotWallet", vm.UseHotWallet.ToString() },
{ "SigningKeyPath", vm.SigningKeyPath },
{ "command", "decode" }
}
@@ -1579,7 +1574,7 @@ namespace BTCPayServer.Controllers
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
if (network == null)
throw new FormatException("Invalid value for crypto code");
- ExtKey extKey = viewModel.GetExtKey(network.NBitcoinNetwork);
+ var extKey = await GetExtKey(walletId, viewModel, network);
if (extKey is null)
{
@@ -1649,7 +1644,8 @@ namespace BTCPayServer.Controllers
viewModel.SigningContext.PSBT = psbt?.ToBase64();
return await RedirectToWalletPSBTReady(walletId, new WalletPSBTReadyViewModel
{
- SigningKey = signingKey.GetWif(network.NBitcoinNetwork).ToString(),
+ SigningKey = viewModel.UseHotWallet ? null : signingKey.GetWif(network.NBitcoinNetwork).ToString(),
+ UseHotWallet = viewModel.UseHotWallet,
SigningKeyPath = rootedKeyPath?.ToString(),
SigningContext = viewModel.SigningContext,
ReturnUrl = viewModel.ReturnUrl,
@@ -1657,6 +1653,33 @@ namespace BTCPayServer.Controllers
});
}
+ private async Task<ExtKey?> GetExtKey(WalletId walletId, SignWithSeedViewModel viewModel, BTCPayNetwork network)
+ {
+ return viewModel.UseHotWallet &&
+ await hotwalletSafe.TryUnlock(User, walletId) is
+ { CanSign: true, ExtKey: var k } ? k
+ : viewModel.GetExtKey(network.NBitcoinNetwork);
+ }
+ private async Task<ExtKey?> GetSigningExtKey(WalletId walletId, WalletPSBTReadyViewModel viewModel, BTCPayNetwork network)
+ {
+ if (viewModel.UseHotWallet)
+ {
+ var key = await hotwalletSafe.TryUnlock(User, walletId) is
+ { CanSign: true, ExtKey: var k }
+ ? k
+ : null;
+ if (!RootedKeyPath.TryParse(viewModel.SigningKeyPath ?? "", out var pp))
+ return null;
+ return key?.Derive(pp.KeyPath);
+ }
+ else
+ {
+ if (string.IsNullOrWhiteSpace(viewModel.SigningKey))
+ return null;
+ return ExtKey.Parse(viewModel.SigningKey, network.NBitcoinNetwork);
+ }
+ }
+
private static string BuildErrorMessage(PSBTError[] errors)
{
StringBuilder errorMessage = new();
@@ -1761,9 +1784,9 @@ namespace BTCPayServer.Controllers
}
internal DerivationSchemeSettings? GetDerivationSchemeSettings(WalletId walletId)
- {
- return GetCurrentStore().GetDerivationSchemeSettings(handlers, walletId.CryptoCode);
- }
+ => GetCurrentStore().Id != walletId?.StoreId ? null : GetDerivationSchemeSettings(walletId.CryptoCode);
+ internal DerivationSchemeSettings? GetDerivationSchemeSettings(string cryptoCode)
+ => GetCurrentStore().GetDerivationSchemeSettings(handlers, cryptoCode);
private static async Task<IMoney> GetBalanceAsMoney(BTCPayWallet wallet,
DerivationStrategyBase derivationStrategy)
@@ -1935,7 +1958,6 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{walletId}/update-labels")]
- [IgnoreAntiforgeryToken]
[Authorize(Policy = WalletPolicies.CanManageWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> UpdateLabels(
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
diff --git a/BTCPayServer/Plugins/Wallets/HotwalletSafe.cs b/BTCPayServer/Plugins/Wallets/HotwalletSafe.cs
new file mode 100644
index 0000000..1c36854
--- /dev/null
+++ b/BTCPayServer/Plugins/Wallets/HotwalletSafe.cs
@@ -0,0 +1,57 @@
+#nullable enable
+using System.Security.Claims;
+using System.Threading.Tasks;
+using BTCPayServer.Client;
+using BTCPayServer.Data;
+using BTCPayServer.Services;
+using BTCPayServer.Services.Invoices;
+using BTCPayServer.Services.Stores;
+using Microsoft.AspNetCore.Authorization;
+using NBitcoin;
+using NBXplorer;
+
+namespace BTCPayServer.Plugins.Wallets;
+
+public class HotwalletSafe(
+ StoreRepository storeRepository,
+ IAuthorizationService authorizationService,
+ ISettingsAccessor<PoliciesSettings> policies,
+ ExplorerClientProvider explorerClientProvider,
+ PaymentMethodHandlerDictionary handlers)
+{
+ /// <summary>
+ /// Represents the result of successfully unlocking a store hot wallet, including the wallet key material
+ /// and the permissions granted to the current user.
+ /// </summary>
+ /// <param name="Store">The store that owns the wallet.</param>
+ /// <param name="User">The user for whom the wallet was unlocked.</param>
+ /// <param name="Settings">The derivation scheme settings associated with the wallet.</param>
+ /// <param name="ExtKey">The master extended private key used by the hot wallet.</param>
+ /// <param name="Mnemonic">The mnemonic phrase associated with the hot wallet, if available.</param>
+ /// <param name="CanSign">Whether the user is authorized to sign wallet transactions.</param>
+ /// <param name="CanSee">Whether the user is authorized to see the seed.</param>
+ public record HotwalletRecord(StoreData Store, ClaimsPrincipal User, DerivationSchemeSettings Settings, ExtKey ExtKey, string? Mnemonic, bool CanSign, bool CanSee);
+ public async Task<HotwalletRecord?> TryUnlock(ClaimsPrincipal user, WalletId walletId)
+ {
+ var store = await storeRepository.FindStore(walletId.StoreId, user);
+ if (store is null)
+ return null;
+ var client = explorerClientProvider.GetExplorerClient(walletId.CryptoCode);
+ var d = store.GetDerivationSchemeSettings(handlers, walletId.CryptoCode);
+ if (d?.IsHotWallet is not true)
+ return null;
+ var metadata = await client.GetMetadataAsync<string>(d.AccountDerivation, WellknownMetadataKeys.MasterHDKey);
+ if (string.IsNullOrWhiteSpace(metadata))
+ return null;
+ var mnemo = await client.GetMetadataAsync<string>(d.AccountDerivation, WellknownMetadataKeys.Mnemonic);
+ return new(
+ store,
+ user,
+ d,
+ client.Network.NBitcoinNetwork.Parse<BitcoinExtKey>(metadata).ExtKey,
+ mnemo,
+ (await authorizationService.AuthorizeAsync(user, walletId.StoreId, WalletPolicies.CanSignWalletTransactions)).Succeeded,
+ (await authorizationService.AuthorizeAsync(user, walletId.StoreId, Policies.CanModifyStoreSettings)).Succeeded
+ );
+ }
+}
diff --git a/BTCPayServer/Plugins/Wallets/Views/UIStoreOnChainWallets/WalletSettings.cshtml b/BTCPayServer/Plugins/Wallets/Views/UIStoreOnChainWallets/WalletSettings.cshtml
index 1ccb819..cc41805 100644
--- a/BTCPayServer/Plugins/Wallets/Views/UIStoreOnChainWallets/WalletSettings.cshtml
+++ b/BTCPayServer/Plugins/Wallets/Views/UIStoreOnChainWallets/WalletSettings.cshtml
@@ -45,7 +45,7 @@
<button type="button" class="dropdown-item" id="RegisterWallet" data-store="@Model.StoreName" data-scheme="@Model.UriScheme" data-url="@Url.Action("WalletSend", "UIWallets", new {walletId = Model.WalletId, bip21 = "%s"})" hidden text-translate="true">Register wallet for payment links</button>
}
<div class="dropdown-divider"></div>
- @if (Model.NBXSeedAvailable)
+ @if (Model.CanSeeSeed)
{
<a asp-action="WalletSeed" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode" class="dropdown-item" id="ViewSeed" text-translate="true">View seed</a>
}
diff --git a/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletPSBTDecoded.cshtml b/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletPSBTDecoded.cshtml
index 25e096e..a1f0579 100644
--- a/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletPSBTDecoded.cshtml
+++ b/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletPSBTDecoded.cshtml
@@ -118,6 +118,7 @@ else if (isReady)
{
<form method="post" asp-action="WalletPSBTReady" asp-route-walletId="@walletId" class="my-5" permission="@WalletPolicies.CanBroadcastWalletTransactions">
<input type="hidden" asp-for="SigningKey" />
+ <input type="hidden" asp-for="UseHotWallet" />
<input type="hidden" asp-for="SigningKeyPath" />
<partial name="SigningContext" for="SigningContext" />
<input type="hidden" asp-for="ReturnUrl" />
diff --git a/BTCPayServer/Plugins/Wallets/WalletsPlugin.cs b/BTCPayServer/Plugins/Wallets/WalletsPlugin.cs
index 3bc1853..373cf4c 100644
--- a/BTCPayServer/Plugins/Wallets/WalletsPlugin.cs
+++ b/BTCPayServer/Plugins/Wallets/WalletsPlugin.cs
@@ -17,6 +17,7 @@ public class WalletsPlugin : BaseBTCPayServerPlugin
public override void Execute(IServiceCollection services)
{
+ services.AddTransient<HotwalletSafe>();
services.AddPolicyDefinitions(
new PolicyDefinition(
WalletPolicies.CanManageWallets,
Why this scored 70/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.