What changed, and why it matters
This commit refactors how BTCPay Server stores and handles API keys. Previously, the secret API key itself was used as the database primary key, meaning the full secret was stored in plaintext and appeared in URLs/revocation endpoints. Now the system creates a separate public 'key ID' (starting with 'akid_') derived from a hash of the secret, stores only a hash of the secret in the database, and uses the ID for management operations such as revocation. The change also removes the old BitPay 'Basic auth' legacy API key feature entirely. The commit is framed as a security hardening measure, but it is a large refactor rather than a single bug fix.
Treat this as a security-hardening change worth reviewing in production: verify the migration ran cleanly, confirm legacy BitPay API key consumers have migrated to SIN/token authentication, and ensure the 5-minute cleanup job does not break legitimate long-lived API key workflows. No immediate exploit is evident from the diff, but the scope of the refactor warrants regression testing of API key creation, authentication, and revocation.
Security signals we found
API key secrets no longer used as database primary key or in revocation URLs
Database now stores SHA256 hash of secret rather than plaintext secret for authentication lookup
New ephemeral Key column cleared after 5 minutes by scheduled cleanup
Migration deletes legacy BitPay API keys (Type=0)
BitPay Basic-auth API key authentication path removed entirely
Changelog explicitly describes this as API key hardening
Evidence from the diff
The commit changes the APIKeyData schema: Id becomes a derived identifier (‘akid_’ + first 16 hex chars of double-SHA256 of the secret), while the actual secret moves to a new Key column that is cleared after 5 minutes by a scheduled cleanup job. A Hash column stores SHA256(secret) for authentication lookup. The migration deletes legacy BitPay-style keys (Type=0), renames ApiKeyPermissionUsages.ApiKey to ApiKeyId, and rewrites IDs. Greenfield API revocation endpoints now accept apiKeyId instead of the secret. The Bitpay authentication handler no longer supports Basic-auth API key lookup, and the UI for generating legacy BitPay API keys is removed. Tests are updated to use APIKeyRepository.Selector.ByApiKey / ById.
Changed components
BTCPayServer.Data APIKeyData entity and migrationBTCPayServer.Security.Greenfield.APIKeyRepositoryGreenfield API keys controllerUIManageController.APIKeysBitPay plugin authentication and token controllerAPI key management UI and Swagger templateInspect 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 56/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.