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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,6 @@ jobs:
KEYMINT_TEST_READONLY_API_KEY: ${{ secrets.KEYMINT_TEST_READONLY_API_KEY }}
KEYMINT_TEST_CLIENT_API_KEY: ${{ secrets.KEYMINT_TEST_CLIENT_API_KEY }}
KEYMINT_TEST_PRODUCT_ID: ${{ vars.KEYMINT_TEST_PRODUCT_ID }}
KEYMINT_TEST_PUBLIC_KEY: ${{ vars.KEYMINT_TEST_PUBLIC_KEY }}
KEYMINT_TEST_BASE_URL: https://api.keymint.dev
run: dotnet test Tests/KeyMint.Tests.csproj --configuration Release --filter "Category=Live"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Thumbs.db
# MSBuild files
*.cache
*.json
!Tests/fixtures/*.json
*.g.props
*.g.targets
*.dgspec.json
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

### Added

- `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

- New dependency: `BouncyCastle.Cryptography` 2.7.0 (MIT, managed, about 6 MB) for Ed25519, which .NET 8 and 9 don't provide. An app that still references the old `Portable.BouncyCastle` package and uses its types directly may get ambiguous-type compile errors, since both packages define the `Org.BouncyCastle` namespaces; move to `BouncyCastle.Cryptography`.

## 1.6.0 - 2026-10-26

### Added
Expand Down
1 change: 1 addition & 0 deletions Csharp-Sdk.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
<None Include="LICENSE" Pack="true" PackagePath="" />
<None Include="README.md" Pack="true" PackagePath="" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.8" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.7.0" />
</ItemGroup>

</Project>
88 changes: 88 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Requires .NET 8 or later.
- **Strongly typed**: Full support for .NET types and JSON serialization.
- **Consistent error handling**: All API errors are returned as structured `KeyMintResult` objects.
- **Machine Identity**: Built-in utilities for hardware fingerprinting and stable installation IDs.
- **Offline licenses**: Verify signed `.lic` files locally with `OfflineLicenseVerifier`.

## Installation
Add the SDK to your project:
Expand Down Expand Up @@ -66,6 +67,92 @@ if (activation.IsSuccess) {

Store the last `ExpirationDate` you received. An expired license fails activation with the same generic error as an invalid key, so the stored date is what lets your app tell the user the license expired on that date instead of calling the key invalid.

## Offline Licenses

An offline license is a `.lic` file holding an Ed25519-signed token bound to one product and one machine. `OfflineLicenseVerifier.Verify` checks it locally, with no network call.

1. **Get the `.lic` file.** Download it from the dashboard (Offline License), or create it with `SignKey` (admin API key) while the machine is online. `HostId` is the machine code the license is bound to.

```csharp
var signed = await client.SignKey(new SignKeyParams { // admin API key
ProductId = productId,
LicenseKey = licenseKey,
HostId = hostId
});
// signed.Data.File holds the .lic contents; save it on the machine.
```

2. **Bundle your public key.** Copy the PEM from **Developer → Public Keys** in the dashboard and ship it with your app. It is a public key, so it is safe to ship; never ship an API key.

3. **Verify** with the product ID and this machine's code:

```csharp
using KeyMint.Services;

const string PublicKeyPem = """
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA...
-----END PUBLIC KEY-----
""";

try
{
var license = OfflineLicenseVerifier.Verify(File.ReadAllText("license.lic"), new OfflineVerifyOptions
{
PublicKeys = new[] { PublicKeyPem },
ProductId = productId, // required
MachineCode = KeyMintIdentity.GetOrCreateInstallationId(), // the HostId the file was signed for
Now = TrustedNow(), // optional, see clock rollback below
// ClockSkew = TimeSpan.FromSeconds(60), // optional leeway on expiry, 0 to 1 hour
});
Console.WriteLine($"Licensed until {license.ExpiresAt:yyyy-MM-dd}");
}
catch (OfflineLicenseException ex)
{
switch (ex.Code)
{
case OfflineLicenseErrorCode.Expired: /* ask the user to go online and renew */ break;
case OfflineLicenseErrorCode.WrongMachine: /* the file was issued for another machine */ break;
case OfflineLicenseErrorCode.WrongProduct: /* the file is for a different product */ break;
default: /* Malformed, UnsupportedAlgorithm, UnknownKey, BadSignature, WrongType: not a genuine license */ break;
}
}
```

`Verify` accepts the `.lic` file contents or the bare token and returns an `OfflineLicense` with `LicenseKey`, `ProductId`, `MachineCode`, `KeyId`, `IssuedAt` (`DateTimeOffset?`) and `ExpiresAt`. `TryVerify(license, options, out var result, out var error)` does the same without throwing for an invalid license. On failure, `OfflineLicenseException.Code` is one of:

| Code | `CodeString` | Meaning |
|---|---|---|
| `Malformed` | `malformed` | Not a `.lic` file or token, or a required claim is missing |
| `UnsupportedAlgorithm` | `unsupported_algorithm` | Not signed with EdDSA |
| `UnknownKey` | `unknown_key` | Signed by a key not in `PublicKeys` |
| `BadSignature` | `bad_signature` | The signature does not match (the file was altered) |
| `WrongType` | `wrong_type` | Not an offline license token |
| `WrongProduct` | `wrong_product` | Issued for a different product |
| `WrongMachine` | `wrong_machine` | Issued for a different machine |
| `Expired` | `expired` | Past its expiry plus `ClockSkew` |

`CodeString` (or `Code.ToSpecString()`) is the same code the other Keymint SDKs use. The exception message never includes the license key.

`ProductId` is required because one signing key covers every product in your organization: without the check, a license for one product would unlock another. `ProductId` and `MachineCode` are compared exactly (no trimming or case folding). Pass more than one key in `PublicKeys` to accept licenses across a key rotation.

Invalid options are programming errors and throw `ArgumentException` (also from `TryVerify`): no public keys, a key that isn't an Ed25519 public key PEM, or an empty `ProductId` or `MachineCode`. A `ClockSkew` outside 0 to 1 hour throws `ArgumentOutOfRangeException`.

**Clock rollback.** The SDK can't tell if the user set the system clock back. Store the latest time your app has trusted (for example after each successful check or online call) and pass `Now = max(system time, last trusted time)`:

```csharp
static DateTimeOffset TrustedNow()
{
var now = DateTimeOffset.UtcNow;
var lastTrusted = LoadLastTrustedTime(); // your app's storage
var trusted = now > lastTrusted ? now : lastTrusted;
SaveLastTrustedTime(trusted);
return trusted;
}
```

An offline license stays valid until it expires; blocking the key online does not revoke a file already issued.

## Machine Identity
Keymint provides utilities to uniquely identify machines for node-locking:

Expand All @@ -86,6 +173,7 @@ Keymint provides utilities to uniquely identify machines for node-locking:
| `UnblockKey` | Unblocks a previously blocked 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). |
| `FloatingCheckout` | Checks out a floating license seat. |
| `FloatingHeartbeat`| Sends a heartbeat to keep a session alive. |
| `FloatingCheckin` | Checks in a session, releasing the seat. |
Expand Down
Loading
Loading