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 + + + + +