diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 31f301e..7ff15c7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,7 +18,7 @@ permissions:
jobs:
format:
name: Verify Code Format
- runs-on: [self-hosted, Linux]
+ runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout Source
@@ -52,7 +52,7 @@ jobs:
repository-boundary:
name: Verify Repository Boundary
- runs-on: [self-hosted, Linux]
+ runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout Source
@@ -65,7 +65,7 @@ jobs:
build-windows:
name: Build & Test (Windows)
timeout-minutes: 45
- runs-on: [self-hosted, Windows]
+ runs-on: windows-latest
steps:
- name: Checkout Source
uses: actions/checkout@v7
@@ -96,7 +96,7 @@ jobs:
build-linux:
name: Build & Test (Linux)
timeout-minutes: 45
- runs-on: [self-hosted, Linux]
+ runs-on: ubuntu-latest
steps:
- name: Checkout Source
uses: actions/checkout@v7
@@ -127,7 +127,7 @@ jobs:
build-macos:
name: Build & Test (macOS)
timeout-minutes: 45
- runs-on: [self-hosted, macOS]
+ runs-on: macos-latest
steps:
- name: Checkout Source
uses: actions/checkout@v7
diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml
index 5491054..4051554 100644
--- a/.github/workflows/code-coverage.yml
+++ b/.github/workflows/code-coverage.yml
@@ -18,7 +18,7 @@ permissions:
jobs:
dotnet:
name: .NET coverage
- runs-on: self-hosted
+ runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout repository
diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml
index 5a9e4d2..151a51c 100644
--- a/.github/workflows/dependabot-auto-merge.yml
+++ b/.github/workflows/dependabot-auto-merge.yml
@@ -11,7 +11,7 @@ permissions:
jobs:
dependabot:
name: Auto-merge Dependabot PR
- runs-on: self-hosted
+ runs-on: ubuntu-latest
if: github.event.pull_request.user.login == 'dependabot[bot]'
steps:
- name: Dependabot metadata
diff --git a/.github/workflows/model-audit.yml b/.github/workflows/model-audit.yml
index 47b4e60..5c3f3d4 100644
--- a/.github/workflows/model-audit.yml
+++ b/.github/workflows/model-audit.yml
@@ -9,7 +9,7 @@ permissions:
jobs:
optimize_ci:
name: Optimize CI
- runs-on: self-hosted
+ runs-on: ubuntu-latest
outputs:
skip: ${{ steps.check_skip.outputs.skip }}
steps:
@@ -23,7 +23,7 @@ jobs:
name: Validate model manifest
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
- runs-on: self-hosted
+ runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@@ -49,7 +49,7 @@ jobs:
name: Verify HF manifest hashes
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false' && (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule')
- runs-on: self-hosted
+ runs-on: ubuntu-latest
timeout-minutes: 180
steps:
diff --git a/src/Trackdub.Licensing/ILicenseSignatureTrustStore.cs b/src/Trackdub.Licensing/ILicenseSignatureTrustStore.cs
new file mode 100644
index 0000000..e42d890
--- /dev/null
+++ b/src/Trackdub.Licensing/ILicenseSignatureTrustStore.cs
@@ -0,0 +1,29 @@
+namespace Trackdub.Licensing;
+
+///
+/// Resolves the trusted public key used to verify a license token's ES256 signature.
+///
+///
+/// The public core verifies tokens against a single embedded development key by
+/// default (see the parameterless constructor). A
+/// consuming product that needs multi-key rotation or revocation supplies its own
+/// trust policy by implementing this interface and passing it to
+/// — itself never
+/// implements or requires one.
+///
+public interface ILicenseSignatureTrustStore
+{
+ ///
+ /// Resolves the PEM-encoded ECDSA P-256 public key trusted for the given key id,
+ /// or null to reject the token — e.g. because the key id is unknown, or the key
+ /// has been revoked.
+ ///
+ ///
+ /// The key id taken from the token's unverified claims. It is itself untrusted:
+ /// callers must not treat it as authenticated until the key it resolves to goes
+ /// on to successfully verify the token's signature. May be null for tokens that
+ /// carry no key id; implementations decide whether that resolves to a default
+ /// key or is rejected.
+ ///
+ string? ResolvePublicKeyPem(string? keyId);
+}
diff --git a/src/Trackdub.Licensing/LicenseService.cs b/src/Trackdub.Licensing/LicenseService.cs
index 568ccb6..a2854d1 100644
--- a/src/Trackdub.Licensing/LicenseService.cs
+++ b/src/Trackdub.Licensing/LicenseService.cs
@@ -20,10 +20,26 @@ public sealed class LicenseService : ILicenseInitializer, ILicenseTierProvider
public LicenseService(
IHardwareFingerprintProvider fingerprintProvider,
ILogger logger)
+ : this(fingerprintProvider, logger, trustStore: null)
+ {
+ }
+
+ ///
+ /// Signature trust policy. When null, tokens are verified against the single
+ /// embedded development public key, matching the two-argument constructor's
+ /// behavior. A consuming product that needs multi-key rotation or revocation
+ /// supplies its own implementation here.
+ ///
+ public LicenseService(
+ IHardwareFingerprintProvider fingerprintProvider,
+ ILogger logger,
+ ILicenseSignatureTrustStore? trustStore)
{
_fileStore = new LicenseFileStore();
_parser = new LicenseTokenParser();
- _validator = new LicenseTokenValidator();
+ _validator = trustStore is not null
+ ? new LicenseTokenValidator(trustStore)
+ : new LicenseTokenValidator();
_fingerprintProvider = fingerprintProvider;
_logger = logger;
_validationResult = new LicenseValidationResult(LicenseTier.Free, null, 0, 0, null, null);
@@ -75,7 +91,7 @@ public Task InitializeAsync(CancellationToken cancellat
// 3. Verify signature
var sigParts = _parser.GetSignatureParts(token);
- if (sigParts is null || !_validator.VerifySignature(sigParts.Value.SigningInput, sigParts.Value.Signature))
+ if (sigParts is null || !_validator.VerifySignature(claims.KeyId, sigParts.Value.SigningInput, sigParts.Value.Signature))
{
_logger.LogWarning("License token signature verification failed.");
_validationResult = new LicenseValidationResult(LicenseTier.Free, claims.Sub, 0, 0, null, "Invalid signature");
diff --git a/src/Trackdub.Licensing/LicenseTokenClaims.cs b/src/Trackdub.Licensing/LicenseTokenClaims.cs
index e764d4a..7c58475 100644
--- a/src/Trackdub.Licensing/LicenseTokenClaims.cs
+++ b/src/Trackdub.Licensing/LicenseTokenClaims.cs
@@ -9,4 +9,5 @@ internal sealed record LicenseTokenClaims(
IReadOnlyList Machines,
long Iat,
long? Exp,
- bool DevUnlimited = false);
+ bool DevUnlimited = false,
+ string? KeyId = null);
diff --git a/src/Trackdub.Licensing/LicenseTokenParser.cs b/src/Trackdub.Licensing/LicenseTokenParser.cs
index 6fa254b..e5ff02d 100644
--- a/src/Trackdub.Licensing/LicenseTokenParser.cs
+++ b/src/Trackdub.Licensing/LicenseTokenParser.cs
@@ -42,7 +42,8 @@ internal sealed class LicenseTokenParser
Machines: payload.Machines ?? [],
Iat: payload.Iat,
Exp: payload.Exp,
- DevUnlimited: payload.DevUnlimited ?? false);
+ DevUnlimited: payload.DevUnlimited ?? false,
+ KeyId: payload.KeyId);
}
catch (FormatException)
{
@@ -105,5 +106,8 @@ private sealed record PayloadDto
[System.Text.Json.Serialization.JsonPropertyName("dev_unlimited")]
public bool? DevUnlimited { get; init; }
+
+ [System.Text.Json.Serialization.JsonPropertyName("kid")]
+ public string? KeyId { get; init; }
}
}
diff --git a/src/Trackdub.Licensing/LicenseTokenValidator.cs b/src/Trackdub.Licensing/LicenseTokenValidator.cs
index b8425d5..c57f423 100644
--- a/src/Trackdub.Licensing/LicenseTokenValidator.cs
+++ b/src/Trackdub.Licensing/LicenseTokenValidator.cs
@@ -18,7 +18,8 @@ internal sealed class LicenseTokenValidator
-----END PUBLIC KEY-----
""";
- private readonly ECDsa _ecdsa;
+ private readonly ECDsa? _ecdsa;
+ private readonly ILicenseSignatureTrustStore? _trustStore;
///
/// Creates a validator using the embedded production public key.
@@ -38,16 +39,49 @@ internal LicenseTokenValidator(string publicKeyPem)
_ecdsa.ImportFromPem(publicKeyPem);
}
+ ///
+ /// Creates a validator that resolves the trusted key per-token via
+ /// instead of the embedded key.
+ ///
+ internal LicenseTokenValidator(ILicenseSignatureTrustStore trustStore)
+ {
+ _trustStore = trustStore;
+ }
+
///
/// Verifies the ES256 signature over the signing input bytes.
/// Returns true if the signature is valid, false otherwise.
/// Never throws.
///
- public bool VerifySignature(byte[] signingInput, byte[] signature)
+ ///
+ /// The unverified key id from the token's claims. Ignored when this validator
+ /// was constructed without a trust store; the embedded key is used in that case.
+ ///
+ public bool VerifySignature(string? keyId, byte[] signingInput, byte[] signature)
{
+ if (_trustStore is not null)
+ {
+ var publicKeyPem = _trustStore.ResolvePublicKeyPem(keyId);
+ if (publicKeyPem is null)
+ {
+ return false;
+ }
+
+ try
+ {
+ using var ecdsa = ECDsa.Create();
+ ecdsa.ImportFromPem(publicKeyPem);
+ return ecdsa.VerifyData(signingInput, signature, HashAlgorithmName.SHA256);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
try
{
- return _ecdsa.VerifyData(signingInput, signature, HashAlgorithmName.SHA256);
+ return _ecdsa!.VerifyData(signingInput, signature, HashAlgorithmName.SHA256);
}
catch
{
diff --git a/tests/Trackdub.Licensing.Tests/LicenseSignatureTrustStoreTests.cs b/tests/Trackdub.Licensing.Tests/LicenseSignatureTrustStoreTests.cs
new file mode 100644
index 0000000..e75a2d5
--- /dev/null
+++ b/tests/Trackdub.Licensing.Tests/LicenseSignatureTrustStoreTests.cs
@@ -0,0 +1,159 @@
+using System.Security.Cryptography;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Trackdub.Licensing.Tests;
+
+// Covers the ILicenseSignatureTrustStore seam: a consuming product can supply its own
+// multi-key trust policy without patching Trackdub.Licensing internals.
+public sealed class LicenseSignatureTrustStoreTests
+{
+ [Fact]
+ public async Task Token_signed_with_trust_store_key_verifies_and_resolves_pro_tier()
+ {
+ var tempBase = Directory.CreateTempSubdirectory().FullName;
+ try
+ {
+ using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+ var publicKeyPem = key.ExportSubjectPublicKeyInfoPem();
+
+ var token = TestTokenBuilder.BuildSignedToken(
+ sub: "user-1",
+ tier: "pro",
+ machines: ["sha256:current"],
+ iat: 1_700_000_000,
+ exp: 2_000_000_000,
+ keyId: "ring-key-1",
+ signingKey: key);
+
+ var store = new LicenseFileStore(tempBase);
+ store.WriteToken(token);
+
+ var trustStore = new FakeTrustStore(("ring-key-1", publicKeyPem));
+ var service = new LicenseService(
+ store,
+ new LicenseTokenParser(),
+ new LicenseTokenValidator(trustStore),
+ new StaticFingerprintProvider("sha256:current"),
+ NullLogger.Instance);
+
+ var result = await service.InitializeAsync();
+
+ Assert.Equal(LicenseTier.Pro, result.Tier);
+ Assert.Null(result.DegradationReason);
+ var requestedKeyId = Assert.Single(trustStore.RequestedKeyIds);
+ Assert.Equal("ring-key-1", requestedKeyId);
+ }
+ finally
+ {
+ Directory.Delete(tempBase, true);
+ }
+ }
+
+ [Fact]
+ public async Task Unknown_key_id_is_rejected_even_with_otherwise_valid_signature()
+ {
+ var tempBase = Directory.CreateTempSubdirectory().FullName;
+ try
+ {
+ using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+
+ var token = TestTokenBuilder.BuildSignedToken(
+ sub: "user-1",
+ tier: "pro",
+ machines: ["sha256:current"],
+ iat: 1_700_000_000,
+ exp: 2_000_000_000,
+ keyId: "revoked-key",
+ signingKey: key);
+
+ var store = new LicenseFileStore(tempBase);
+ store.WriteToken(token);
+
+ // Trust store knows nothing about "revoked-key" -- simulates an unknown or
+ // revoked key id. ResolvePublicKeyPem returning null must fail verification
+ // closed, not fall back to any other key.
+ var trustStore = new FakeTrustStore();
+ var service = new LicenseService(
+ store,
+ new LicenseTokenParser(),
+ new LicenseTokenValidator(trustStore),
+ new StaticFingerprintProvider("sha256:current"),
+ NullLogger.Instance);
+
+ var result = await service.InitializeAsync();
+
+ Assert.Equal(LicenseTier.Free, result.Tier);
+ Assert.Equal("Invalid signature", result.DegradationReason);
+ }
+ finally
+ {
+ Directory.Delete(tempBase, true);
+ }
+ }
+
+ [Fact]
+ public async Task Signature_from_wrong_key_fails_even_when_key_id_resolves()
+ {
+ var tempBase = Directory.CreateTempSubdirectory().FullName;
+ try
+ {
+ using var signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+ using var differentKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+
+ var token = TestTokenBuilder.BuildSignedToken(
+ sub: "user-1",
+ tier: "pro",
+ machines: ["sha256:current"],
+ iat: 1_700_000_000,
+ exp: 2_000_000_000,
+ keyId: "ring-key-1",
+ signingKey: signingKey);
+
+ var store = new LicenseFileStore(tempBase);
+ store.WriteToken(token);
+
+ // Trust store resolves "ring-key-1" to a *different* public key than the one
+ // that actually signed the token.
+ var trustStore = new FakeTrustStore(("ring-key-1", differentKey.ExportSubjectPublicKeyInfoPem()));
+ var service = new LicenseService(
+ store,
+ new LicenseTokenParser(),
+ new LicenseTokenValidator(trustStore),
+ new StaticFingerprintProvider("sha256:current"),
+ NullLogger.Instance);
+
+ var result = await service.InitializeAsync();
+
+ Assert.Equal(LicenseTier.Free, result.Tier);
+ Assert.Equal("Invalid signature", result.DegradationReason);
+ }
+ finally
+ {
+ Directory.Delete(tempBase, true);
+ }
+ }
+
+ private sealed class FakeTrustStore : ILicenseSignatureTrustStore
+ {
+ private readonly Dictionary _keysByKeyId;
+
+ public FakeTrustStore(params (string KeyId, string PublicKeyPem)[] keys)
+ {
+ _keysByKeyId = keys.ToDictionary(k => k.KeyId, k => k.PublicKeyPem);
+ }
+
+ public List RequestedKeyIds { get; } = [];
+
+ public string? ResolvePublicKeyPem(string? keyId)
+ {
+ RequestedKeyIds.Add(keyId);
+ return keyId is not null && _keysByKeyId.TryGetValue(keyId, out var pem) ? pem : null;
+ }
+ }
+
+ private sealed class StaticFingerprintProvider(string fingerprint) : IHardwareFingerprintProvider
+ {
+ public string GetFingerprint() => fingerprint;
+ }
+}
diff --git a/tests/Trackdub.Licensing.Tests/TokenSignatureRoundTripTests.cs b/tests/Trackdub.Licensing.Tests/TokenSignatureRoundTripTests.cs
index 5eafb12..1dc01b0 100644
--- a/tests/Trackdub.Licensing.Tests/TokenSignatureRoundTripTests.cs
+++ b/tests/Trackdub.Licensing.Tests/TokenSignatureRoundTripTests.cs
@@ -42,7 +42,7 @@ from exp in Gen.Frequency(
return false.Label("Parse returned null");
// Assert: signature is valid
- var signatureValid = validator.VerifySignature(sigParts.Value.SigningInput, sigParts.Value.Signature);
+ var signatureValid = validator.VerifySignature(claims.KeyId, sigParts.Value.SigningInput, sigParts.Value.Signature);
if (!signatureValid)
return false.Label("Signature validation failed");
@@ -88,7 +88,9 @@ public static string BuildSignedToken(
IReadOnlyList machines,
long iat,
long? exp,
- bool devUnlimited = false)
+ bool devUnlimited = false,
+ string? keyId = null,
+ ECDsa? signingKey = null)
{
var header = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(new { alg = "ES256", typ = "JWT" }));
var payloadObj = new Dictionary
@@ -103,10 +105,14 @@ public static string BuildSignedToken(
{
payloadObj["dev_unlimited"] = true;
}
+ if (keyId is not null)
+ {
+ payloadObj["kid"] = keyId;
+ }
var payload = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(payloadObj));
var signingInput = $"{header}.{payload}";
- var signatureBytes = Ecdsa.SignData(Encoding.UTF8.GetBytes(signingInput), HashAlgorithmName.SHA256);
+ var signatureBytes = (signingKey ?? Ecdsa).SignData(Encoding.UTF8.GetBytes(signingInput), HashAlgorithmName.SHA256);
var signature = Base64UrlEncode(signatureBytes);
return $"{header}.{payload}.{signature}";