Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand All @@ -188,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

Expand Down
39 changes: 39 additions & 0 deletions Services/Index.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,31 @@ private async Task<KeyMintResult<T>> HandleDeleteRequest<T>(string endpoint, Dic
}
}

/// <summary>
/// Generic method to handle DELETE requests with a JSON body
/// </summary>
private async Task<KeyMintResult<T>> HandleDeleteRequestWithBody<T>(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<T>(response).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error in DELETE {Endpoint}", endpoint);
return KeyMintResult<T>.Failure(new KeyMintApiError { Message = ex.Message, Code = -1 });
}
}

/// <summary>
/// Generic method to handle PUT requests
/// </summary>
Expand Down Expand Up @@ -389,6 +414,20 @@ public async Task<KeyMintResult<BlockKeyResponse>> BlockKey(BlockKeyParams param
return await HandleRequest<BlockKeyResponse>("/key/block", parameters, options, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Permanently deletes a license key. Use BlockKey to stop a key reversibly.
/// </summary>
/// <param name="parameters">The parameters for deleting the key.</param>
/// <param name="options">Optional request configurations (e.g. idempotency keys).</param>
/// <returns>The delete response.</returns>
/// <exception cref="KeyMintApiException">Thrown if the API returns an error.</exception>
public async Task<KeyMintResult<DeleteKeyResponse>> DeleteKey(DeleteKeyParams parameters, RequestOptions? options = null, CancellationToken cancellationToken = default)
{
if (parameters == null || !parameters.IsValid())
return KeyMintResult<DeleteKeyResponse>.Failure(new KeyMintApiError { Message = "Invalid DeleteKeyParams", Code = -1 });
return await HandleDeleteRequestWithBody<DeleteKeyResponse>("/key", parameters, options, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Unblocks a license key.
/// </summary>
Expand Down
24 changes: 24 additions & 0 deletions Services/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,30 @@ public class BlockKeyResponse
public required int Code { get; set; } // API response code (e.g., 0 for success)
}

/// <summary>
/// Parameters for the deleteKey API endpoint.
/// </summary>
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);
}

/// <summary>
/// Response structure for a successful deleteKey API call.
/// </summary>
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)
}

/// <summary>
/// Parameters for the unblockKey API endpoint.
/// </summary>
Expand Down
79 changes: 79 additions & 0 deletions Tests/ApiContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
63 changes: 53 additions & 10 deletions Tests/LiveApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -206,18 +206,30 @@ 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
{
if (nodeKey != null)
{
try
{
await admin.BlockKey(new BlockKeyParams
{
ProductId = productId,
LicenseKey = nodeKey
});
await DeleteOrBlockKeyAsync(admin, productId, nodeKey);
}
catch
{
Expand All @@ -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
{
Expand All @@ -258,6 +266,41 @@ await admin.DeleteCustomer(new DeleteCustomerParams
}
}

/// <summary>
/// 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.
/// </summary>
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<KeyMintResult<T>> RetryWhileRateLimitedAsync<T>(Func<Task<KeyMintResult<T>>> action, int maxAttempts = 4)
{
KeyMintResult<T> 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<T>(KeyMintResult<T> result)
{
var message = result.Error?.Message ?? string.Empty;
Expand Down
Loading