From acb45820f69ea97f2c57836eda982a472779377b Mon Sep 17 00:00:00 2001 From: catsika Date: Mon, 26 Oct 2026 03:13:59 +0000 Subject: [PATCH 1/2] feat: DeleteKey for DELETE /key Adds DeleteKey(DeleteKeyParams), sending productId and licenseKey in the JSON body of DELETE /key so the license key never appears in the URL. The live test now deletes the keys it creates. --- CHANGELOG.md | 1 + README.md | 1 + Services/Index.cs | 39 +++++++++++++++++++ Services/Types.cs | 24 ++++++++++++ Tests/ApiContractTests.cs | 79 +++++++++++++++++++++++++++++++++++++++ Tests/LiveApiTests.cs | 63 ++++++++++++++++++++++++++----- 6 files changed, 197 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efcc57a..1713cb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- `DeleteKey`: permanently deletes a license key (`DELETE /key`). Use `BlockKey` to stop a key reversibly. - `OfflineLicenseVerifier.Verify` and `TryVerify`: verify an offline license (`.lic` file from `SignKey` or the dashboard, or the bare token) locally against your public key PEMs, with no network call. `ProductId` and `MachineCode` are required and compared exactly, so a license for one product can't unlock another (one signing key covers every product in an organization). Returns `OfflineLicense`; failures throw `OfflineLicenseException` with an `OfflineLicenseErrorCode` (`CodeString` gives the shared snake_case code). Follows the shared Keymint offline-verifier spec and passes its 34 test vectors. ### Dependencies diff --git a/README.md b/README.md index 5a128af..dbf93e5 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,7 @@ Keymint provides utilities to uniquely identify machines for node-locking: | `GetKey` | Retrieves detailed information about a key. | | `BlockKey` | Blocks a license key. | | `UnblockKey` | Unblocks a previously blocked license key. | +| `DeleteKey` | Permanently deletes a license key. | | `UpdateKey` | Updates an existing license key. | | `SignKey` | Signs a key for offline (air-gapped) validation using an admin API key.| | `OfflineLicenseVerifier.Verify` | Verifies a signed `.lic` file locally, with no network call. See [Offline Licenses](#offline-licenses). | diff --git a/Services/Index.cs b/Services/Index.cs index b618914..4b3a88e 100644 --- a/Services/Index.cs +++ b/Services/Index.cs @@ -129,6 +129,31 @@ private async Task> HandleDeleteRequest(string endpoint, Dic } } + /// + /// Generic method to handle DELETE requests with a JSON body + /// + private async Task> HandleDeleteRequestWithBody(string endpoint, object parameters, RequestOptions? options = null, CancellationToken cancellationToken = default) + { + try + { + var request = new HttpRequestMessage(HttpMethod.Delete, endpoint) + { + Content = JsonContent.Create(parameters) + }; + if (options?.IdempotencyKey != null) + { + request.Headers.Add("Idempotency-Key", options.IdempotencyKey); + } + var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + return await HandleResponse(response).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error in DELETE {Endpoint}", endpoint); + return KeyMintResult.Failure(new KeyMintApiError { Message = ex.Message, Code = -1 }); + } + } + /// /// Generic method to handle PUT requests /// @@ -389,6 +414,20 @@ public async Task> BlockKey(BlockKeyParams param return await HandleRequest("/key/block", parameters, options, cancellationToken).ConfigureAwait(false); } + /// + /// Permanently deletes a license key. Use BlockKey to stop a key reversibly. + /// + /// The parameters for deleting the key. + /// Optional request configurations (e.g. idempotency keys). + /// The delete response. + /// Thrown if the API returns an error. + public async Task> DeleteKey(DeleteKeyParams parameters, RequestOptions? options = null, CancellationToken cancellationToken = default) + { + if (parameters == null || !parameters.IsValid()) + return KeyMintResult.Failure(new KeyMintApiError { Message = "Invalid DeleteKeyParams", Code = -1 }); + return await HandleDeleteRequestWithBody("/key", parameters, options, cancellationToken).ConfigureAwait(false); + } + /// /// Unblocks a license key. /// diff --git a/Services/Types.cs b/Services/Types.cs index 99dc6f6..8824186 100644 --- a/Services/Types.cs +++ b/Services/Types.cs @@ -357,6 +357,30 @@ public class BlockKeyResponse public required int Code { get; set; } // API response code (e.g., 0 for success) } + /// + /// Parameters for the deleteKey API endpoint. + /// + public class DeleteKeyParams + { + [JsonPropertyName("productId")] + public required string ProductId { get; set; } // Required: The unique identifier of the product. + [JsonPropertyName("licenseKey")] + public required string LicenseKey { get; set; } // Required: The license key to permanently delete. + + public bool IsValid() => !string.IsNullOrWhiteSpace(ProductId) && !string.IsNullOrWhiteSpace(LicenseKey); + } + + /// + /// Response structure for a successful deleteKey API call. + /// + public class DeleteKeyResponse + { + [JsonPropertyName("message")] + public required string Message { get; set; } // Confirmation message (e.g., "Key deleted") + [JsonPropertyName("code")] + public required int Code { get; set; } // API response code (e.g., 0 for success) + } + /// /// Parameters for the unblockKey API endpoint. /// diff --git a/Tests/ApiContractTests.cs b/Tests/ApiContractTests.cs index 1b5ab95..dfd113c 100644 --- a/Tests/ApiContractTests.cs +++ b/Tests/ApiContractTests.cs @@ -67,6 +67,85 @@ public async Task GetKey_sends_license_in_header_instead_of_url() Assert.Equal("secret/license+key", capturedRequest.Headers.GetValues("x-license-key").Single()); } + [Fact] + public async Task DeleteKey_sends_DELETE_with_license_in_json_body_instead_of_url() + { + HttpRequestMessage? capturedRequest = null; + string? requestBody = null; + var handler = new StubHandler(request => + { + capturedRequest = request; + requestBody = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + return JsonResponse(new { message = "Key deleted", code = 0 }); + }); + var sdk = CreateSdk(handler); + + var result = await sdk.DeleteKey(new DeleteKeyParams + { + ProductId = "product_123", + LicenseKey = "secret/license+key" + }); + + Assert.True(result.IsSuccess, result.Error?.Message); + Assert.Equal("Key deleted", result.Data!.Message); + Assert.Equal(0, result.Data.Code); + + Assert.NotNull(capturedRequest); + Assert.Equal(HttpMethod.Delete, capturedRequest!.Method); + Assert.Equal("/key", capturedRequest.RequestUri!.AbsolutePath); + Assert.Equal(string.Empty, capturedRequest.RequestUri.Query); + Assert.DoesNotContain("secret", capturedRequest.RequestUri.ToString()); + Assert.False(capturedRequest.Headers.Contains("x-license-key")); + Assert.Equal("Bearer", capturedRequest.Headers.Authorization?.Scheme); + Assert.Equal("admin_test", capturedRequest.Headers.Authorization?.Parameter); + Assert.Equal("application/json", capturedRequest.Content!.Headers.ContentType?.MediaType); + + using var body = JsonDocument.Parse(requestBody!); + Assert.Equal("product_123", body.RootElement.GetProperty("productId").GetString()); + Assert.Equal("secret/license+key", body.RootElement.GetProperty("licenseKey").GetString()); + } + + [Fact] + public async Task DeleteKey_returns_api_error_for_unknown_key() + { + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent( + "{\"message\":\"Invalid license key\",\"code\":1}", + Encoding.UTF8, + "application/json") + }); + var sdk = CreateSdk(handler); + + var result = await sdk.DeleteKey(new DeleteKeyParams + { + ProductId = "product_123", + LicenseKey = "missing_key" + }); + + Assert.False(result.IsSuccess); + Assert.Null(result.Data); + Assert.Equal("Invalid license key", result.Error!.Message); + Assert.Equal(1, result.Error.Code); + Assert.Equal(404, result.Error.Status); + } + + [Fact] + public async Task DeleteKey_rejects_missing_parameters_without_a_request() + { + var called = false; + var sdk = CreateSdk(new StubHandler(_ => + { + called = true; + return JsonResponse(new { message = "Key deleted", code = 0 }); + })); + + var result = await sdk.DeleteKey(new DeleteKeyParams { ProductId = "product_123", LicenseKey = " " }); + + Assert.False(result.IsSuccess); + Assert.False(called); + } + [Fact] public async Task ActivateKey_deserializes_metadata_and_version_fields() { diff --git a/Tests/LiveApiTests.cs b/Tests/LiveApiTests.cs index 46f0e62..dc39ef0 100644 --- a/Tests/LiveApiTests.cs +++ b/Tests/LiveApiTests.cs @@ -206,6 +206,22 @@ await RequireSuccessAsync(() => client.FloatingCheckin(new FloatingCheckinParams })); Assert.True(deletedCustomer.Status); customerId = null; + + var deletedKey = await RequireSuccessAsync(() => admin.DeleteKey(new DeleteKeyParams + { + ProductId = productId, + LicenseKey = nodeKey + })); + Assert.Equal(0, deletedKey.Code); + var deletedNodeKey = nodeKey; + nodeKey = null; + + var lookupAfterDelete = await RetryWhileRateLimitedAsync(() => readOnly.GetKey(new GetKeyParams + { + ProductId = productId, + LicenseKey = deletedNodeKey + })); + Assert.False(lookupAfterDelete.IsSuccess, "GetKey should fail for a deleted license key"); } finally { @@ -213,11 +229,7 @@ await RequireSuccessAsync(() => client.FloatingCheckin(new FloatingCheckinParams { try { - await admin.BlockKey(new BlockKeyParams - { - ProductId = productId, - LicenseKey = nodeKey - }); + await DeleteOrBlockKeyAsync(admin, productId, nodeKey); } catch { @@ -229,11 +241,7 @@ await admin.BlockKey(new BlockKeyParams { try { - await admin.BlockKey(new BlockKeyParams - { - ProductId = productId, - LicenseKey = floatingKey - }); + await DeleteOrBlockKeyAsync(admin, productId, floatingKey); } catch { @@ -258,6 +266,41 @@ await admin.DeleteCustomer(new DeleteCustomerParams } } + /// + /// Best-effort cleanup: permanently delete a key created by the test so the shared + /// test workspace doesn't fill up; block it if the delete doesn't go through. + /// + private static async Task DeleteOrBlockKeyAsync(KeyMintSDK admin, string productId, string licenseKey) + { + var deleted = await RetryWhileRateLimitedAsync(() => admin.DeleteKey(new DeleteKeyParams + { + ProductId = productId, + LicenseKey = licenseKey + })).ConfigureAwait(false); + if (deleted.IsSuccess) + { + return; + } + + await admin.BlockKey(new BlockKeyParams + { + ProductId = productId, + LicenseKey = licenseKey + }).ConfigureAwait(false); + } + + private static async Task> RetryWhileRateLimitedAsync(Func>> action, int maxAttempts = 4) + { + KeyMintResult result = await action().ConfigureAwait(false); + for (var attempt = 2; attempt <= maxAttempts && IsRateLimited(result); attempt++) + { + await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + result = await action().ConfigureAwait(false); + } + + return result; + } + private static bool IsRateLimited(KeyMintResult result) { var message = result.Error?.Message ?? string.Empty; From f63edea7495fa816367d03a0fcd7083408dfa566 Mon Sep 17 00:00:00 2001 From: catsika Date: Mon, 26 Oct 2026 03:15:12 +0000 Subject: [PATCH 2/2] docs: DeleteCustomer keeps the customer's license keys --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dbf93e5..5de8d58 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ Keymint provides utilities to uniquely identify machines for node-locking: | `GetCustomerWithKeys` | Gets a customer along with their license keys. | | `UpdateCustomer` | Updates an existing customer's information. | | `ToggleCustomerStatus`| Toggles a customer's active status. | -| `DeleteCustomer` | Permanently deletes a customer and their keys. | +| `DeleteCustomer` | Deletes a customer. Their license keys are kept; use `DeleteKey` to remove them. | ### Webhook Verification