What changed, and why it matters
This commit changes how BTCPay Server stores and handles API keys. Previously, the secret API key itself was used as the database primary key and was stored in plaintext. After this change, the database stores a one-way hash of the secret, a short prefix for display, and a separate non-secret ID for management. The actual secret is only kept briefly in memory after creation and is then erased. The commit also removes an old BitPay-compatible 'legacy API key' feature that used Basic authentication with a plaintext key. These are defensive security improvements that reduce the damage if the database is stolen, but they do not by themselves fix an active vulnerability in running code.
Treat this as a positive hardening patch. Review the migration in production-like environments to ensure existing API keys are correctly migrated and that integrations using the old BitPay Basic-auth legacy API keys are replaced with SIN/token or Greenfield API key authentication before upgrading. Verify the scheduled cleanup job is running so plaintext secrets are not retained indefinitely.
Security signals we found
Database now stores SHA-256 hash of API key secret instead of the secret itself
API key secret is cleared from database after creation via scheduled cleanup job
Public management ID (akid_*) is separated from the secret
Legacy BitPay Basic-auth API key support removed
Migration deletes legacy Type=0 API keys and rewrites remaining keys with hashes/IDs
Revocation endpoint parameter renamed from apikey to apiKeyId
Evidence from the diff
The patch hardens API key storage by separating the API key secret from its database identifier. APIKeyData now has Id (a derived public ID), Hash (SHA-256 of the secret), Prefix (first 6 hex chars of the secret), and Key (the plaintext secret, set only at creation and cleared after ~5 minutes by a scheduled cleanup). A migration rewrites existing rows: legacy Type=0 keys are deleted, remaining keys get new akid_* IDs, a hash, and a prefix. API key lookup now uses Selector.ByApiKey, which hashes the supplied secret and verifies the stored hash. Revocation endpoints now accept apiKeyId rather than the secret. The BitPay Basic-auth API key path (TokenRepository legacy methods, ApiKeyAuthentication handler, UI for generating legacy keys) is removed entirely. Tests and Swagger are updated accordingly.
Changed components
BTCPayServer.Security.Greenfield.APIKeyRepositoryBTCPayServer.Data.Data.APIKeyDataBTCPayServer.Controllers.GreenField.GreenfieldApiKeysControllerBTCPayServer.Controllers.UIManageController.APIKeysBTCPayServer.Security.GreenField.APIKeysAuthenticationHandlerBTCPayServer.Plugins.Bitpay.Security.TokenRepository / BitpayAuthenticationHandler / BitpayAuthorizationHandlerDatabase migrations (20260904133933_hardenapikey)Greenfield API client (BTCPayServer.Client)Inspect captured patch +368 / −391
### BTCPayServer.Client/BTCPayServerClient.APIKeys.cs
@@ -30,15 +30,15 @@ public virtual async Task RevokeCurrentAPIKeyInfo(CancellationToken token = defa
await SendHttpRequest($"api/v1/api-keys/current", null, HttpMethod.Delete, token);
}
- public virtual async Task RevokeAPIKey(string apikey, CancellationToken token = default)
+ public virtual async Task RevokeAPIKey(string apiKeyId, CancellationToken token = default)
{
- if (apikey == null) throw new ArgumentNullException(nameof(apikey));
- await SendHttpRequest($"api/v1/api-keys/{apikey}", null, HttpMethod.Delete, token);
+ if (apiKeyId == null) throw new ArgumentNullException(nameof(apiKeyId));
+ await SendHttpRequest($"api/v1/api-keys/{apiKeyId}", null, HttpMethod.Delete, token);
}
- public virtual async Task RevokeAPIKey(string userId, string apikey, CancellationToken token = default)
+ public virtual async Task RevokeAPIKey(string userId, string apiKeyId, CancellationToken token = default)
{
- if (apikey == null) throw new ArgumentNullException(nameof(apikey));
+ if (apiKeyId == null) throw new ArgumentNullException(nameof(apiKeyId));
if (userId is null) throw new ArgumentNullException(nameof(userId));
- await SendHttpRequest($"api/v1/users/{userId}/api-keys/{apikey}", null, HttpMethod.Delete, token);
+ await SendHttpRequest($"api/v1/users/{userId}/api-keys/{apiKeyId}", null, HttpMethod.Delete, token);
}
}
### BTCPayServer.Client/Models/ApiKeyData.cs
@@ -1,12 +1,16 @@
+using System;
using BTCPayServer.Client.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models
{
public class ApiKeyData
{
+ public string Id { get; set; }
public string ApiKey { get; set; }
public string Label { get; set; }
+ [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
+ public DateTimeOffset? Created { get; set; }
[JsonProperty(ItemConverterType = typeof(PermissionJsonConverter))]
public Permission[] Permissions { get; set; }
### BTCPayServer.Data/Data/APIKeyData.cs
@@ -1,23 +1,23 @@
using System;
-using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data
{
public class APIKeyData : IHasBlob<APIKeyBlob>
{
- [MaxLength(50)]
+ public const string IdPrefix = "akid";
+ public DateTimeOffset? CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public string Id { get; set; }
+ public string Hash { get; set; }
+
+ public string Key { get; set; }
+ public string Prefix { get; set; }
- [MaxLength(50)]
public string StoreId { get; set; }
- [MaxLength(50)]
public string UserId { get; set; }
- public APIKeyType Type { get; set; } = APIKeyType.Legacy;
-
[Obsolete("Use Blob2 instead")]
public byte[] Blob { get; set; }
public string Blob2 { get; set; }
@@ -28,6 +28,8 @@ public class APIKeyData : IHasBlob<APIKeyBlob>
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
+ builder.Entity<APIKeyData>().Property(x => x.CreatedAt).HasColumnName("CreatedAt").HasColumnType("timestamptz")
+ .HasDefaultValueSql("now()");
builder.Entity<APIKeyData>()
.HasOne(o => o.StoreData)
.WithMany(i => i.APIKeys)
@@ -45,6 +47,9 @@ internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databa
.Property(o => o.Blob2)
.HasColumnType("JSONB");
}
+
+ public static bool IsId(string apiKeyId)
+ => apiKeyId.StartsWith(APIKeyData.IdPrefix + "_", StringComparison.OrdinalIgnoreCase);
}
public class APIKeyBlob
@@ -54,10 +59,4 @@ public class APIKeyBlob
public string ApplicationAuthority { get; set; }
}
-
- public enum APIKeyType
- {
- Legacy,
- Permanent
- }
}
### BTCPayServer.Data/Data/ApiKeyPermissionUsage.cs
@@ -6,8 +6,8 @@ namespace BTCPayServer.Data
public class ApiKeyPermissionUsage
{
[Key]
- public string Id { get; set; } // Id in the format [apiKey]-[permission]
- public string ApiKey { get; set; }
+ public string Id { get; set; } // Id in the format [apiKeyId]-[permission]
+ public string ApiKeyId { get; set; }
public string Permission { get; set; }
public DateTimeOffset LastUsed { get; set; }
public int UsageCount { get; set; }
### BTCPayServer.Data/Migrations/20260904133933_hardenapikey.cs
@@ -0,0 +1,56 @@
+using BTCPayServer.Data;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace BTCPayServer.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260904133933_hardenapikey")]
+ public partial class hardenapikey : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql("""
+ ALTER TABLE "ApiKeys"
+ ADD COLUMN "CreatedAt" timestamptz NULL DEFAULT now(),
+ ADD COLUMN "Hash" text NULL,
+ ADD COLUMN "Key" text NULL,
+ ADD COLUMN "Prefix" text NULL;
+
+ DELETE FROM "ApiKeys"
+ WHERE "Type" = 0;
+
+ DELETE FROM "ApiKeyPermissionUsages" AS usage
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM "ApiKeys" AS api_key
+ WHERE api_key."Id" = usage."ApiKey"
+ );
+
+ ALTER TABLE "ApiKeys"
+ DROP COLUMN "Type",
+ ALTER COLUMN "Id" TYPE text,
+ ALTER COLUMN "StoreId" TYPE text,
+ ALTER COLUMN "UserId" TYPE text;
+
+ ALTER TABLE "ApiKeyPermissionUsages"
+ RENAME COLUMN "ApiKey" TO "ApiKeyId";
+
+ UPDATE "ApiKeyPermissionUsages" AS usage
+ SET "Id" = 'akid_' || left(encode(sha256(sha256(convert_to(api_key."Id", 'UTF8'))), 'hex'), 16) ||
+ substring(usage."Id" FROM char_length(api_key."Id") + 1),
+ "ApiKeyId" = 'akid_' || left(encode(sha256(sha256(convert_to(api_key."Id", 'UTF8'))), 'hex'), 16)
+ FROM "ApiKeys" AS api_key
+ WHERE usage."ApiKeyId" = api_key."Id";
+
+ UPDATE "ApiKeys"
+ SET "Hash" = encode(sha256(convert_to("Id", 'UTF8')), 'hex'),
+ "Prefix" = left("Id", 6),
+ "Id" = 'akid_' || left(encode(sha256(sha256(convert_to("Id", 'UTF8'))), 'hex'), 16);
+ """);
+ }
+ }
+}
### BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -26,28 +26,37 @@ protected override void BuildModel(ModelBuilder modelBuilder)
modelBuilder.Entity("BTCPayServer.Data.APIKeyData", b =>
{
b.Property<string>("Id")
- .HasMaxLength(50)
- .HasColumnType("character varying(50)");
+ .HasColumnType("text");
b.Property<byte[]>("Blob")
.HasColumnType("bytea");
b.Property<string>("Blob2")
.HasColumnType("JSONB");
+ b.Property<DateTimeOffset?>("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamptz")
+ .HasColumnName("CreatedAt")
+ .HasDefaultValueSql("now()");
+
+ b.Property<string>("Hash")
+ .HasColumnType("text");
+
+ b.Property<string>("Key")
+ .HasColumnType("text");
+
b.Property<string>("Label")
.HasColumnType("text");
- b.Property<string>("StoreId")
- .HasMaxLength(50)
- .HasColumnType("character varying(50)");
+ b.Property<string>("Prefix")
+ .HasColumnType("text");
- b.Property<int>("Type")
- .HasColumnType("integer");
+ b.Property<string>("StoreId")
+ .HasColumnType("text");
b.Property<string>("UserId")
- .HasMaxLength(50)
- .HasColumnType("character varying(50)");
+ .HasColumnType("text");
b.HasKey("Id");
@@ -81,7 +90,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<string>("Id")
.HasColumnType("text");
- b.Property<string>("ApiKey")
+ b.Property<string>("ApiKeyId")
.HasColumnType("text");
b.Property<DateTimeOffset>("LastUsed")
### BTCPayServer.Tests/ApiKeysTests.cs
@@ -74,7 +74,7 @@ async Task AssertPermission(string permission)
await s.Page.SetCheckedAsync("#btcpay\\.store\\.canmodifystoresettings", true);
await s.Page.SetCheckedAsync("#btcpay\\.user\\.canviewprofile", true);
await s.ClickPagePrimary();
- var superApiKey = await (await s.FindAlertMessage()).Locator("code").TextContentAsync();
+ var superApiKey = new APIKeyRepository.Selector.ByApiKey(await (await s.FindAlertMessage()).Locator("code").TextContentAsync());
//this api key has access to everything
await TestApiAgainstAccessToken(superApiKey, tester, user, Policies.CanModifyServerSettings, Policies.CanModifyStoreSettings,
@@ -83,14 +83,14 @@ await TestApiAgainstAccessToken(superApiKey, tester, user, Policies.CanModifySer
await s.ClickPagePrimary();
await s.Page.SetCheckedAsync("#btcpay\\.server\\.canmodifyserversettings", true);
await s.ClickPagePrimary();
- var serverOnlyApiKey = await (await s.FindAlertMessage()).Locator("code").TextContentAsync();
+ var serverOnlyApiKey = new APIKeyRepository.Selector.ByApiKey(await (await s.FindAlertMessage()).Locator("code").TextContentAsync());
await TestApiAgainstAccessToken(serverOnlyApiKey, tester, user,
Policies.CanModifyServerSettings);
await s.ClickPagePrimary();
await s.Page.SetCheckedAsync("#btcpay\\.store\\.canmodifystoresettings", true);
await s.ClickPagePrimary();
- var allStoreOnlyApiKey = await (await s.FindAlertMessage()).Locator("code").TextContentAsync();
+ var allStoreOnlyApiKey = new APIKeyRepository.Selector.ByApiKey(await (await s.FindAlertMessage()).Locator("code").TextContentAsync());
await TestApiAgainstAccessToken(allStoreOnlyApiKey, tester, user,
Policies.CanModifyStoreSettings);
@@ -103,13 +103,13 @@ await s.Page.Locator("input[value='btcpay.store.canmodifystoresettings']")
getPermissionValueIndex = getPermissionValueIndex!.Replace(".Permission", ".SpecificStores[0]");
await s.Page.SelectOptionAsync($"[name='{getPermissionValueIndex}']", user.StoreId);
await s.ClickPagePrimary();
- var selectiveStoreApiKey = await (await s.FindAlertMessage()).Locator("code").TextContentAsync();
+ var selectiveStoreApiKey = new APIKeyRepository.Selector.ByApiKey(await (await s.FindAlertMessage()).Locator("code").TextContentAsync());
await TestApiAgainstAccessToken(selectiveStoreApiKey, tester, user,
Permission.Create(Policies.CanModifyStoreSettings, user.StoreId).ToString());
await s.ClickPagePrimary(); // New API key
await s.ClickPagePrimary(); // Generate
- var noPermissionsApiKey = await (await s.FindAlertMessage()).Locator("code").TextContentAsync();
+ var noPermissionsApiKey = new APIKeyRepository.Selector.ByApiKey(await (await s.FindAlertMessage()).Locator("code").TextContentAsync());
await TestApiAgainstAccessToken(noPermissionsApiKey, tester, user);
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
{
@@ -283,8 +283,7 @@ public async Task CanViewApiKeyPermissionAnalysis()
await s.ClickPagePrimary();
var apiKey = await (await s.FindAlertMessage()).Locator("code").TextContentAsync();
- await s.GoToUrl($"api-keys/{apiKey}/view-analysis");
- await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+ await s.Page.Locator(".api-key-list__view-usage").ClickAsync();
var cards = await s.Page.Locator(".display-6.fw-bold").AllTextContentsAsync();
Assert.Equal("2", cards[0].Trim());
@@ -349,19 +348,20 @@ public async Task CannotViewOtherUsersApiKeyAnalysis()
await s.GoToProfile(ManageNavPages.APIKeys);
await s.ClickPagePrimary();
await s.ClickPagePrimary();
- var user1ApiKey = await (await s.FindAlertMessage()).Locator("code").TextContentAsync();
+ var user1ApiKeyId = new APIKeyRepository.Selector.ByApiKey( await (await s.FindAlertMessage()).Locator("code").TextContentAsync()).GetId();
await s.Logout();
await s.GoToLogin();
await s.LogIn(user2.RegisterDetails.Email, user2.RegisterDetails.Password);
- await s.GoToUrl($"api-keys/{user1ApiKey}/view-analysis", true);
+ await s.GoToUrl($"api-keys/{user1ApiKeyId}/view-analysis", true);
await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
Assert.Contains("404", await s.Page.ContentAsync());
}
- async Task TestApiAgainstAccessToken(string accessToken, ServerTester tester, TestAccount testAccount,
+ async Task TestApiAgainstAccessToken(APIKeyRepository.Selector.ByApiKey selector, ServerTester tester, TestAccount testAccount,
params string[] expectedPermissionsArr)
{
+ var accessToken = selector.ApiKey;
var expectedPermissions = Permission.ToPermissions(expectedPermissionsArr).ToArray();
var apikeydata = await TestApiAgainstAccessToken<ApiKeyData>(accessToken, $"api/v1/api-keys/current", tester.PayTester.HttpClient);
var permissions = apikeydata.Permissions;
@@ -532,11 +532,11 @@ private async Task<T> TestApiAgainstAccessToken<T>(string apikey, string url, Ht
return JsonConvert.DeserializeObject<T>(rawJson);
}
- private async Task<string> GetAccessTokenFromCallbackResult(PlaywrightTester tester)
+ private async Task<APIKeyRepository.Selector.ByApiKey> GetAccessTokenFromCallbackResult(PlaywrightTester tester)
{
var source = await tester.Page.Locator("body").TextContentAsync();
var json = JObject.Parse(source ?? "{}");
- return json.GetValue("apiKey")!.Value<string>();
+ return new(json.GetValue("apiKey")!.Value<string>());
}
}
### BTCPayServer.Tests/BitpayTests.cs
@@ -26,47 +26,6 @@ namespace BTCPayServer.Tests;
[Collection(nameof(NonParallelizableCollectionDefinition))]
public class BitpayTests(ITestOutputHelper log) : UnitTestBase(log)
{
- [Fact]
- [Trait("Integration", "Integration")]
- public async Task CanThrowBitpay404Error()
- {
- using var tester = CreateServerTester();
- await tester.StartAsync();
- var user = tester.NewAccount();
- await user.GrantAccessAsync();
- user.RegisterDerivationScheme("BTC");
-
- var invoice = await user.BitPay.CreateInvoiceAsync(
- new Invoice()
- {
- Buyer = new Buyer() { email = "test@fwf.com" },
- Price = 5000.0m,
- Currency = "USD",
- PosData = "posData",
- OrderId = "orderId",
- ItemDesc = "Some description",
- FullNotifications = true
- }, Facade.Merchant);
-
- try
- {
- await user.BitPay.GetInvoiceAsync(invoice.Id + "123");
- }
- catch (BitPayException ex)
- {
- Assert.Equal("Object not found", ex.Errors.First());
- }
-
- var req = new HttpRequestMessage(HttpMethod.Get, "/invoices/Cy9jfK82eeEED1T3qhwF3Y");
- req.Headers.TryAddWithoutValidation("Authorization", "Basic dGVzdA==");
- req.Content = new StringContent("{}", Encoding.UTF8, "application/json");
- var result = await tester.PayTester.HttpClient.SendAsync(req);
- Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode);
- var err = await result.Content.ReadAsStringAsync();
- var errModel = JsonConvert.DeserializeObject<BitpayErrorsModel>(err);
- Assert.Equal("ApiKey authentication failed", errModel.Errors[0].Error);
- }
-
[Fact]
[Trait("Integration", "Integration")]
public async Task CanUseServerInitiatedPairingCode()
@@ -259,36 +218,6 @@ await storeController
var resp = await client.SendAsync(req);
resp.EnsureSuccessStatusCode();
- // Can generate API Key
- var repo = tester.PayTester.GetService<TokenRepository>();
- Assert.Empty(await repo.GetLegacyAPIKeys(user.StoreId));
- Assert.IsType<RedirectToActionResult>(await user.GetController<UIStoresTokenController>()
- .GenerateAPIKey(user.StoreId));
-
- var apiKey = Assert.Single(await repo.GetLegacyAPIKeys(user.StoreId));
- ///////
-
- // Generating a new one remove the previous
- Assert.IsType<RedirectToActionResult>(await user.GetController<UIStoresTokenController>()
- .GenerateAPIKey(user.StoreId));
- var apiKey2 = Assert.Single(await repo.GetLegacyAPIKeys(user.StoreId));
- Assert.NotEqual(apiKey, apiKey2);
- ////////
-
- apiKey = apiKey2;
-
- // Can create an invoice with this new API Key
- var message = new HttpRequestMessage(HttpMethod.Post,
- tester.PayTester.ServerUri.AbsoluteUri + "invoices");
- message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic",
- Encoders.Base64.EncodeData(Encoders.ASCII.DecodeData(apiKey)));
- var invoice = new Invoice() { Price = 5000.0m, Currency = "USD" };
- message.Content = new StringContent(JsonConvert.SerializeObject(invoice), Encoding.UTF8,
- "application/json");
- var result = await client.SendAsync(message);
- result.EnsureSuccessStatusCode();
- /////////////////////
-
// Have error 403 with a bad signature
client = new HttpClient();
var mess =
@@ -299,7 +228,7 @@ await storeController
mess.Headers.Add("x-identity",
"04b4d82095947262dd70f94c0a0e005ec3916e3f5f2181c176b8b22a52db22a8c436c4703f43a9e8884104854a11e1eb30df8fdf116e283807a1f1b8fe4c182b99");
mess.Method = HttpMethod.Get;
- result = await client.SendAsync(mess);
+ var result = await client.SendAsync(mess);
Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode);
//
### BTCPayServer.Tests/DatabaseTests.cs
@@ -5,6 +5,7 @@
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments;
+using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services;
using Dapper;
using Microsoft.EntityFrameworkCore;
@@ -303,6 +304,58 @@ public Task ProcessEventForTest(object evt)
}
}
+ [Fact]
+ public async Task CanMigrateApiKeys()
+ {
+ const string apiKey = "2683feeddb2277ce5f18e9ea2eb665aaec2bf700";
+ const string permission = "btcpay.store.canviewstoresettings";
+ const string permissionSuffix = "-btcpay.store.canviewstoresettings:Da7u4vBajRTAYg";
+ const string expectedHash = "ac79f0e4b7e453b6417eed4a13af18338128272fe35f758f0fa82e51ab204f86";
+ const string expectedApiKeyId = "akid_6f83c19078b05351";
+
+ var tester = CreateDBTester();
+ await tester.MigrateUntil("20260904133933_hardenapikey");
+ await using (var ctx = tester.CreateContext())
+ {
+ await ctx.Database.GetDbConnection().ExecuteAsync("""
+ INSERT INTO "ApiKeys" ("Id", "Type", "Label", "Blob", "Blob2")
+ VALUES (@apiKey, 1, 'New', '\x'::bytea, CAST(@blob AS JSONB));
+
+ INSERT INTO "ApiKeyPermissionUsages" ("Id", "ApiKey", "Permission", "LastUsed", "UsageCount")
+ VALUES (@usageId, @apiKey, @permission, '2026-05-01 16:47:11.537205+00', 1);
+
+ INSERT INTO "ApiKeyPermissionUsages" ("Id", "ApiKey", "Permission", "LastUsed", "UsageCount")
+ VALUES ('orphan-permission', 'orphan-api-key', @permission, '2026-05-01 16:47:11.537205+00', 1);
+ """, new
+ {
+ apiKey,
+ usageId = apiKey + permissionSuffix,
+ permission,
+ blob = "{\"permissions\":[\"btcpay.store.canviewstoresettings:Da7u4vBajRTAYg\"]}"
+ });
+ }
+
+ await tester.CompleteMigrations();
+
+ var selector = new APIKeyRepository.Selector.ByApiKey(apiKey);
+ Assert.Equal(expectedHash, selector.GetHash());
+ Assert.Equal(expectedApiKeyId, selector.GetId());
+
+ await using (var ctx = tester.CreateContext())
+ {
+ var migratedApiKey = await ctx.ApiKeys.SingleAsync();
+ Assert.Equal(expectedApiKeyId, migratedApiKey.Id);
+ Assert.Equal(expectedHash, migratedApiKey.Hash);
+ Assert.Equal("2683fe", migratedApiKey.Prefix);
+ Assert.Null(migratedApiKey.Key);
+ Assert.NotNull(migratedApiKey.CreatedAt);
+
+ var usage = await ctx.ApiKeyPermissionUsages.SingleAsync();
+ Assert.Equal(expectedApiKeyId, usage.ApiKeyId);
+ Assert.Equal(expectedApiKeyId + permissionSuffix, usage.Id);
+ }
+ }
+
[Fact]
public async Task CanMigratePendingTransactionIds()
{
### BTCPayServer.Tests/GreenfieldAPITests.cs
@@ -190,8 +190,8 @@ await AssertHttpError(403,
Permissions = new Permission[] { Permission.Create(Policies.CanViewProfile) }
}));
- await unrestricted.RevokeAPIKey(apiKey.ApiKey);
- await AssertAPIError("apikey-not-found", () => unrestricted.RevokeAPIKey(apiKey.ApiKey));
+ await unrestricted.RevokeAPIKey(apiKey.Id);
+ await AssertAPIError("apikey-not-found", () => unrestricted.RevokeAPIKey(apiKey.Id));
// Admin create API key to new user
@@ -215,7 +215,7 @@ await AssertHttpError(403,
Assert.Equal("New User", newUser.Name);
Assert.Equal("avatar.jpg", newUser.ImageUrl);
// Admin delete it
- await unrestricted.RevokeAPIKey(newUser.Id, newUserAPIKey.ApiKey);
+ await unrestricted.RevokeAPIKey(newUser.Id, newUserAPIKey.Id);
await Assert.ThrowsAsync<GreenfieldAPIException>(() => newUserClient.GetCurrentUser());
// Admin create store
### BTCPayServer.Tests/ThirdPartyTests.cs
@@ -295,7 +295,8 @@ private async Task CheckDeadLinks(Regex regex, HttpClient httpClient, string fil
"https://www.coingecko.com", // unhappy service
"https://www.wasabiwallet.io", // Banning US, CI unhappy
"https://fullynoded.app", // Sometimes DNS doesn't work
- "https://hrf.org" // Started returning Forbidden
+ "https://hrf.org", // Started returning Forbidden
+ "https://x.com" // Fail on CI
};
foreach (var match in regex.Matches(text).OfType<Match>())
### BTCPayServer.Tests/docker-entrypoint.sh
@@ -6,4 +6,4 @@ if [ -n "${TEST_FILTERS:-}" ]; then
set -- --filter "$TEST_FILTERS"
fi
-dotnet test -c "${CONFIGURATION_NAME}" "$@" --no-build -v n --output Normal --report-gh
+dotnet test -c "${CONFIGURATION_NAME}" "$@" --no-build -v n --output Normal --report-gh --xunit-info --xunit-diagnostics on --long-running 180
### BTCPayServer/Controllers/GreenField/GreenfieldApiKeysController.cs
@@ -28,7 +28,8 @@ public async Task<IActionResult> GetKey()
return
this.CreateAPIError(404, "api-key-not-found", "The api key was not present.");
}
- var data = await apiKeyRepository.GetKey(apiKey);
+ var data = await apiKeyRepository.GetKey(new APIKeyRepository.Selector.ByApiKey(apiKey));
+ data.Key = apiKey;
return Ok(FromModel(data));
}
@@ -48,13 +49,9 @@ public async Task<IActionResult> CreateUserAPIKey(string idOrEmail, CreateApiKey
if (userId is null)
return this.UserNotFound();
- var key = new APIKeyData()
- {
- Id = Encoders.Hex.EncodeData(RandomUtils.GetBytes(20)),
- Type = APIKeyType.Permanent,
- UserId = userId,
- Label = request.Label
- };
+ var key = APIKeyRepository.New();
+ key.UserId = userId;
+ key.Label = request.Label;
key.SetBlob(new APIKeyBlob()
{
Permissions = request.Permissions.Select(p => p.ToString()).Distinct().ToArray()
@@ -72,36 +69,36 @@ public Task<IActionResult> RevokeCurrentKey()
// Should be impossible (we force apikey auth)
return Task.FromResult<IActionResult>(BadRequest());
}
- return RevokeAPIKey(apiKey);
+ return RevokeAPIKey(new APIKeyRepository.Selector.ByApiKey(apiKey).GetId());
}
- [HttpDelete("~/api/v1/api-keys/{apikey}", Order = 1)]
+ [HttpDelete("~/api/v1/api-keys/{apiKeyId}", Order = 1)]
[Authorize(Policy = Policies.Unrestricted, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
- public Task<IActionResult> RevokeAPIKey(string apikey)
- => RevokeAPIKey(User.GetId(), apikey);
+ public Task<IActionResult> RevokeAPIKey(string apiKeyId)
+ => RevokeAPIKey(User.GetId(), apiKeyId);
- [HttpDelete("~/api/v1/users/{idOrEmail}/api-keys/{apikey}", Order = 1)]
+ [HttpDelete("~/api/v1/users/{idOrEmail}/api-keys/{apiKeyId}", Order = 1)]
[Authorize(Policy = Policies.CanManageUsers, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
- public async Task<IActionResult> RevokeAPIKey(string idOrEmail, string apikey)
+ public async Task<IActionResult> RevokeAPIKey(string idOrEmail, string apiKeyId)
{
var userId = (await userManager.FindByIdOrEmail(idOrEmail))?.Id;
if (userId is null)
return this.UserNotFound();
- if (!string.IsNullOrEmpty(apikey) &&
- await apiKeyRepository.Remove(apikey, userId))
+ if (!string.IsNullOrEmpty(apiKeyId) &&
+ await apiKeyRepository.Remove(new APIKeyRepository.Selector.ById(apiKeyId), userId))
return Ok();
else
return this.CreateAPIError("apikey-not-found", "This apikey does not exists");
}
private static ApiKeyData FromModel(APIKeyData data)
+ => new ApiKeyData()
{
- return new ApiKeyData()
- {
- Permissions = Permission.ToPermissions(data.GetBlob().Permissions).ToArray(),
- ApiKey = data.Id,
- Label = data.Label ?? string.Empty
- };
- }
+ Id = data.Id,
+ Permissions = Permission.ToPermissions(data.GetBlob().Permissions).ToArray(),
+ ApiKey = data.Key,
+ Label = data.Label ?? string.Empty,
+ Created = data.CreatedAt
+ };
}
}
### BTCPayServer/Controllers/UIManageController.APIKeys.cs
@@ -12,8 +12,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
-using NBitcoin;
-using NBitcoin.DataEncoders;
+using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers
{
@@ -31,11 +30,12 @@ public async Task<IActionResult> APIKeys()
});
}
- [HttpGet("~/api-keys/{id}/view-analysis")]
- public async Task<IActionResult> APIKeyPermissionAnalysis(string id)
+ [HttpGet("~/api-keys/{apiKeyId}/view-analysis")]
+ public async Task<IActionResult> APIKeyPermissionAnalysis(string apiKeyId)
{
+ var id = new APIKeyRepository.Selector.ById(apiKeyId);
var key = await _apiKeyRepository.GetKey(id);
- if (key == null || key.UserId != _userManager.GetUserId(User))
+ if (key == null || key.UserId != User.GetId())
return NotFound();
var allPermissions = key.GetBlob().Permissions;
@@ -67,34 +67,35 @@ public async Task<IActionResult> APIKeyPermissionAnalysis(string id)
}
return View(new ApiKeyPermissionAnalyticsViewModel
{
- ApiKey = key.Id,
+ ApiKey = key.Key,
Label = key.Label,
UsedPermissions = usedPermissions,
UnusedPermissions = unusedPermissions,
AllPermissions = allPermissionVMs
});
}
- [HttpGet("~/api-keys/{id}/delete")]
- public async Task<IActionResult> DeleteAPIKey(string id)
+ [HttpGet("~/api-keys/{apiKeyId}/delete")]
+ public async Task<IActionResult> DeleteAPIKey(string apiKeyId)
{
- var key = await _apiKeyRepository.GetKey(id);
+ var key = await _apiKeyRepository.GetKey(new APIKeyRepository.Selector.ById(apiKeyId));
if (key == null || key.UserId != User.GetId())
{
return NotFound();
}
return View("Confirm", new ConfirmModel
{
Title = "Delete API key",
- Description = $"Any application using the API key <strong>{Html.Encode(key.Label ?? key.Id)}<strong> will immediately lose access.",
+ Description = $"Any application using the API key <strong>{Html.Encode(key.Label ?? (key.Prefix + "..."))}<strong> will immediately lose access.",
Action = "Delete",
ActionName = nameof(DeleteAPIKeyPost)
});
}
- [HttpPost("~/api-keys/{id}/delete")]
- public async Task<IActionResult> DeleteAPIKeyPost(string id)
+ [HttpPost("~/api-keys/{apiKeyId}/delete")]
+ public async Task<IActionResult> DeleteAPIKeyPost(string apiKeyId)
{
+ var id = new APIKeyRepository.Selector.ById(apiKeyId);
var key = await _apiKeyRepository.GetKey(id);
if (key == null || key.UserId != User.GetId())
{
@@ -164,7 +165,7 @@ public async Task<IActionResult> AuthorizeAPIKey(string[] permissions, string ap
var existingApiKey = await CheckForMatchingApiKey(requestPermissions, vm);
if (existingApiKey != null)
{
- vm.ApiKey = existingApiKey.Id;
+ vm.ApiKey = existingApiKey.Key;
return View("ConfirmAPIKey", vm);
}
@@ -219,7 +220,8 @@ public async Task<IActionResult> AuthorizeAPIKey([FromForm] AuthorizeApiKeysView
case "confirm":
var key = command == "authorize"
? await CreateKey(viewModel, (viewModel.ApplicationIdentifier, viewModel.RedirectUrl?.AbsoluteUri))
- : await _apiKeyRepository.GetKey(viewModel.ApiKey);
+ : await _apiKeyRepository.GetKey(new APIKeyRepository.Selector.ByApiKey(viewModel.ApiKey));
+ key.Key ??= viewModel.ApiKey;
if (viewModel.RedirectUrl != null)
{
@@ -230,7 +232,7 @@ public async Task<IActionResult> AuthorizeAPIKey([FromForm] AuthorizeApiKeysView
FormUrl = viewModel.RedirectUrl.AbsoluteUri,
FormParameters =
{
- { "apiKey", key.Id },
+ { "apiKey", key.Key },
{ "userId", key.UserId },
},
};
@@ -244,7 +246,7 @@ public async Task<IActionResult> AuthorizeAPIKey([FromForm] AuthorizeApiKeysView
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
- Html = StringLocalizer["API key generated!"].Value + $" <code class='alert-link'>{key.Id}</code>"
+ Html = StringLocalizer["API key generated!"].Value + $" <code class='alert-link'>{key.Key}</code>"
});
return RedirectToAction("APIKeys");
@@ -259,7 +261,7 @@ public async Task<IActionResult> AuthorizeAPIKey([FromForm] AuthorizeApiKeysView
var existingApiKey = await CheckForMatchingApiKey(requestPermissions, viewModel);
if (existingApiKey != null)
{
- viewModel.ApiKey = existingApiKey.Id;
+ viewModel.ApiKey = existingApiKey.Key;
return View("ConfirmAPIKey", viewModel);
}
}
@@ -289,7 +291,7 @@ public async Task<IActionResult> AddApiKey(AddApiKeyViewModel viewModel)
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
- Html = StringLocalizer["API key generated!"].Value + $" <code class='alert-link'>{key.Id}</code>"
+ Html = StringLocalizer["API key generated!"].Value + $" <code class='alert-link'>{key.Key}</code>"
});
return RedirectToAction("APIKeys");
}
@@ -308,6 +310,8 @@ private async Task<APIKeyData> CheckForMatchingApiKey(IEnumerable<Permission> re
});
foreach (var key in keys)
{
+ if (key.Key is null)
+ continue;
var blob = key.GetBlob();
if (blob.ApplicationIdentifier != vm.ApplicationIdentifier || blob.ApplicationAuthority != vm.RedirectUrl.AbsoluteUri)
{
@@ -475,13 +479,9 @@ private IActionResult HandleCommands(AddApiKeyViewModel viewModel)
private async Task<APIKeyData> CreateKey(AddApiKeyViewModel viewModel, (string appIdentifier, string appAuthority) app = default)
{
- var key = new APIKeyData
- {
- Id = Encoders.Hex.EncodeData(RandomUtils.GetBytes(20)),
- Type = APIKeyType.Permanent,
- UserId = User.GetId(),
- Label = viewModel.Label,
- };
+ var key = APIKeyRepository.New();
+ key.UserId = User.GetId();
+ key.Label = viewModel.Label;
key.SetBlob(new APIKeyBlob
{
Permissions = GetPermissionsFromViewModel(viewModel).Select(p => p.ToString()).Distinct().ToArray(),
### BTCPayServer/Hosting/BTCPayServerServices.cs
@@ -411,6 +411,24 @@ public static IServiceCollection AddBTCPayServer(this IServiceCollection service
services.AddSingleton<IHostedService, NBXplorerWaiters>();
services.AddSingleton<IHostedService, InvoiceEventSaverService>();
services.AddSingleton<IHostedService, InvoiceWatcher>();
+ services.AddScheduledDbScript("API Key Cleanup",
+ """
+ WITH expired_api_keys AS (
+ SELECT "Id"
+ FROM "ApiKeys"
+ WHERE "Key" IS NOT NULL
+ AND ("CreatedAt" IS NULL OR "CreatedAt" < @now - INTERVAL '5 minutes')
+ ORDER BY "CreatedAt" NULLS FIRST
+ LIMIT 1000
+ ),
+ cleaned_api_keys AS (
+ UPDATE "ApiKeys"
+ SET "Key" = NULL
+ WHERE "Id" IN (SELECT "Id" FROM expired_api_keys)
+ RETURNING 1
+ )
+ SELECT COUNT(*) FROM cleaned_api_keys;
+ """);
services.AddScheduledDbScript("Invoice Cleanup",
"""
WITH useless_invoices AS (
### BTCPayServer/Plugins/Bitpay/Controllers/UIStoresTokenController.cs
@@ -56,18 +56,6 @@ public async Task<IActionResult> ListTokens()
SIN = t.SIN,
Id = t.Value
}).ToArray();
-
- var userId = GetUserId();
- var canModify = userId != null && (await authorizationService.AuthorizeAsync(User, CurrentStore.Id, Policies.CanModifyStoreSettings)).Succeeded;
- if (canModify)
- {
- model.ApiKey = (await tokenRepository.GetLegacyAPIKeys(CurrentStore.Id)).FirstOrDefault();
- model.EncodedApiKey = model.ApiKey == null ? "*API Key*" : Encoders.Base64.EncodeData(Encoders.ASCII.DecodeData(model.ApiKey));
- }
- else
- {
- model.EncodedApiKey = "*API Key*";
- }
return View(model);
}
@@ -198,30 +186,6 @@ public Task<IActionResult> CreateToken2(CreateTokenViewModel model)
return CreateToken(model.StoreId, model);
}
- [HttpPost("{storeId}/tokens/apikey")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> GenerateAPIKey(string storeId, string command = "")
- {
- var store = HttpContext.GetStoreDataOrNull();
- if (store == null)
- return NotFound();
- if (command == "revoke")
- {
- await tokenRepository.RevokeLegacyAPIKeys(CurrentStore.Id);
- TempData[WellKnownTempData.SuccessMessage] = "API Key revoked";
- }
- else
- {
- await tokenRepository.GenerateLegacyAPIKey(CurrentStore.Id);
- TempData[WellKnownTempData.SuccessMessage] = "API Key re-generated";
- }
-
- return RedirectToAction(nameof(ListTokens), new
- {
- storeId
- });
- }
-
[HttpGet("/api-access-request")]
[AllowAnonymous]
public async Task<IActionResult> RequestPairing(string pairingCode, string? selectedStore = null)
### BTCPayServer/Plugins/Bitpay/Security/BitpayAuthenticationHandler.cs
@@ -12,15 +12,13 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NBitcoin;
-using NBitcoin.DataEncoders;
using NBitpayClient.Extensions;
using Newtonsoft.Json;
namespace BTCPayServer.Plugins.Bitpay.Security
{
public class BitpayAuthenticationHandler(
- TokenRepository tokenRepository,
IOptionsMonitor<BitpayAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
@@ -49,13 +47,6 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
return Fail("BitId authentication failed");
return Success(BitpayClaims.SIN, sin, BitpayAuthenticationTypes.SinAuthentication);
}
- else if (!string.IsNullOrEmpty(bitpayAuth.Authorization))
- {
- var storeId = await GetStoreIdFromAuth(bitpayAuth.Authorization);
- if (storeId == null)
- return Fail("ApiKey authentication failed");
- return Success(BitpayClaims.ApiKeyStoreId, storeId, BitpayAuthenticationTypes.ApiKeyAuthentication);
- }
else
{
return Success(null, null, BitpayAuthenticationTypes.Anonymous);
@@ -101,25 +92,5 @@ private async Task<string> CheckBitId(HttpContext httpContext, string sig, strin
catch { }
return null;
}
-
- private async Task<string> GetStoreIdFromAuth(string auth)
- {
- var splitted = auth.Split(' ', StringSplitOptions.RemoveEmptyEntries);
- if (splitted.Length != 2 || !splitted[0].Equals("Basic", StringComparison.OrdinalIgnoreCase))
- {
- return null;
- }
-
- string apiKey = null;
- try
- {
- apiKey = Encoders.ASCII.EncodeData(Encoders.Base64.DecodeData(splitted[1]));
- }
- catch
- {
- return null;
- }
- return await tokenRepository.GetStoreIdFromAPIKey(apiKey);
- }
}
}
### BTCPayServer/Plugins/Bitpay/Security/BitpayAuthenticationTypes.cs
@@ -2,7 +2,6 @@ namespace BTCPayServer.Plugins.Bitpay.Security
{
public class BitpayAuthenticationTypes
{
- public const string ApiKeyAuthentication = "Bitpay.APIKey";
public const string SinAuthentication = "Bitpay.SIN";
public const string Anonymous = "Bitpay.Anonymous";
}
### BTCPayServer/Plugins/Bitpay/Security/BitpayAuthorizationHandler.cs
@@ -22,11 +22,7 @@ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext
return;
var httpContext = httpContextAccessor.HttpContext;
string storeId = null;
- if (context.User.Identity is { AuthenticationType: BitpayAuthenticationTypes.ApiKeyAuthentication })
- {
- storeId = context.User.Claims.Where(c => c.Type == BitpayClaims.ApiKeyStoreId).Select(c => c.Value).First();
- }
- else if (context.User.Identity is { AuthenticationType: BitpayAuthenticationTypes.SinAuthentication })
+ if (context.User.Identity is { AuthenticationType: BitpayAuthenticationTypes.SinAuthentication })
{
var sin = context.User.Claims.Where(c => c.Type == BitpayClaims.SIN).Select(c => c.Value).First();
var bitToken = (await tokenRepository.GetTokens(sin)).FirstOrDefault();
### BTCPayServer/Plugins/Bitpay/Security/BitpayClaims.cs
@@ -3,6 +3,5 @@ namespace BTCPayServer.Plugins.Bitpay.Security
public class BitpayClaims
{
public const string SIN = "Bitpay.SIN";
- public const string ApiKeyStoreId = "Bitpay.ApiKeyStoreId";
}
}
### BTCPayServer/Plugins/Bitpay/Security/TokenRepository.cs
@@ -36,52 +36,6 @@ public async Task<BitTokenEntity[]> GetTokens(string sin)
.ToArray();
}
- public async Task<String> GetStoreIdFromAPIKey(string apiKey)
- {
- using var ctx = _Factory.CreateContext();
- return await ctx.ApiKeys.Where(o => o.Id == apiKey).Select(o => o.StoreId).FirstOrDefaultAsync();
- }
-
- public async Task GenerateLegacyAPIKey(string storeId)
- {
- // It is legacy support and Bitpay generate string of unknown format, trying to replicate them
- // as good as possible. The string below got generated for me.
- var chars = "ERo0vkBMOYhyU0ZHvirCplbLDIGWPdi1ok77VnW7QdE";
- var generated = new char[chars.Length];
- for (int i = 0; i < generated.Length; i++)
- {
- generated[i] = chars[(int)(RandomUtils.GetUInt32() % generated.Length)];
- }
-
- using var ctx = _Factory.CreateContext();
- var existing = await ctx.ApiKeys.Where(o => o.StoreId == storeId && o.Type == APIKeyType.Legacy).ToListAsync();
- if (existing.Any())
- {
- ctx.ApiKeys.RemoveRange(existing);
- }
- ctx.ApiKeys.Add(new APIKeyData() { Id = new string(generated), StoreId = storeId });
- await ctx.SaveChangesAsync().ConfigureAwait(false);
- }
-
- public async Task RevokeLegacyAPIKeys(string storeId)
- {
- var keys = await GetLegacyAPIKeys(storeId);
- if (!keys.Any())
- {
- return;
- }
-
- using var ctx = _Factory.CreateContext();
- ctx.ApiKeys.RemoveRange(keys.Select(s => new APIKeyData() { Id = s }));
- await ctx.SaveChangesAsync();
- }
-
- public async Task<string[]> GetLegacyAPIKeys(string storeId)
- {
- using var ctx = _Factory.CreateContext();
- return await ctx.ApiKeys.Where(o => o.StoreId == storeId && o.Type == APIKeyType.Legacy).Select(c => c.Id).ToArrayAsync();
- }
-
private BitTokenEntity CreateTokenEntity(PairedSINData data)
{
return new BitTokenEntity()
### BTCPayServer/Plugins/Bitpay/Views/ListTokens.cshtml
@@ -79,30 +79,6 @@
</p>
}
</div>
-
- <h3 class="settings-section__heading settings-section__heading--spaced" text-translate="true">Legacy API Keys</h3>
- <div class="settings-section">
- <p text-translate="true">Alternatively, you can use the invoice API by including the following HTTP Header in your requests:</p>
- <p permission="@Policies.CanModifyStoreSettings"><code>Authorization: Basic @Model.EncodedApiKey</code></p>
-
- <form method="post" asp-action="GenerateAPIKey" asp-route-storeId="@Context.GetRouteValue("storeId")" permission="@Policies.CanModifyStoreSettings">
- <div class="form-group mb-0">
- <label asp-for="ApiKey" class="form-label"></label>
- <div class="d-flex">
- <input asp-for="ApiKey" readonly class="form-control"/>
- @if (string.IsNullOrEmpty(Model.ApiKey))
- {
- <button class="btn btn-primary ms-3" type="submit" text-translate="true">Generate</button>
- }
- else
- {
- <button class="btn btn-danger ms-3" type="submit" name="command" value="revoke" text-translate="true">Revoke</button>
- <button class="btn btn-primary ms-3" type="submit" text-translate="true">Regenerate</button>
- }
- </div>
- </div>
- </form>
- </div>
</div>
</div>
### BTCPayServer/Plugins/Bitpay/Views/TokensViewModel.cs
@@ -52,10 +52,6 @@ public TokenViewModel[] Tokens
{
get; set;
}
-
- [Display(Name = "API Key")]
- public string ApiKey { get; set; }
- public string EncodedApiKey { get; set; }
public bool StoreNotConfigured { get; set; }
}
}
### BTCPayServer/Security/BuiltInPermissionHandler.cs
@@ -77,7 +77,7 @@ public async Task HandleAsync(AuthorizationHandlerContext authContext, Permissio
{
if (permContext.HttpContext.GetAPIKey(out var apiKey))
{
- _ = apiKeyRepository.RecordPermissionUsage(apiKey, permContext.Permission);
+ _ = apiKeyRepository.RecordPermissionUsage(new APIKeyRepository.Selector.ByApiKey(apiKey), permContext.Permission);
}
authContext.Succeed(permContext.Requirement);
if (permissionedStore is not null)
### BTCPayServer/Security/GreenField/APIKeyRepository.cs
@@ -1,37 +1,86 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Internal;
+using NBitcoin;
+using NBitcoin.DataEncoders;
using Npgsql;
namespace BTCPayServer.Security.Greenfield
{
- public class APIKeyRepository
+ public class APIKeyRepository(ApplicationDbContextFactory applicationDbContextFactory)
{
- private readonly ApplicationDbContextFactory _applicationDbContextFactory;
-
- public APIKeyRepository(ApplicationDbContextFactory applicationDbContextFactory)
+ public static APIKeyData New()
{
- _applicationDbContextFactory = applicationDbContextFactory;
+ APIKeyData k = new()
+ {
+ Key = Encoders.Hex.EncodeData(RandomUtils.GetBytes(20))
+ };
+ var apiKey = new Selector.ByApiKey(k.Key);
+ k.Hash = apiKey.GetHash();
+ k.Id = apiKey.GetId();
+ k.Prefix = k.Key.Substring(0, 6);
+ return k;
}
- public async Task<APIKeyData> GetKey(string apiKey, bool includeUser = false)
+ public abstract record Selector
{
- await using var context = _applicationDbContextFactory.CreateContext();
- if (includeUser)
+ public abstract string GetId();
+
+ public record ById(string ApiKeyId) : Selector
+ {
+ public override string GetId() => APIKeyData.IsId(ApiKeyId) ? ApiKeyId : "???";
+ }
+
+ public record ByApiKey(string ApiKey) : Selector
{
- return await context.ApiKeys.Include(data => data.User).SingleOrDefaultAsync(data => data.Id == apiKey && data.Type != APIKeyType.Legacy);
+ public string GetHash()
+ {
+ try
+ {
+ return Encoders.Hex.EncodeData(SHA256.HashData(Encoding.UTF8.GetBytes(ApiKey)));
+ }
+ catch
+ {
+ return "???";
+ }
+ }
+ public override string GetId()
+ {
+ try
+ {
+ var hash = SHA256.HashData(SHA256.HashData(Encoding.UTF8.GetBytes(ApiKey)));
+ return APIKeyData.IdPrefix + "_" + Encoders.Hex.EncodeData(hash)[..16];
+ }
+ catch
+ {
+ return "???";
+ }
+ }
}
- return await context.ApiKeys.SingleOrDefaultAsync(data => data.Id == apiKey && data.Type != APIKeyType.Legacy);
+
+ }
+
+ public async Task<APIKeyData> GetKey(Selector selector, bool includeUser = false)
+ {
+ var id = selector.GetId();
+ await using var context = applicationDbContextFactory.CreateContext();
+ var result = includeUser ?
+ await context.ApiKeys.Include(data => data.User).SingleOrDefaultAsync(data => data.Id == id) :
+ await context.ApiKeys.SingleOrDefaultAsync(data => data.Id == id);
+ if (result != null && selector is Selector.ByApiKey apiKey && apiKey.GetHash() != result.Hash)
+ result = null;
+ return result;
}
public async Task<List<APIKeyData>> GetKeys(APIKeyQuery query)
{
- using var context = _applicationDbContextFactory.CreateContext();
+ using var context = applicationDbContextFactory.CreateContext();
var queryable = context.ApiKeys.AsQueryable();
if (query != null)
{
@@ -46,73 +95,51 @@ public async Task<List<APIKeyData>> GetKeys(APIKeyQuery query)
public async Task CreateKey(APIKeyData key)
{
- if (key.Type == APIKeyType.Legacy || !string.IsNullOrEmpty(key.StoreId) || string.IsNullOrEmpty(key.UserId))
- {
- throw new InvalidOperationException("cannot save a bitpay legacy api key with this repository");
- }
- using var context = _applicationDbContextFactory.CreateContext();
+ using var context = applicationDbContextFactory.CreateContext();
await context.ApiKeys.AddAsync(key);
await context.SaveChangesAsync();
}
- public async Task UpdateKey(string id, Permission[] permissions, string label, string userId)
- {
- using var context = _applicationDbContextFactory.CreateContext();
- var key = await EntityFrameworkQueryableExtensions.SingleOrDefaultAsync(context.ApiKeys,
- data => data.Id == id && data.UserId == userId);
- if (key != null)
- {
- var keyBlob = key.GetBlob();
- key.Label = label;
- key.SetBlob(new APIKeyBlob
- {
- Permissions = permissions.Select(p => p.ToString()).ToArray(),
- ApplicationAuthority = keyBlob.ApplicationAuthority,
- ApplicationIdentifier = keyBlob.ApplicationIdentifier
- });
- context.ApiKeys.Update(key);
- await context.SaveChangesAsync();
- }
- }
-
- public async Task<bool> Remove(string id, string getUserId)
+ public async Task<bool> Remove(Selector selector, string getUserId)
{
- using (var context = _applicationDbContextFactory.CreateContext())
+ var id = selector.GetId();
+ using (var context = applicationDbContextFactory.CreateContext())
{
var key = await EntityFrameworkQueryableExtensions.SingleOrDefaultAsync(context.ApiKeys,
data => data.Id == id && data.UserId == getUserId);
if (key == null)
return false;
-
- await context.ApiKeyPermissionUsages.Where(u => u.Id.StartsWith(id)).ExecuteDeleteAsync();
+ await context.ApiKeyPermissionUsages.Where(u => u.ApiKeyId == id).ExecuteDeleteAsync();
context.ApiKeys.Remove(key);
await context.SaveChangesAsync();
}
return true;
}
- public async Task RecordPermissionUsage(string apiKey, Permission permission)
+ public async Task RecordPermissionUsage(Selector selector, Permission permission)
{
- using var context = _applicationDbContextFactory.CreateContext();
+ var id = selector.GetId();
+ using var context = applicationDbContextFactory.CreateContext();
var sql = @"
- INSERT INTO ""ApiKeyPermissionUsages"" (""Id"", ""ApiKey"", ""Permission"", ""LastUsed"", ""UsageCount"")
- VALUES (@Id, @ApiKey, @Permission, @LastUsed, 1)
+ INSERT INTO ""ApiKeyPermissionUsages"" (""Id"", ""ApiKeyId"", ""Permission"", ""LastUsed"", ""UsageCount"")
+ VALUES (@Id, @ApiKeyId, @Permission, @LastUsed, 1)
ON CONFLICT (""Id"")
DO UPDATE SET
""LastUsed"" = @LastUsed,
""UsageCount"" = ""ApiKeyPermissionUsages"".""UsageCount"" + 1";
await context.Database.ExecuteSqlRawAsync(sql,
- new NpgsqlParameter("@Id", $"{apiKey}-{permission}"),
- new NpgsqlParameter("@ApiKey", apiKey),
+ new NpgsqlParameter("@Id", $"{id}-{permission}"),
+ new NpgsqlParameter("@ApiKeyId", id),
new NpgsqlParameter("@Permission", permission.Policy),
new NpgsqlParameter("@LastUsed", DateTimeOffset.UtcNow));
}
- public async Task<List<ApiKeyPermissionUsage>> GetAPIPermissionUsageRecords(string apiKey)
+ public async Task<List<ApiKeyPermissionUsage>> GetAPIPermissionUsageRecords(Selector selector)
{
- await using var ctx = _applicationDbContextFactory.CreateContext();
- var entity = ctx.ApiKeyPermissionUsages.Where(c => c.ApiKey == apiKey).ToList();
+ var id = selector.GetId();
+ await using var ctx = applicationDbContextFactory.CreateContext();
+ var entity = ctx.ApiKeyPermissionUsages.Where(c => c.ApiKeyId == id).ToList();
return entity.Any() ? entity : new List<ApiKeyPermissionUsage>();
}
### BTCPayServer/Security/GreenField/APIKeysAuthenticationHandler.cs
@@ -49,7 +49,7 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
if (!Context.Request.HttpContext.GetAPIKey(out var apiKey) || string.IsNullOrEmpty(apiKey))
return AuthenticateResult.NoResult();
- var key = await apiKeyRepository.GetKey(apiKey, true);
+ var key = await apiKeyRepository.GetKey(new APIKeyRepository.Selector.ByApiKey(apiKey), true);
if (key is null)
return Fail($"ApiKey authentication failed: Unknown API Key");
var loggingContext = new UserService.CanLoginContext(key.User, baseUrl: Request.GetRequestBaseUrl());
### BTCPayServer/Views/UIManage/APIKeys.cshtml
@@ -28,12 +28,14 @@
{
<table class="table table-lg">
<thead>
- <tr>
- <th text-translate="true">Label</th>
- <th text-translate="true" class="w-125px">Key</th>
- <th text-translate="true">Permissions</th>
- <th class="actions-col"></th>
- </tr>
+ <tr>
+ <vc:date-column></vc:date-column>
+ <th text-translate="true">Id</th>
+ <th text-translate="true">Label</th>
+ <th text-translate="true" class="w-125px">Key</th>
+ <th text-translate="true">Permissions</th>
+ <th class="actions-col"></th>
+ </tr>
</thead>
<tbody>
@{
@@ -42,16 +44,25 @@
@foreach (var keyData in Model.ApiKeyDatas)
{
<tr>
+ <td class="date-col">@keyData.CreatedAt?.ToBrowserDate()</td>
+ <td><code>@keyData.Id</code></td>
<td>@keyData.Label</td>
<td>
- <code class="hide-when-js">@keyData.Id</code>
- <button type="button" class="btn btn-link only-for-js p-0" data-reveal-btn text-translate="true">Reveal</button>
- <div hidden class="gap-2 align-items-center">
- <code>@keyData.Id</code>
- <button type="button" class="btn btn-link d-flex p-0 clipboard-button" data-clipboard="@keyData.Id">
- <vc:icon symbol="actions-copy" />
- </button>
- </div>
+ @if (keyData.Key is not null)
+ {
+ <code class="hide-when-js">@keyData.Key</code>
+ <button type="button" class="btn btn-link only-for-js p-0" data-reveal-btn text-translate="true">Reveal</button>
+ <div hidden class="gap-2 align-items-center">
+ <code>@keyData.Key</code>
+ <button type="button" class="btn btn-link d-flex p-0 clipboard-button" data-clipboard="@keyData.Key">
+ <vc:icon symbol="actions-copy" />
+ </button>
+ </div>
+ }
+ else
+ {
+ <span>@(keyData.Prefix + "...")</span>
+ }
</td>
<td>
@{
@@ -60,7 +71,7 @@
@if (!permissions.Any())
{
<span class="info-note text-warning">
- <vc:icon symbol="warning"/>
+ <vc:icon symbol="warning" />
<span text-translate="true">No permissions</span>
</span>
}
@@ -74,11 +85,14 @@
</td>
<td>
<div class="d-flex align-items-center justify-content-end gap-1">
- <a asp-action="DeleteAPIKey" asp-route-id="@keyData.Id" asp-controller="UIManage" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="Any application using the API key <strong>@Html.Encode(keyData.Label ?? keyData.Id)</strong> will immediately lose access." data-confirm-input="@StringLocalizer["Delete"]" text-translate="true">Delete</a>
- <span>-</span>
- <button type="button" class="btn btn-link only-for-js p-0" data-qr="@index" text-translate="true">Show QR</button>
+ <a asp-action="DeleteAPIKey" asp-route-apiKeyId="@keyData.Id" asp-controller="UIManage" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="Any application using the API key <strong>@Html.Encode(keyData.Label ?? (keyData.Prefix + "..."))</strong> will immediately lose access." data-confirm-input="@StringLocalizer["Delete"]" text-translate="true">Delete</a>
+ @if (keyData.Key is not null)
+ {
+ <span>-</span>
+ <button type="button" class="btn btn-link only-for-js p-0" data-qr="@index" text-translate="true">Show QR</button>
+ }
<span>-</span>
- <a id="viewusage-@keyData.Id" asp-action="APIKeyPermissionAnalysis" asp-route-id="@keyData.Id" asp-controller="UIManage" text-translate="true">View Usage</a>
+ <a class="api-key-list__view-usage" asp-action="APIKeyPermissionAnalysis" asp-route-apiKeyId="@keyData.Id" asp-controller="UIManage" text-translate="true">View Usage</a>
</div>
</td>
</tr>
@@ -113,7 +127,7 @@
document.addEventListener("DOMContentLoaded", function () {
const apiKeys = @Safe.Json(Model.ApiKeyDatas.Select(data => new
{
- ApiKey = data.Id,
+ ApiKey = data.Key,
Host = Context.Request.GetAbsoluteRoot()
}));
const qrApp = initQRShow({ title: "API Key QR" });
### BTCPayServer/wwwroot/swagger/v1/swagger.template.api-keys.json
@@ -1,6 +1,6 @@
{
"paths": {
- "/api/v1/api-keys/{apikey}": {
+ "/api/v1/api-keys/{apiKeyId}": {
"delete": {
"operationId": "ApiKeys_DeleteApiKey",
"tags": [
@@ -10,10 +10,10 @@
"description": "Revoke the current API key so that it cannot be used anymore",
"parameters": [
{
- "name": "apikey",
+ "name": "apiKeyId",
"in": "path",
"required": true,
- "description": "The API Key to revoke",
+ "description": "The ID of the API Key to revoke",
"schema": {
"type": "string"
}
@@ -37,7 +37,7 @@
]
}
},
- "/api/v1/users/{idOrEmail}/api-keys/{apikey}": {
+ "/api/v1/users/{idOrEmail}/api-keys/{apiKeyId}": {
"delete": {
"operationId": "ApiKeys_DeleteUserApiKey",
"tags": [
@@ -50,10 +50,10 @@
"$ref": "#/components/parameters/UserIdOrEmail"
},
{
- "name": "apikey",
+ "name": "apiKeyId",
"in": "path",
"required": true,
- "description": "The API Key to revoke",
+ "description": "The ID of the API Key to revoke",
"schema": {
"type": "string"
}
@@ -316,6 +316,11 @@
"type": "object",
"additionalProperties": false,
"properties": {
+ "id": {
+ "type": "string",
+ "description": "The ID of the API Key, used to revoke it without sending the secret",
+ "nullable": false
+ },
"apiKey": {
"type": "string",
"description": "The API Key to use for API Key Authentication",
@@ -326,6 +331,13 @@
"description": "The label given by the user to this API Key",
"nullable": false
},
+ "created": {
+ "type": "integer",
+ "format": "unix-time",
+ "description": "Timestamp when the API Key was created. (null if created before 2.4.4)",
+ "example": 1710598234,
+ "nullable": true
+ },
"permissions": {
"type": "array",
"description": "The permissions associated to this API Key (can be scoped to a specific store)",
### Changelog.md
@@ -10,6 +10,8 @@
* **Store users**: Users must accept an invitation before joining a store (#7519) @dstrukt
* **Point of Sale**: Remove the per-request `notificationUrl`. Invoices now use the app's configured notification URL @Kukks
* **Server administration**: Remove legacy SSH settings and add deployment-provided `btcpay-host` integration, including `btcpay-host env` ([documentation](https://docs.btcpayserver.org/Development/HostIntegration/)) (#7511 #7543) @NicolasDorier
+* **API Keys**: Use API key IDs instead of secrets for Greenfield API revocation (#7561) @NicolasDorier
+* **Bitpay API**: Remove legacy BitPay Basic-auth API keys (#7561) @NicolasDorier
### New features
@@ -44,6 +46,7 @@
* **Greenfield SDK**: Safely encode values in API URLs (#7530) @NicolasDorier
* Hide breadcrumbs that only repeat the page title (#7517) @NicolasDorier
* **Monetization**: Show a useful message instead of a 404 when Manage billing is unavailable (#7516) @NicolasDorier
+* **API Keys**: Store API key hashes instead of plaintext secrets and show key IDs and creation dates (#7561) @NicolasDorier
## 2.4.3
Why this scored 60/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.