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}";