Fase 3: offline-verifiable signed licenses and license tool
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions

- SQLInsight.Licensing: ECDSA P-256 signed documents (SQLI1.payload.sig),
  licenses with plan, expiry, instance count and key id for rotation;
  30-day grace; invalid or missing licenses are states, never errors,
  and never stop monitoring (a license only adds hub sync and support)
- Trusted public keys are embedded; the source ships with none, so a
  published key can never mint licenses. The vendor adds its own.
- tools/SQLInsight.LicenseTool: keygen, issue, verify
- *.private.pem ignored by git

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
This commit is contained in:
Claude 2026-09-29 08:30:28 +00:00
parent edb7410f76
commit 1ba274d699
No known key found for this signature in database
12 changed files with 448 additions and 0 deletions

3
.gitignore vendored
View file

@ -480,3 +480,6 @@ $RECYCLE.BIN/
# Vim temporary swap files
*.swp
# SQLInsight license signing keys: never commit private keys
*.private.pem

View file

@ -6,6 +6,7 @@
<Project Path="src/SQLInsight.Contracts/SQLInsight.Contracts.csproj" />
<Project Path="src/SQLInsight.Core/SQLInsight.Core.csproj" />
<Project Path="src/SQLInsight.Hub/SQLInsight.Hub.csproj" />
<Project Path="src/SQLInsight.Licensing/SQLInsight.Licensing.csproj" />
<Project Path="src/SQLInsight.Notifications/SQLInsight.Notifications.csproj" />
<Project Path="src/SQLInsight.Repository/SQLInsight.Repository.csproj" />
<Project Path="src/SQLInsight.Rules/SQLInsight.Rules.csproj" />
@ -16,7 +17,11 @@
<Project Path="tests/SQLInsight.Connectivity.Tests/SQLInsight.Connectivity.Tests.csproj" />
<Project Path="tests/SQLInsight.Core.Tests/SQLInsight.Core.Tests.csproj" />
<Project Path="tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj" />
<Project Path="tests/SQLInsight.Licensing.Tests/SQLInsight.Licensing.Tests.csproj" />
<Project Path="tests/SQLInsight.Notifications.Tests/SQLInsight.Notifications.Tests.csproj" />
<Project Path="tests/SQLInsight.Rules.Tests/SQLInsight.Rules.Tests.csproj" />
</Folder>
<Folder Name="/tools/">
<Project Path="tools/SQLInsight.LicenseTool/SQLInsight.LicenseTool.csproj" />
</Folder>
</Solution>

View file

