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
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/code-coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/model-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions src/Trackdub.Licensing/ILicenseSignatureTrustStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace Trackdub.Licensing;

/// <summary>
/// Resolves the trusted public key used to verify a license token's ES256 signature.
/// </summary>
/// <remarks>
/// The public core verifies tokens against a single embedded development key by
/// default (see the parameterless <see cref="LicenseService"/> constructor). A
/// consuming product that needs multi-key rotation or revocation supplies its own
/// trust policy by implementing this interface and passing it to
/// <see cref="LicenseService"/> — <see cref="Trackdub.Licensing"/> itself never
/// implements or requires one.
/// </remarks>
public interface ILicenseSignatureTrustStore
{
/// <summary>
/// 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.
/// </summary>
/// <param name="keyId">
/// 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.
/// </param>
string? ResolvePublicKeyPem(string? keyId);
}
20 changes: 18 additions & 2 deletions src/Trackdub.Licensing/LicenseService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,26 @@ public sealed class LicenseService : ILicenseInitializer, ILicenseTierProvider
public LicenseService(
IHardwareFingerprintProvider fingerprintProvider,
ILogger<LicenseService> logger)
: this(fingerprintProvider, logger, trustStore: null)
{
}

/// <param name="trustStore">
/// 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 <see cref="ILicenseSignatureTrustStore"/> implementation here.
/// </param>
public LicenseService(
IHardwareFingerprintProvider fingerprintProvider,
ILogger<LicenseService> 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);
Expand Down Expand Up @@ -75,7 +91,7 @@ public Task<LicenseValidationResult> 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");
Expand Down
3 changes: 2 additions & 1 deletion src/Trackdub.Licensing/LicenseTokenClaims.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ internal sealed record LicenseTokenClaims(
IReadOnlyList<string> Machines,
long Iat,
long? Exp,
bool DevUnlimited = false);
bool DevUnlimited = false,
string? KeyId = null);
6 changes: 5 additions & 1 deletion src/Trackdub.Licensing/LicenseTokenParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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; }
}
}
40 changes: 37 additions & 3 deletions src/Trackdub.Licensing/LicenseTokenValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ internal sealed class LicenseTokenValidator
-----END PUBLIC KEY-----
""";

private readonly ECDsa _ecdsa;
private readonly ECDsa? _ecdsa;
private readonly ILicenseSignatureTrustStore? _trustStore;

/// <summary>
/// Creates a validator using the embedded production public key.
Expand All @@ -38,16 +39,49 @@ internal LicenseTokenValidator(string publicKeyPem)
_ecdsa.ImportFromPem(publicKeyPem);
}

/// <summary>
/// Creates a validator that resolves the trusted key per-token via
/// <paramref name="trustStore"/> instead of the embedded key.
/// </summary>
internal LicenseTokenValidator(ILicenseSignatureTrustStore trustStore)
{
_trustStore = trustStore;
}

/// <summary>
/// Verifies the ES256 signature over the signing input bytes.
/// Returns true if the signature is valid, false otherwise.
/// Never throws.
/// </summary>
public bool VerifySignature(byte[] signingInput, byte[] signature)
/// <param name="keyId">
/// 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.
/// </param>
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
{
Expand Down
Loading
Loading