diff --git a/.gitignore b/.gitignore
index 0808c4a..9d07fa0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -480,3 +480,6 @@ $RECYCLE.BIN/
# Vim temporary swap files
*.swp
+
+# SQLInsight license signing keys: never commit private keys
+*.private.pem
diff --git a/SQLInsight.slnx b/SQLInsight.slnx
index 0ff6098..e6114fe 100644
--- a/SQLInsight.slnx
+++ b/SQLInsight.slnx
@@ -6,6 +6,7 @@
+
@@ -16,7 +17,11 @@
+
+
+
+
diff --git a/src/SQLInsight.Licensing/License.cs b/src/SQLInsight.Licensing/License.cs
new file mode 100644
index 0000000..fe8fa5f
--- /dev/null
+++ b/src/SQLInsight.Licensing/License.cs
@@ -0,0 +1,52 @@
+namespace SQLInsight.Licensing;
+
+public enum LicensePlan
+{
+ Community,
+ Professional,
+ Enterprise,
+}
+
+///
+/// What a license grants. SQLInsight Pro monitors everything without a license; a license adds findings sync to
+/// SQLInsight Hub and records the support agreement.
+///
+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; }
+
+ /// How many instances may sync findings to the hub. 0 means unlimited.
+ public int MaxInstances { get; init; }
+
+ /// Id of the key that signed the license (for key rotation).
+ 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
+{
+ /// No license configured: Community.
+ None,
+ Valid,
+
+ /// Expired, but within the grace period: everything still works; renew soon.
+ ExpiredInGrace,
+
+ Expired,
+
+ /// The signature does not match, the key is unknown, or the text is damaged.
+ Invalid,
+}
+
+public sealed record LicenseStatus(LicenseState State, License? License, string Message)
+{
+ /// Findings may be synced while the license is valid or in grace.
+ public bool AllowsFindingsSync => State is LicenseState.Valid or LicenseState.ExpiredInGrace && License!.IncludesFindingsSync;
+}
diff --git a/src/SQLInsight.Licensing/LicenseVerifier.cs b/src/SQLInsight.Licensing/LicenseVerifier.cs
new file mode 100644
index 0000000..39801a7
--- /dev/null
+++ b/src/SQLInsight.Licensing/LicenseVerifier.cs
@@ -0,0 +1,34 @@
+namespace SQLInsight.Licensing;
+
+///
+/// Checks a license text offline. Never throws on bad input: an invalid license is a state, not an error, and never
+/// stops monitoring.
+///
+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(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.");
+ }
+}
diff --git a/src/SQLInsight.Licensing/SQLInsight.Licensing.csproj b/src/SQLInsight.Licensing/SQLInsight.Licensing.csproj
new file mode 100644
index 0000000..9781207
--- /dev/null
+++ b/src/SQLInsight.Licensing/SQLInsight.Licensing.csproj
@@ -0,0 +1,8 @@
+
+
+ SQLInsight.Licensing
+
+
+
+
+
diff --git a/src/SQLInsight.Licensing/SignedDocument.cs b/src/SQLInsight.Licensing/SignedDocument.cs
new file mode 100644
index 0000000..1ace749
--- /dev/null
+++ b/src/SQLInsight.Licensing/SignedDocument.cs
@@ -0,0 +1,75 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace SQLInsight.Licensing;
+
+///
+/// A JSON document signed with ECDSA P-256 / SHA-256: SQLI1.<base64url payload>.<base64url signature>.
+/// Used for licenses and update manifests. Verification is offline; only the public key is needed.
+///
+public static class SignedDocument
+{
+ public const string Prefix = "SQLI1";
+
+ public static string Sign(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)}";
+ }
+
+ /// Returns the payload if the signature is valid for one of the trusted keys; otherwise null.
+ public static T? Verify(string token, TrustedKeys keys, Func 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(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, '='));
+ }
+}
diff --git a/src/SQLInsight.Licensing/TrustedKeys.cs b/src/SQLInsight.Licensing/TrustedKeys.cs
new file mode 100644
index 0000000..217f3f9
--- /dev/null
+++ b/src/SQLInsight.Licensing/TrustedKeys.cs
@@ -0,0 +1,47 @@
+using System.Security.Cryptography;
+using System.Text.Json;
+
+namespace SQLInsight.Licensing;
+
+///
+/// Public keys, by key id, that may sign licenses and update manifests.
+///
+public sealed class TrustedKeys
+{
+ private readonly Dictionary _keys;
+
+ public TrustedKeys(IEnumerable> keys)
+ {
+ ArgumentNullException.ThrowIfNull(keys);
+ _keys = keys.ToDictionary(k => k.Key, k => Convert.FromBase64String(k.Value), StringComparer.Ordinal);
+ }
+
+ public int Count => _keys.Count;
+
+ /// The keys compiled into this build (see trusted-keys.json).
+ public static TrustedKeys Embedded()
+ {
+ using var stream = typeof(TrustedKeys).Assembly.GetManifestResourceStream("SQLInsight.Licensing.trusted-keys.json")!;
+ var document = JsonSerializer.Deserialize(stream, Json.Options)!;
+ return new TrustedKeys(document.Keys.Select(k => KeyValuePair.Create(k.Id, k.PublicKey)));
+ }
+
+ public TrustedKeys With(IEnumerable> 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 Keys);
+
+ private sealed record KeyEntry(string Id, string PublicKey);
+}
diff --git a/src/SQLInsight.Licensing/trusted-keys.json b/src/SQLInsight.Licensing/trusted-keys.json
new file mode 100644
index 0000000..384f331
--- /dev/null
+++ b/src/SQLInsight.Licensing/trusted-keys.json
@@ -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": []
+}
diff --git a/tests/SQLInsight.Licensing.Tests/LicenseTests.cs b/tests/SQLInsight.Licensing.Tests/LicenseTests.cs
new file mode 100644
index 0000000..235cfd1
--- /dev/null
+++ b/tests/SQLInsight.Licensing.Tests/LicenseTests.cs
@@ -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);
+ }
+}
diff --git a/tests/SQLInsight.Licensing.Tests/SQLInsight.Licensing.Tests.csproj b/tests/SQLInsight.Licensing.Tests/SQLInsight.Licensing.Tests.csproj
new file mode 100644
index 0000000..f503a85
--- /dev/null
+++ b/tests/SQLInsight.Licensing.Tests/SQLInsight.Licensing.Tests.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/SQLInsight.LicenseTool/Program.cs b/tools/SQLInsight.LicenseTool/Program.cs
new file mode 100644
index 0000000..7e09035
--- /dev/null
+++ b/tools/SQLInsight.LicenseTool/Program.cs
@@ -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 Opret nøglepar (privat PEM + offentlig nøgle)
+ issue --key --kid --customer --plan
+ --expires [--instances ] Udsted licens
+ verify --public --kid 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(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.");
diff --git a/tools/SQLInsight.LicenseTool/SQLInsight.LicenseTool.csproj b/tools/SQLInsight.LicenseTool/SQLInsight.LicenseTool.csproj
new file mode 100644
index 0000000..9dcdb32
--- /dev/null
+++ b/tools/SQLInsight.LicenseTool/SQLInsight.LicenseTool.csproj
@@ -0,0 +1,9 @@
+
+
+ Exe
+ SQLInsight.LicenseTool
+
+
+
+
+