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
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:
parent
edb7410f76
commit
1ba274d699
12 changed files with 448 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -480,3 +480,6 @@ $RECYCLE.BIN/
|
|||
|
||||
# Vim temporary swap files
|
||||
*.swp
|
||||
|
||||
# SQLInsight license signing keys: never commit private keys
|
||||
*.private.pem
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
52
src/SQLInsight.Licensing/License.cs
Normal file
52
src/SQLInsight.Licensing/License.cs
Normal 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;
|
||||
}
|
||||
34
src/SQLInsight.Licensing/LicenseVerifier.cs
Normal file
34
src/SQLInsight.Licensing/LicenseVerifier.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
8
src/SQLInsight.Licensing/SQLInsight.Licensing.csproj
Normal file
8
src/SQLInsight.Licensing/SQLInsight.Licensing.csproj
Normal 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>
|
||||
75
src/SQLInsight.Licensing/SignedDocument.cs
Normal file
75
src/SQLInsight.Licensing/SignedDocument.cs
Normal 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.<base64url payload>.<base64url signature></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, '='));
|
||||
}
|
||||
}
|
||||
47
src/SQLInsight.Licensing/TrustedKeys.cs
Normal file
47
src/SQLInsight.Licensing/TrustedKeys.cs
Normal 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);
|
||||
}
|
||||
4
src/SQLInsight.Licensing/trusted-keys.json
Normal file
4
src/SQLInsight.Licensing/trusted-keys.json
Normal 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": []
|
||||
}
|
||||
125
tests/SQLInsight.Licensing.Tests/LicenseTests.cs
Normal file
125
tests/SQLInsight.Licensing.Tests/LicenseTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
72
tools/SQLInsight.LicenseTool/Program.cs
Normal file
72
tools/SQLInsight.LicenseTool/Program.cs
Normal 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.");
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Reference in a new issue