@ -0,0 +1,52 @@
namespace SQLInsight.Licensing;
public enum LicensePlan
{
Community,
Professional,
Enterprise,
}
/// <summary>
/// What a license grants. SQLInsight Pro monitors everything without a license; a license adds findings sync to
/// SQLInsight Hub and records the support agreement.
/// </summary>
public sealed record License
{
public required string LicenseId { get; init; }
public required string Customer { get; init; }
public required LicensePlan Plan { get; init; }
public required DateTimeOffset IssuedAt { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>How many instances may sync findings to the hub. 0 means unlimited.</summary>
public int MaxInstances { get; init; }
/// <summary>Id of the key that signed the license (for key rotation).</summary>
public required string KeyId { get; init; }
public bool IncludesFindingsSync => Plan is LicensePlan.Professional or LicensePlan.Enterprise;
public bool IncludesPrioritySupport => Plan is LicensePlan.Professional or LicensePlan.Enterprise;
public bool IncludesPhoneSupport => Plan is LicensePlan.Enterprise;
}
public enum LicenseState
{
/// <summary>No license configured: Community.</summary>
None,
Valid,
/// <summary>Expired, but within the grace period: everything still works; renew soon.</summary>
ExpiredInGrace,
Expired,
/// <summary>The signature does not match, the key is unknown, or the text is damaged.</summary>
Invalid,
}
public sealed record LicenseStatus(LicenseState State, License? License, string Message)
{
/// <summary>Findings may be synced while the license is valid or in grace.</summary>
public bool AllowsFindingsSync => State is LicenseState.Valid or LicenseState.ExpiredInGrace && License!.IncludesFindingsSync;
}

View file

@ -0,0 +1,34 @@
namespace SQLInsight.Licensing;
/// <summary>
/// Checks a license text offline. Never throws on bad input: an invalid license is a state, not an error, and never
/// stops monitoring.
/// </summary>
public sealed class LicenseVerifier(TrustedKeys keys, TimeProvider time)
{
public static readonly TimeSpan GracePeriod = TimeSpan.FromDays(30);
public LicenseStatus Check(string? licenseText)
{
if (string.IsNullOrWhiteSpace(licenseText))
{
return new LicenseStatus(LicenseState.None, null, "Ingen licens: Community. Al overvågning er gratis; Hub-synkronisering og prioriteret support kræver et abonnement.");
}
var license = SignedDocument.Verify<License>(licenseText, keys, l => l.KeyId);
if (license is null)
{
return new LicenseStatus(LicenseState.Invalid, null, "Licensen er ugyldig: signaturen passer ikke, eller teksten er beskadiget. Overvågningen fortsætter.");
}
var now = time.GetUtcNow();
if (now <= license.ExpiresAt)
{
return new LicenseStatus(LicenseState.Valid, license, $"{license.Plan} til {license.Customer}, gyldig til {license.ExpiresAt:yyyy-MM-dd}.");
}
return now <= license.ExpiresAt + GracePeriod
? new LicenseStatus(LicenseState.ExpiredInGrace, license, $"Licensen udløb {license.ExpiresAt:yyyy-MM-dd}. Hub-synkronisering fortsætter til {(license.ExpiresAt + GracePeriod):yyyy-MM-dd}; forny venligst.")
: new LicenseStatus(LicenseState.Expired, license, $"Licensen udløb {license.ExpiresAt:yyyy-MM-dd}. Overvågningen fortsætter; Hub-synkronisering er stoppet.");
}
}

View file

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>SQLInsight.Licensing</RootNamespace>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="trusted-keys.json" LogicalName="SQLInsight.Licensing.trusted-keys.json" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,75 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace SQLInsight.Licensing;
/// <summary>
/// A JSON document signed with ECDSA P-256 / SHA-256: <c>SQLI1.&lt;base64url payload&gt;.&lt;base64url signature&gt;</c>.
/// Used for licenses and update manifests. Verification is offline; only the public key is needed.
/// </summary>
public static class SignedDocument
{
public const string Prefix = "SQLI1";
public static string Sign<T>(T payload, ECDsa privateKey)
{
ArgumentNullException.ThrowIfNull(privateKey);
var bytes = JsonSerializer.SerializeToUtf8Bytes(payload, Json.Options);
var signature = privateKey.SignData(bytes, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
return $"{Prefix}.{Base64Url.Encode(bytes)}.{Base64Url.Encode(signature)}";
}
/// <summary>Returns the payload if the signature is valid for one of the trusted keys; otherwise null.</summary>
public static T? Verify<T>(string token, TrustedKeys keys, Func<T, string> keyIdOf)
where T : class
{
ArgumentNullException.ThrowIfNull(keys);
ArgumentNullException.ThrowIfNull(keyIdOf);
var parts = (token ?? string.Empty).Trim().Split('.');
if (parts.Length != 3 || parts[0] != Prefix)
{
return null;
}
byte[] payload;
byte[] signature;
T? document;
try
{
payload = Base64Url.Decode(parts[1]);
signature = Base64Url.Decode(parts[2]);
document = JsonSerializer.Deserialize<T>(payload, Json.Options);
}
catch (Exception ex) when (ex is FormatException or JsonException)
{
return null;
}
if (document is null || keys.Find(keyIdOf(document)) is not { } key)
{
return null;
}
return key.VerifyData(payload, signature, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation) ? document : null;
}
}
internal static class Json
{
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
}
internal static class Base64Url
{
public static string Encode(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
public static byte[] Decode(string text)
{
var s = text.Replace('-', '+').Replace('_', '/');
return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '='));
}
}

View file

@ -0,0 +1,47 @@
using System.Security.Cryptography;
using System.Text.Json;
namespace SQLInsight.Licensing;
/// <summary>
/// Public keys, by key id, that may sign licenses and update manifests.
/// </summary>
public sealed class TrustedKeys
{
private readonly Dictionary<string, byte[]> _keys;
public TrustedKeys(IEnumerable<KeyValuePair<string, string>> keys)
{
ArgumentNullException.ThrowIfNull(keys);
_keys = keys.ToDictionary(k => k.Key, k => Convert.FromBase64String(k.Value), StringComparer.Ordinal);
}
public int Count => _keys.Count;
/// <summary>The keys compiled into this build (see trusted-keys.json).</summary>
public static TrustedKeys Embedded()
{
using var stream = typeof(TrustedKeys).Assembly.GetManifestResourceStream("SQLInsight.Licensing.trusted-keys.json")!;
var document = JsonSerializer.Deserialize<KeyFile>(stream, Json.Options)!;
return new TrustedKeys(document.Keys.Select(k => KeyValuePair.Create(k.Id, k.PublicKey)));
}
public TrustedKeys With(IEnumerable<KeyValuePair<string, string>> extra) =>
new(_keys.Select(k => KeyValuePair.Create(k.Key, Convert.ToBase64String(k.Value))).Concat(extra));
internal ECDsa? Find(string keyId)
{
if (!_keys.TryGetValue(keyId, out var spki))
{
return null;
}
var key = ECDsa.Create();
key.ImportSubjectPublicKeyInfo(spki, out _);
return key;
}
private sealed record KeyFile(IReadOnlyList<KeyEntry> Keys);
private sealed record KeyEntry(string Id, string PublicKey);
}

View file

@ -0,0 +1,4 @@
{
"comment": "Public keys trusted to sign licenses and update manifests. Deliberately empty in source: the vendor generates a key pair with SQLInsight.LicenseTool (keygen) and adds the PUBLIC key here before building a release. The private key never goes into the repository.",
"keys": []
}

View file

@ -0,0 +1,125 @@
using System.Security.Cryptography;
namespace SQLInsight.Licensing.Tests;
public class LicenseTests
{
private static readonly DateTimeOffset Now = new(2026, 9, 29, 12, 0, 0, TimeSpan.Zero);
private sealed class FixedTime(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
private static (ECDsa Key, TrustedKeys Trusted) NewKey(string kid = "test1")
{
var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
return (key, new TrustedKeys([KeyValuePair.Create(kid, Convert.ToBase64String(key.ExportSubjectPublicKeyInfo()))]));
}
private static License Sample(DateTimeOffset expires, LicensePlan plan = LicensePlan.Professional, string kid = "test1") => new()
{
LicenseId = "lic-1", Customer = "Kunde A/S", Plan = plan, IssuedAt = Now.AddDays(-10), ExpiresAt = expires, MaxInstances = 5, KeyId = kid,
};
private static LicenseStatus Check(string text, TrustedKeys keys, DateTimeOffset? now = null) => new LicenseVerifier(keys, new FixedTime(now ?? Now)).Check(text);
[Fact]
public void Valid_license_round_trips()
{
var (key, trusted) = NewKey();
var text = SignedDocument.Sign(Sample(Now.AddYears(1)), key);
var status = Check(text, trusted);
Assert.StartsWith("SQLI1.", text, StringComparison.Ordinal);
Assert.Equal(LicenseState.Valid, status.State);
Assert.Equal("Kunde A/S", status.License!.Customer);
Assert.True(status.AllowsFindingsSync);
}
[Fact]
public void No_license_is_community_and_not_an_error()
{
var status = Check("", NewKey().Trusted);
Assert.Equal(LicenseState.None, status.State);
Assert.False(status.AllowsFindingsSync);
}
[Fact]
public void Tampered_payload_is_invalid()
{
var (key, trusted) = NewKey();
var parts = SignedDocument.Sign(Sample(Now.AddYears(1)), key).Split('.');
var forged = SignedDocument.Sign(Sample(Now.AddYears(10), LicensePlan.Enterprise), ECDsa.Create(ECCurve.NamedCurves.nistP256)).Split('.');
// Enterprise payload with the original, genuine signature.
Assert.Equal(LicenseState.Invalid, Check($"{parts[0]}.{forged[1]}.{parts[2]}", trusted).State);
}
[Fact]
public void License_signed_with_an_untrusted_key_is_invalid()
{
var (_, trusted) = NewKey();
var (other, _) = NewKey();
Assert.Equal(LicenseState.Invalid, Check(SignedDocument.Sign(Sample(Now.AddYears(1)), other), trusted).State);
}
[Fact]
public void Unknown_key_id_is_invalid()
{
var (key, trusted) = NewKey("test1");
Assert.Equal(LicenseState.Invalid, Check(SignedDocument.Sign(Sample(Now.AddYears(1), kid: "other"), key), trusted).State);
}
[Theory]
[InlineData("garbage")]
[InlineData("SQLI1.x.y")]
[InlineData("SQLI1.@@@.###")]
[InlineData("SQLI2.a.b")]
public void Garbage_is_invalid_not_an_exception(string text)
{
Assert.Equal(LicenseState.Invalid, Check(text, NewKey().Trusted).State);
}
[Fact]
public void Expired_license_keeps_syncing_during_grace_then_stops()
{
var (key, trusted) = NewKey();
var text = SignedDocument.Sign(Sample(Now), key);
var grace = Check(text, trusted, Now.AddDays(10));
var expired = Check(text, trusted, Now.AddDays(31));
Assert.Equal(LicenseState.ExpiredInGrace, grace.State);
Assert.True(grace.AllowsFindingsSync);
Assert.Equal(LicenseState.Expired, expired.State);
Assert.False(expired.AllowsFindingsSync);
}
[Fact]
public void Community_plan_does_not_include_sync()
{
var (key, trusted) = NewKey();
Assert.False(Check(SignedDocument.Sign(Sample(Now.AddYears(1), LicensePlan.Community), key), trusted).AllowsFindingsSync);
}
[Fact]
public void Plans()
{
Assert.True(Sample(Now, LicensePlan.Enterprise).IncludesPhoneSupport);
Assert.False(Sample(Now, LicensePlan.Professional).IncludesPhoneSupport);
Assert.True(Sample(Now, LicensePlan.Professional).IncludesPrioritySupport);
}
[Fact]
public void Source_ships_without_trusted_keys()
{
// The vendor adds its public key before a release; a published test key would let anyone mint licenses.
Assert.Equal(0, TrustedKeys.Embedded().Count);
}
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SQLInsight.Licensing\SQLInsight.Licensing.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,72 @@
// Vendor tool: generate a signing key pair, issue licenses, verify them. Runs on the vendor's machine only.
using System.Globalization;
using System.Security.Cryptography;
using SQLInsight.Licensing;
return args.FirstOrDefault() switch
{
"keygen" => KeyGen(Option("--out") ?? "."),
"issue" => Issue(),
"verify" => Verify(),
_ => Usage(),
};
int Usage()
{
Console.Error.WriteLine("""
SQLInsight.LicenseTool
keygen --out <mappe> Opret nøglepar (privat PEM + offentlig nøgle)
issue --key <privat.pem> --kid <id> --customer <navn> --plan <Professional|Enterprise>
--expires <yyyy-mm-dd> [--instances <antal>] Udsted licens
verify --public <base64> --kid <id> <licens> Verificér licens
""");
return 1;
}
int KeyGen(string folder)
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var kid = "k" + DateTime.UtcNow.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
Directory.CreateDirectory(folder);
var privatePath = Path.Combine(folder, $"sqlinsight-{kid}.private.pem");
File.WriteAllText(privatePath, key.ExportPkcs8PrivateKeyPem());
var publicKey = Convert.ToBase64String(key.ExportSubjectPublicKeyInfo());
Console.WriteLine($"Privat nøgle: {privatePath} (opbevar sikkert, aldrig i git)");
Console.WriteLine("Tilføj til src/SQLInsight.Licensing/trusted-keys.json:");
Console.WriteLine($$"""{ "id": "{{kid}}", "publicKey": "{{publicKey}}" }""");
return 0;
}
int Issue()
{
using var key = ECDsa.Create();
key.ImportFromPem(File.ReadAllText(Required("--key")));
var license = new License
{
LicenseId = Guid.NewGuid().ToString("N"),
Customer = Required("--customer"),
Plan = Enum.Parse<LicensePlan>(Required("--plan"), ignoreCase: true),
IssuedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.Parse(Required("--expires") + "T23:59:59Z", CultureInfo.InvariantCulture),
MaxInstances = int.Parse(Option("--instances") ?? "0", CultureInfo.InvariantCulture),
KeyId = Required("--kid"),
};
Console.WriteLine(SignedDocument.Sign(license, key));
return 0;
}
int Verify()
{
var keys = new TrustedKeys([KeyValuePair.Create(Required("--kid"), Required("--public"))]);
var status = new LicenseVerifier(keys, TimeProvider.System).Check(args[^1]);
Console.WriteLine($"{status.State}: {status.Message}");
return status.State is LicenseState.Valid ? 0 : 2;
}
string? Option(string name)
{
var i = Array.IndexOf(args, name);
return i >= 0 && i + 1 < args.Length ? args[i + 1] : null;
}
string Required(string name) => Option(name) ?? throw new ArgumentException($"{name} mangler.");

View file

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>SQLInsight.LicenseTool</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SQLInsight.Licensing\SQLInsight.Licensing.csproj" />
</ItemGroup>
</Project>