diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..dc90d6c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,4 @@ +# Copilot Instructions + +## Project Guidelines +- For this fork, keep .lic expiration storage format unchanged for compatibility, but interpret expiration as a date-only value (timezone-agnostic) during validation. \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d7fab56 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,114 @@ +# .github/workflows/build.yml +# +# Build workflow for Standard.Licensing. +# +# - Trigger on pushes, pull requests, and manual dispatch. +# - Restore, build, test, and pack the NuGet package. +# - The package project multi-targets .NET 10, 9, 8, 6, .NET Standard 2.0, and .NET Framework 4.6.1/4.8.1. +# - Upload the generated NuGet packages as a short-lived artifact. + +name: Build + +on: + push: + branches: [ '**' ] + tags: + - 'v*' + paths-ignore: + - '**/*.gitignore' + - '**/*.gitattributes' + - '**/*.md' + - 'LICENSE' + pull_request: + branches: [ master, main ] + paths-ignore: + - '**/*.gitignore' + - '**/*.gitattributes' + - '**/*.md' + - 'LICENSE' + workflow_dispatch: + +env: + PROJECT_PATH: src/Standard.Licensing/Standard.Licensing.csproj + TEST_PROJECT_PATH: src/Standard.Licensing.Tests/Standard.Licensing.Tests.csproj + BUILD_CONFIGURATION: Release + ARTIFACT_NAME: package-standard-licensing + PUBLISH_FILES_PATH: ${{ github.workspace }}/artifacts/package + +concurrency: + group: standard-licensing-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: 🏗 Build, test, and pack + # TODO: Change back when Windows 2025 with VS2026 is available. (4 May 2026) + # runs-on: windows-latest + runs-on: windows-2025-vs2026 + permissions: + contents: read + + steps: + - name: 🧾 Check out repository + uses: actions/checkout@v6 + + - name: 🛠️ Set up .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 6.0.x + 8.0.x + 9.0.x + 10.0.x + dotnet-quality: ga + + - name: 📦 Restore NuGet package project + run: | + dotnet restore "${{ env.PROJECT_PATH }}" + + - name: 📦 Restore test project + run: | + dotnet restore "${{ env.TEST_PROJECT_PATH }}" + + - name: 🧩 Build NuGet package project + run: | + dotnet build ` + "${{ env.PROJECT_PATH }}" ` + --configuration ${{ env.BUILD_CONFIGURATION }} ` + --no-restore + + - name: 🏗 Build test project + run: | + dotnet build ` + "${{ env.TEST_PROJECT_PATH }}" ` + --configuration ${{ env.BUILD_CONFIGURATION }} ` + --no-restore + + - name: 💯 Run tests + timeout-minutes: 5 + run: | + dotnet test ` + "${{ env.TEST_PROJECT_PATH }}" ` + --configuration ${{ env.BUILD_CONFIGURATION }} ` + --no-restore ` + --no-build ` + --verbosity detailed + + - name: 🧳 Pack NuGet package + run: | + dotnet pack ` + "${{ env.PROJECT_PATH }}" ` + --configuration ${{ env.BUILD_CONFIGURATION }} ` + --no-restore ` + --no-build ` + --output "${{ env.PUBLISH_FILES_PATH }}" + + - name: 📤 Upload NuGet package artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ env.ARTIFACT_NAME }} + path: | + ${{ env.PUBLISH_FILES_PATH }}/*.nupkg + ${{ env.PUBLISH_FILES_PATH }}/*.snupkg + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml new file mode 100644 index 0000000..aa44091 --- /dev/null +++ b/.github/workflows/publish-nuget.yml @@ -0,0 +1,103 @@ +# .github/workflows/publish-nuget.yml +# +# Manual NuGet publish workflow for Standard.Licensing.12noon. +# +# - Triggered only by manual dispatch. +# - Requires environment approval before publishing. +# - Restores, builds, and packs the multi-targeted package before publishing to NuGet.org. + +name: Publish NuGet Package + +on: + workflow_dispatch: + +env: + PROJECT_PATH: src/Standard.Licensing/Standard.Licensing.csproj + BUILD_CONFIGURATION: Release + PUBLISH_FILES_PATH: ${{ github.workspace }}\artifacts\package + +jobs: + publish_nuget: + name: 🚀 Publish NuGet package + # TODO: Change back when Windows 2025 with VS2026 is available. (4 May 2026) + # runs-on: windows-latest + runs-on: windows-2025-vs2026 + environment: nuget-prod + permissions: + contents: read + + steps: + - name: 🧾 Check out repository + uses: actions/checkout@v6 + + - name: 🛠️ Set up .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 6.0.x + 8.0.x + 9.0.x + 10.0.x + dotnet-quality: ga + + - name: 📦 Restore NuGet package project + run: | + dotnet restore "${{ env.PROJECT_PATH }}" + + - name: 🧩 Build NuGet package project + run: | + dotnet build ` + "${{ env.PROJECT_PATH }}" ` + --configuration ${{ env.BUILD_CONFIGURATION }} ` + --no-restore + + - name: 🧳 Pack NuGet package + run: | + dotnet pack ` + "${{ env.PROJECT_PATH }}" ` + --configuration ${{ env.BUILD_CONFIGURATION }} ` + --no-restore ` + --no-build ` + --include-symbols ` + --property:SymbolPackageFormat=snupkg ` + --output "${{ env.PUBLISH_FILES_PATH }}" + + - name: 🔎 List packed artifacts + run: | + Write-Host "Package output directory: ${{ env.PUBLISH_FILES_PATH }}" + if (-not (Test-Path "${{ env.PUBLISH_FILES_PATH }}")) + { + throw "Package output directory does not exist: ${{ env.PUBLISH_FILES_PATH }}" + } + + $allPackages = Get-ChildItem -Path "${{ env.PUBLISH_FILES_PATH }}" -File | Where-Object { $_.Extension -in '.nupkg', '.snupkg' } + if ($allPackages.Count -eq 0) + { + throw "No .nupkg or .snupkg files were found in ${{ env.PUBLISH_FILES_PATH }}" + } + + Write-Host "Discovered package files:" + $allPackages | ForEach-Object { Write-Host " - $($_.FullName)" } + + - name: 🚀 Publish NuGet package to NuGet.org + env: + NUGET_AUTH_TOKEN: ${{ secrets.API_KEY_NUGET }} + run: | + if ([string]::IsNullOrWhiteSpace($env:NUGET_AUTH_TOKEN)) + { + throw "NUGET_AUTH_TOKEN is missing or empty. Check environment/repository secrets and environment approval." + } + + $packages = Get-ChildItem -Path "${{ env.PUBLISH_FILES_PATH }}" -Filter "*.nupkg" -File + if ($packages.Count -eq 0) + { + throw "No .nupkg files found in ${{ env.PUBLISH_FILES_PATH }}" + } + + foreach ($package in $packages) + { + dotnet nuget push "$($package.FullName)" ` + --source "https://api.nuget.org/v3/index.json" ` + --api-key "$env:NUGET_AUTH_TOKEN" ` + --skip-duplicate + } diff --git a/src/Standard.Licensing.Tests/LicenseSignatureTests.cs b/src/Standard.Licensing.Tests/LicenseSignatureTests.cs index c3537be..0a09adf 100644 --- a/src/Standard.Licensing.Tests/LicenseSignatureTests.cs +++ b/src/Standard.Licensing.Tests/LicenseSignatureTests.cs @@ -1,4 +1,4 @@ -// +// // Copyright © 2012 - 2013 Nauck IT KG http://www.nauck-it.de // // Author: @@ -52,7 +52,12 @@ private static DateTime ConvertToRfc1123(DateTime dateTime) { return DateTime.ParseExact( dateTime.ToUniversalTime().ToString("r", CultureInfo.InvariantCulture) - , "r", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); + , "r", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal); + } + + private static DateTime NormalizeToUtcMidnight(DateTime dateTime) + { + return new DateTime(dateTime.Year, dateTime.Month, dateTime.Day, 0, 0, 0, DateTimeKind.Utc); } [Test] @@ -80,13 +85,25 @@ public void Can_Generate_And_Validate_Signature_With_Empty_License() Assert.That(license.VerifySignature(publicKey), Is.True); } - [Test] - public void Can_Generate_And_Validate_Signature_With_Standard_License() + public static IEnumerable LocalAndUtc + { + get + { + yield return new TestCaseData(DateTime.Now.AddMinutes(1)); + yield return new TestCaseData(DateTime.Now.AddYears(1)); + yield return new TestCaseData(DateTime.UtcNow.AddMinutes(1)); + yield return new TestCaseData(DateTime.UtcNow.AddYears(1)); + yield return new TestCaseData(DateTime.SpecifyKind(DateTime.Now.AddMinutes(1), DateTimeKind.Unspecified)); + yield return new TestCaseData(DateTime.SpecifyKind(DateTime.Now.AddYears(1), DateTimeKind.Unspecified)); + } + } + + [Test, TestCaseSource(nameof(LocalAndUtc))] + public void Can_Generate_And_Validate_Signature_With_Standard_License(DateTime expirationDate) { var licenseId = Guid.NewGuid(); var customerName = "Max Mustermann"; var customerEmail = "max@mustermann.tld"; - var expirationDate = DateTime.Now.AddYears(1); var productFeatures = new Dictionary { {"Sales Module", "yes"}, @@ -119,7 +136,7 @@ public void Can_Generate_And_Validate_Signature_With_Standard_License() Assert.That(license.Customer, Is.Not.Null); Assert.That(license.Customer.Name, Is.EqualTo(customerName)); Assert.That(license.Customer.Email, Is.EqualTo(customerEmail)); - Assert.That(license.Expiration, Is.EqualTo(ConvertToRfc1123(expirationDate))); + Assert.That(license.Expiration, Is.EqualTo(NormalizeToUtcMidnight(expirationDate))); // verify signature Assert.That(license.VerifySignature(publicKey), Is.True); @@ -174,10 +191,38 @@ public void Can_Detect_Hacked_License() Assert.That(hackedLicense.Customer, Is.Not.Null); Assert.That(hackedLicense.Customer.Name, Is.EqualTo(customerName)); Assert.That(hackedLicense.Customer.Email, Is.EqualTo(customerEmail)); - Assert.That(hackedLicense.Expiration, Is.EqualTo(ConvertToRfc1123(expirationDate))); + Assert.That(hackedLicense.Expiration, Is.EqualTo(NormalizeToUtcMidnight(expirationDate))); // verify signature Assert.That(hackedLicense.VerifySignature(publicKey), Is.False); } + + public static IEnumerable AllDateTimeKinds + { + get + { + var date = new DateTime(2030, 6, 15, 14, 30, 0); + yield return new TestCaseData(DateTime.SpecifyKind(date, DateTimeKind.Utc)).SetName("Utc"); + yield return new TestCaseData(DateTime.SpecifyKind(date, DateTimeKind.Local)).SetName("Local"); + yield return new TestCaseData(DateTime.SpecifyKind(date, DateTimeKind.Unspecified)).SetName("Unspecified"); + } + } + + [Test, TestCaseSource(nameof(AllDateTimeKinds))] + public void Expiration_NormalizesToUtcMidnight(DateTime expirationDate) + { + var expectedUtcMidnight = NormalizeToUtcMidnight(expirationDate); + + var licenseViaBuilder = License.New() + .ExpiresAt(expirationDate) + .CreateAndSignWithPrivateKey(privateKey, passPhrase); + Assert.That(licenseViaBuilder.Expiration, Is.EqualTo(expectedUtcMidnight)); + Assert.That(licenseViaBuilder.Expiration.Kind, Is.EqualTo(DateTimeKind.Utc)); + + var licenseViaProperty = License.Load(""); + licenseViaProperty.Expiration = expirationDate; + Assert.That(licenseViaProperty.Expiration, Is.EqualTo(expectedUtcMidnight)); + Assert.That(licenseViaProperty.Expiration.Kind, Is.EqualTo(DateTimeKind.Utc)); + } } } \ No newline at end of file diff --git a/src/Standard.Licensing.Tests/LicenseValidationTests.cs b/src/Standard.Licensing.Tests/LicenseValidationTests.cs index 9a0b601..1eb1b17 100644 --- a/src/Standard.Licensing.Tests/LicenseValidationTests.cs +++ b/src/Standard.Licensing.Tests/LicenseValidationTests.cs @@ -24,230 +24,341 @@ // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using NUnit.Framework; -using Standard.Licensing.Validation; - -namespace Standard.Licensing.Tests -{ - [TestFixture] - public class LicenseValidationTests - { - [Test] - public void Can_Validate_Valid_Signature() - { - var publicKey = - @"MIIBKjCB4wYHKoZIzj0CATCB1wIBATAsBgcqhkjOPQEBAiEA/////wAAAAEAAAAAAAAAAAAAAAD///////////////8wWwQg/////wAAAAEAAAAAAAAAAAAAAAD///////////////wEIFrGNdiqOpPns+u9VXaYhrxlHQawzFOw9jvOPD4n0mBLAxUAxJ02CIbnBJNqZnjhE50mt4GffpAEIQNrF9Hy4SxCR/i85uVjpEDydwN9gS3rM6D0oTlF2JjClgIhAP////8AAAAA//////////+85vqtpxeehPO5ysL8YyVRAgEBA0IABNVLQ1xKY80BFMgGXec++Vw7n8vvNrq32PaHuBiYMm0PEj2JoB7qSSWhfgcjxNVJsxqJ6gDQVWgl0r7LH4dr0KU="; - var licenseData = @" - 77d4c193-6088-4c64-9663-ed7398ae8c1a - Trial - Sun, 31 Dec 1899 23:00:00 GMT - 1 - - John Doe - john@doe.tld - - - - MEUCIQCCEDAldOZHHIKvYZRDdzUP4V51y23d6deeK5jIFy27GQIgDz2CndjBh4Vb8tiC3FGQ6fn3GKt8d/P5+luJH0cWv+I= - "; - - var license = License.Load(licenseData); - - var validationResults = license - .Validate() - .Signature(publicKey) - .AssertValidLicense(); - - Assert.That(validationResults, Is.Not.Null); - Assert.That(validationResults.Count(), Is.EqualTo(0)); - } - - [Test] - public void Can_Validate_Invalid_Signature() - { - var publicKey = - @"MIIBKjCB4wYHKoZIzj0CATCB1wIBATAsBgcqhkjOPQEBAiEA/////wAAAAEAAAAAAAAAAAAAAAD///////////////8wWwQg/////wAAAAEAAAAAAAAAAAAAAAD///////////////wEIFrGNdiqOpPns+u9VXaYhrxlHQawzFOw9jvOPD4n0mBLAxUAxJ02CIbnBJNqZnjhE50mt4GffpAEIQNrF9Hy4SxCR/i85uVjpEDydwN9gS3rM6D0oTlF2JjClgIhAP////8AAAAA//////////+85vqtpxeehPO5ysL8YyVRAgEBA0IABNVLQ1xKY80BFMgGXec++Vw7n8vvNrq32PaHuBiYMm0PEj2JoB7qSSWhfgcjxNVJsxqJ6gDQVWgl0r7LH4dr0KU="; - var licenseData = @" - 77d4c193-6088-4c64-9663-ed7398ae8c1a - Trial - Sun, 31 Dec 1899 23:00:00 GMT - 999 - - John Doe - john@doe.tld - - - - MEUCIQCCEDAldOZHHIKvYZRDdzUP4V51y23d6deeK5jIFy27GQIgDz2CndjBh4Vb8tiC3FGQ6fn3GKt8d/P5+luJH0cWv+I= - "; - - var license = License.Load(licenseData); - - var validationResults = license - .Validate() - .Signature(publicKey) - .AssertValidLicense().ToList(); - - Assert.That(validationResults, Is.Not.Null); - Assert.That(validationResults.Count(), Is.EqualTo(1)); - Assert.That(validationResults.FirstOrDefault(), Is.TypeOf()); - } - - [Test] - public void Can_Validate_Expired_ExpirationDate() - { - var publicKey = ""; - var licenseData = @" - 77d4c193-6088-4c64-9663-ed7398ae8c1a - Trial - Sun, 31 Dec 1899 23:00:00 GMT - 1 - - John Doe - john@doe.tld - - - - MEUCIQCCEDAldOZHHIKvYZRDdzUP4V51y23d6deeK5jIFy27GQIgDz2CndjBh4Vb8tiC3FGQ6fn3GKt8d/P5+luJH0cWv+I= - "; - - var license = License.Load(licenseData); - - var validationResults = license - .Validate() - .ExpirationDate() - .AssertValidLicense().ToList(); - - Assert.That(validationResults, Is.Not.Null); - Assert.That(validationResults.Count(), Is.EqualTo(1)); - Assert.That(validationResults.FirstOrDefault(), Is.TypeOf()); - - } - - [Test] - public void Can_Validate_Expired_ExpirationDate_CustomDateTime() - { - var publicKey = ""; - var licenseData = @" - 77d4c193-6088-4c64-9663-ed7398ae8c1a - Trial - Sun, 31 Dec 1899 23:00:00 GMT - 1 - - John Doe - john@doe.tld - - - - MEUCIQCCEDAldOZHHIKvYZRDdzUP4V51y23d6deeK5jIFy27GQIgDz2CndjBh4Vb8tiC3FGQ6fn3GKt8d/P5+luJH0cWv+I= - "; - - var license = License.Load(licenseData); - - var validationResults = license - .Validate() - .ExpirationDate(systemDateTime: new DateTime(1900, 1, 2, 0, 0, 0, DateTimeKind.Utc)) - .AssertValidLicense().ToList(); - - Assert.That(validationResults, Is.Not.Null); - Assert.That(validationResults.Count(), Is.EqualTo(1)); - Assert.That(validationResults.FirstOrDefault(), Is.TypeOf()); - - } - - [Test] - public void Can_Validate_CustomAssertion() - { - var publicKey = @"MIIBKjCB4wYHKoZIzj0CATCB1wIBATAsBgcqhkjOPQEBAiEA/////wAAAAEAAAAAAAAAAAAAAAD///////////////8wWwQg/////wAAAAEAAAAAAAAAAAAAAAD///////////////wEIFrGNdiqOpPns+u9VXaYhrxlHQawzFOw9jvOPD4n0mBLAxUAxJ02CIbnBJNqZnjhE50mt4GffpAEIQNrF9Hy4SxCR/i85uVjpEDydwN9gS3rM6D0oTlF2JjClgIhAP////8AAAAA//////////+85vqtpxeehPO5ysL8YyVRAgEBA0IABNVLQ1xKY80BFMgGXec++Vw7n8vvNrq32PaHuBiYMm0PEj2JoB7qSSWhfgcjxNVJsxqJ6gDQVWgl0r7LH4dr0KU="; - var licenseData = @" - 77d4c193-6088-4c64-9663-ed7398ae8c1a - Trial - Thu, 31 Dec 2009 23:00:00 GMT - 1 - - John Doe - john@doe.tld - - - 123456789 - - - yes - yes - 10000 - - MEUCIQCa6A7Cts5ex4rGHAPxiXpy+2ocZzTDSP7SsddopKUx5QIgHnqv0DjoOpc+K9wALqajxxvmLCRJAywCX5vDAjmWqr8= - "; - - var license = License.Load(licenseData); - - var validationResults = license - .Validate() - .AssertThat(lic => lic.ProductFeatures.Contains("Sales Module"), - new GeneralValidationFailure {Message = "Sales Module not licensed!"}) - .And() - .AssertThat(lic => lic.AdditionalAttributes.Get("Assembly Signature") == "123456789", - new GeneralValidationFailure {Message = "Assembly Signature does not match!"}) - .And() - .Signature(publicKey) - .AssertValidLicense().ToList(); - - Assert.That(validationResults, Is.Not.Null); - Assert.That(validationResults.Count(), Is.EqualTo(0)); - } - - [Test] - public void Do_Not_Crash_On_Invalid_Data() - { - var publicKey = "1234"; - var licenseData = - @"John Doe"; - - var license = License.Load(licenseData); - - var validationResults = license - .Validate() - .ExpirationDate() - .And() - .Signature(publicKey) - .AssertValidLicense().ToList(); - - Assert.That(validationResults, Is.Not.Null); - Assert.That(validationResults.Count(), Is.EqualTo(1)); - Assert.That(validationResults.FirstOrDefault(), Is.TypeOf()); - - } - - [Test] - public void Test_ValidationChainBuilder_ValidationFailure_List() - { - var keyGenerator = Standard.Licensing.Security.Cryptography.KeyGenerator.Create(); - var keyPair = keyGenerator.GenerateKeyPair(); - var publicKey = keyPair.ToPublicKeyString(); - - var invalidLicense = @" - WFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFg= -"; - - var licenseToVerify = License.Load(invalidLicense); - - var validationFailures = licenseToVerify - .Validate() - .Signature(publicKey) - .AssertValidLicense(); - - var count = 0; - foreach (var v in validationFailures) - count++; - - Assert.That(count, Is.EqualTo(1)); - Assert.That(validationFailures.ToArray().Length, Is.EqualTo(1)); - Assert.That(validationFailures.ToArray().Length, Is.EqualTo(1)); - } - } +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using Standard.Licensing.Validation; + +namespace Standard.Licensing.Tests +{ + [TestFixture] + public class LicenseValidationTests + { + private License _expiredLicense; + private License _notExpiredLicense; + + private static readonly DateTime ExpirationUtc = new DateTime(1899, 12, 31, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime NotExpiredExpirationUtc = new DateTime(2100, 12, 31, 0, 0, 0, DateTimeKind.Utc); + + [SetUp] + public void SetUp() + { + var passPhrase = Guid.NewGuid().ToString(); + var keyGenerator = Security.Cryptography.KeyGenerator.Create(); + var keyPair = keyGenerator.GenerateKeyPair(); + var privateKey = keyPair.ToEncryptedPrivateKeyString(passPhrase); + + _expiredLicense = License.New() + .WithUniqueIdentifier(new Guid("77d4c193-6088-4c64-9663-ed7398ae8c1a")) + .As(LicenseType.Trial) + .ExpiresAt(ExpirationUtc) + .WithMaximumUtilization(1) + .LicensedTo("John Doe", "john@doe.tld") + .CreateAndSignWithPrivateKey(privateKey, passPhrase); + + _notExpiredLicense = License.New() + .WithUniqueIdentifier(new Guid("77d4c193-6088-4c64-9663-ed7398ae8c1a")) + .As(LicenseType.Trial) + .ExpiresAt(NotExpiredExpirationUtc) + .WithMaximumUtilization(1) + .LicensedTo("John Doe", "john@doe.tld") + .CreateAndSignWithPrivateKey(privateKey, passPhrase); + } + + [Test] + public void Can_Validate_Valid_Signature() + { + var passPhrase = Guid.NewGuid().ToString(); + var keyGenerator = Security.Cryptography.KeyGenerator.Create(); + var keyPair = keyGenerator.GenerateKeyPair(); + var privateKey = keyPair.ToEncryptedPrivateKeyString(passPhrase); + var publicKey = keyPair.ToPublicKeyString(); + + License license = License.New() + .WithUniqueIdentifier(new Guid("77d4c193-6088-4c64-9663-ed7398ae8c1a")) + .As(LicenseType.Trial) + .ExpiresAt(new DateTime(1899, 12, 31, 0, 0, 0, DateTimeKind.Utc)) + .WithMaximumUtilization(1) + .LicensedTo("John Doe", "john@doe.tld") + .CreateAndSignWithPrivateKey(privateKey, passPhrase); + + var validationResults = license + .Validate() + .Signature(publicKey) + .AssertValidLicense(); + + Assert.That(validationResults, Is.Not.Null); + Assert.That(validationResults.Count(), Is.EqualTo(0)); + } + + [Test] + public void Can_Validate_Invalid_Signature() + { + var passPhrase = Guid.NewGuid().ToString(); + var keyGenerator = Security.Cryptography.KeyGenerator.Create(); + var keyPair = keyGenerator.GenerateKeyPair(); + var privateKey = keyPair.ToEncryptedPrivateKeyString(passPhrase); + var publicKey = keyPair.ToPublicKeyString(); + + License license = License.New() + .WithUniqueIdentifier(new Guid("77d4c193-6088-4c64-9663-ed7398ae8c1a")) + .As(LicenseType.Trial) + .ExpiresAt(new DateTime(1899, 12, 31, 0, 0, 0, DateTimeKind.Utc)) + .WithMaximumUtilization(1) + .LicensedTo("John Doe", "john@doe.tld") + .CreateAndSignWithPrivateKey(privateKey, passPhrase); + + License tamperedLicense = License.Load(license.ToString().Replace("1", "999")); + + var validationResults = tamperedLicense + .Validate() + .Signature(publicKey) + .AssertValidLicense().ToList(); + + Assert.That(validationResults, Is.Not.Null); + Assert.That(validationResults.Count(), Is.EqualTo(1)); + Assert.That(validationResults.FirstOrDefault(), Is.TypeOf()); + } + + [Test] + public void Can_Validate_Expired_ExpirationDate() + { + Assert.That(_expiredLicense.Expiration.Kind, Is.EqualTo(DateTimeKind.Utc)); + + var validationResults = _expiredLicense + .Validate() + .ExpirationDate() + .AssertValidLicense().ToList(); + + Assert.That(validationResults, Is.Not.Null); + Assert.That(validationResults.Count(), Is.EqualTo(1)); + Assert.That(validationResults.FirstOrDefault(), Is.TypeOf()); + } + + [Test] + public void Can_Validate_NotExpired_ExpirationDate() + { + Assert.That(_notExpiredLicense.Expiration.Kind, Is.EqualTo(DateTimeKind.Utc)); + + var validationResults = _notExpiredLicense + .Validate() + .ExpirationDate() + .AssertValidLicense().ToList(); + + Assert.That(validationResults, Is.Not.Null); + Assert.That(validationResults.Count(), Is.EqualTo(0)); + } + + public static IEnumerable LocalAndUtcExpired + { + get + { + var expirationLocalDate = new DateTime(ExpirationUtc.Year, ExpirationUtc.Month, ExpirationUtc.Day, 0, 0, 0, DateTimeKind.Local); + var expirationLocalDateAtOneMinutePastMidnight = expirationLocalDate.AddMinutes(1); + var expirationLocalDateAtThirtyMinutesPastMidnight = expirationLocalDate.AddMinutes(30); + var expirationLocalDateAtNoon = expirationLocalDate.AddHours(12); + var expirationLocalDateTomorrow = expirationLocalDate.AddDays(1); + + yield return new TestCaseData(expirationLocalDate); + yield return new TestCaseData(expirationLocalDateAtOneMinutePastMidnight); + yield return new TestCaseData(expirationLocalDateAtThirtyMinutesPastMidnight); + yield return new TestCaseData(expirationLocalDateAtNoon); + yield return new TestCaseData(expirationLocalDateTomorrow); + + yield return new TestCaseData(expirationLocalDate.ToUniversalTime()); + yield return new TestCaseData(expirationLocalDateAtOneMinutePastMidnight.ToUniversalTime()); + yield return new TestCaseData(expirationLocalDateAtThirtyMinutesPastMidnight.ToUniversalTime()); + yield return new TestCaseData(expirationLocalDateAtNoon.ToUniversalTime()); + yield return new TestCaseData(expirationLocalDateTomorrow.ToUniversalTime()); + } + } + + [Test, TestCaseSource(nameof(LocalAndUtcExpired))] + public void Can_Validate_Expired_ExpirationDate_CustomDateTime(DateTime currentDate) + { + Assert.That(_expiredLicense.Expiration.Kind, Is.EqualTo(DateTimeKind.Utc)); + + var validationResults = _expiredLicense + .Validate() + .ExpirationDate(systemDateTime: currentDate) + .AssertValidLicense().ToList(); + + Assert.That(validationResults, Is.Not.Null); + Assert.That(validationResults.Count(), Is.EqualTo(1)); + Assert.That(validationResults.FirstOrDefault(), Is.TypeOf()); + } + + public static IEnumerable LocalAndUtcNotExpired + { + get + { + var dayBeforeExpirationLocalDate = new DateTime(ExpirationUtc.Year, ExpirationUtc.Month, ExpirationUtc.Day, 0, 0, 0, DateTimeKind.Local).AddDays(-1); + var dayBeforeExpirationLocalDateAtOneMinutePastMidnight = dayBeforeExpirationLocalDate.AddMinutes(1); + var dayBeforeExpirationLocalDateAtThirtyMinutesPastMidnight = dayBeforeExpirationLocalDate.AddMinutes(30); + var dayBeforeExpirationLocalDateAtNoon = dayBeforeExpirationLocalDate.AddHours(12); + var dayBeforeExpirationLocalDateAtThirtyMinutesBeforeMidnight = dayBeforeExpirationLocalDate.AddHours(23).AddMinutes(30); + var dayBeforeExpirationLocalDateAtOneMinuteBeforeMidnight = dayBeforeExpirationLocalDate.AddHours(23).AddMinutes(59); + + yield return new TestCaseData(dayBeforeExpirationLocalDate); + yield return new TestCaseData(dayBeforeExpirationLocalDateAtOneMinutePastMidnight); + yield return new TestCaseData(dayBeforeExpirationLocalDateAtThirtyMinutesPastMidnight); + yield return new TestCaseData(dayBeforeExpirationLocalDateAtNoon); + yield return new TestCaseData(dayBeforeExpirationLocalDateAtThirtyMinutesBeforeMidnight); + yield return new TestCaseData(dayBeforeExpirationLocalDateAtOneMinuteBeforeMidnight); + + yield return new TestCaseData(dayBeforeExpirationLocalDate.ToUniversalTime()); + yield return new TestCaseData(dayBeforeExpirationLocalDateAtOneMinutePastMidnight.ToUniversalTime()); + yield return new TestCaseData(dayBeforeExpirationLocalDateAtThirtyMinutesPastMidnight.ToUniversalTime()); + yield return new TestCaseData(dayBeforeExpirationLocalDateAtNoon.ToUniversalTime()); + yield return new TestCaseData(dayBeforeExpirationLocalDateAtThirtyMinutesBeforeMidnight.ToUniversalTime()); + yield return new TestCaseData(dayBeforeExpirationLocalDateAtOneMinuteBeforeMidnight.ToUniversalTime()); + } + } + + [Test, TestCaseSource(nameof(LocalAndUtcNotExpired))] + public void Can_Validate_NotExpired_ExpirationDate_CustomDateTime(DateTime currentDate) + { + Assert.That(_expiredLicense.Expiration.Kind, Is.EqualTo(DateTimeKind.Utc)); + + var validationResults = _expiredLicense + .Validate() + .ExpirationDate(systemDateTime: currentDate) + .AssertValidLicense().ToList(); + + Assert.That(validationResults, Is.Not.Null); + Assert.That(validationResults.Count(), Is.EqualTo(0)); + } + + [Test] + public void Can_Validate_CustomAssertion() + { + var passPhrase = Guid.NewGuid().ToString(); + var keyGenerator = Security.Cryptography.KeyGenerator.Create(); + var keyPair = keyGenerator.GenerateKeyPair(); + var privateKey = keyPair.ToEncryptedPrivateKeyString(passPhrase); + var publicKey = keyPair.ToPublicKeyString(); + + var license = License.New() + .WithUniqueIdentifier(new Guid("77d4c193-6088-4c64-9663-ed7398ae8c1a")) + .As(LicenseType.Trial) + .ExpiresAt(new DateTime(2009, 12, 31, 23, 0, 0, DateTimeKind.Utc)) + .WithMaximumUtilization(1) + .LicensedTo("John Doe", "john@doe.tld") + .WithAdditionalAttributes(new Dictionary + { + {"Assembly Signature", "123456789"}, + }) + .WithProductFeatures(new Dictionary + { + {"Sales Module", "yes"}, + {"Workflow Module", "yes"}, + {"Maximum Transactions", "10000"}, + }) + .CreateAndSignWithPrivateKey(privateKey, passPhrase); + + var validationResults = license + .Validate() + .AssertThat(lic => lic.ProductFeatures.Contains("Sales Module"), + new GeneralValidationFailure {Message = "Sales Module not licensed!"}) + .And() + .AssertThat(lic => lic.AdditionalAttributes.Get("Assembly Signature") == "123456789", + new GeneralValidationFailure {Message = "Assembly Signature does not match!"}) + .And() + .Signature(publicKey) + .AssertValidLicense().ToList(); + + Assert.That(validationResults, Is.Not.Null); + Assert.That(validationResults.Count(), Is.EqualTo(0)); + } + + [Test] + public void Do_Not_Crash_On_Invalid_Data() + { + var publicKey = "1234"; + var licenseData = + @"John Doe"; + + var license = License.Load(licenseData); + + var validationResults = license + .Validate() + .ExpirationDate() + .And() + .Signature(publicKey) + .AssertValidLicense().ToList(); + + Assert.That(validationResults, Is.Not.Null); + Assert.That(validationResults.Count(), Is.EqualTo(1)); + Assert.That(validationResults.FirstOrDefault(), Is.TypeOf()); + + } + + [Test] + public void Test_ValidationChainBuilder_ValidationFailure_List() + { + var keyGenerator = Standard.Licensing.Security.Cryptography.KeyGenerator.Create(); + var keyPair = keyGenerator.GenerateKeyPair(); + var publicKey = keyPair.ToPublicKeyString(); + + var invalidLicense = @" + WFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFg= +"; + + var licenseToVerify = License.Load(invalidLicense); + + var validationFailures = licenseToVerify + .Validate() + .Signature(publicKey) + .AssertValidLicense(); + + var count = 0; + foreach (var v in validationFailures) + count++; + + Assert.That(count, Is.EqualTo(1)); + Assert.That(validationFailures.ToArray().Length, Is.EqualTo(1)); + Assert.That(validationFailures.ToArray().Length, Is.EqualTo(1)); + } + + [Test] + public void ExpirationDate_IsInvalidStartingAtLocalMidnight_OnStoredExpirationDate() + { + var passPhrase = Guid.NewGuid().ToString(); + var keyGenerator = Security.Cryptography.KeyGenerator.Create(); + var keyPair = keyGenerator.GenerateKeyPair(); + var privateKey = keyPair.ToEncryptedPrivateKeyString(passPhrase); + + var expirationUtc = new DateTime(2030, 6, 1, 0, 0, 0, DateTimeKind.Utc); + var license = License.New() + .WithUniqueIdentifier(new Guid("77d4c193-6088-4c64-9663-ed7398ae8c1a")) + .As(LicenseType.Trial) + .ExpiresAt(expirationUtc) + .WithMaximumUtilization(1) + .LicensedTo("John Doe", "john@doe.tld") + .CreateAndSignWithPrivateKey(privateKey, passPhrase); + + var oneMinuteBeforeLocalMidnight = new DateTime(2030, 5, 31, 23, 59, 0, DateTimeKind.Local); + var atLocalMidnight = new DateTime(2030, 6, 1, 0, 0, 0, DateTimeKind.Local); + + var validationBeforeMidnight = license + .Validate() + .ExpirationDate(systemDateTime: oneMinuteBeforeLocalMidnight) + .AssertValidLicense().ToList(); + + var validationAtMidnight = license + .Validate() + .ExpirationDate(systemDateTime: atLocalMidnight) + .AssertValidLicense().ToList(); + + Assert.That(validationBeforeMidnight, Is.Not.Null); + Assert.That(validationBeforeMidnight.Count(), Is.EqualTo(0)); + + Assert.That(validationAtMidnight, Is.Not.Null); + Assert.That(validationAtMidnight.Count(), Is.EqualTo(1)); + Assert.That(validationAtMidnight.FirstOrDefault(), Is.TypeOf()); + } + } } \ No newline at end of file diff --git a/src/Standard.Licensing.Tests/Standard.Licensing.Tests.csproj b/src/Standard.Licensing.Tests/Standard.Licensing.Tests.csproj index e6909a5..d4cb8d8 100644 --- a/src/Standard.Licensing.Tests/Standard.Licensing.Tests.csproj +++ b/src/Standard.Licensing.Tests/Standard.Licensing.Tests.csproj @@ -1,7 +1,7 @@  - net10.0;net9.0;net8.0;net6.0;net48 + net10.0;net9.0;net8.0;net6.0;net48;net481 enable enable latest diff --git a/src/Standard.Licensing.slnx b/src/Standard.Licensing.slnx index 40ca611..dddc2ad 100644 --- a/src/Standard.Licensing.slnx +++ b/src/Standard.Licensing.slnx @@ -1,4 +1,8 @@ + + + + diff --git a/src/Standard.Licensing/License.cs b/src/Standard.Licensing/License.cs index 397e10e..5646f7b 100644 --- a/src/Standard.Licensing/License.cs +++ b/src/Standard.Licensing/License.cs @@ -1,4 +1,4 @@ -// +// // Copyright © 2012 - 2013 Nauck IT KG http://www.nauck-it.de // // Author: @@ -165,10 +165,17 @@ public LicenseAttributes AdditionalAttributes } /// - /// Gets or sets the expiration date of this . + /// Gets or sets the expiration date in UTC of this . /// Use this property to set the expiration date for a trial license /// or the expiration of support & subscription updates for a standard license. /// + /// + /// When setting this value, only the year/month/day components are used. + /// Time-of-day and are ignored. + /// The stored value is normalized to UTC midnight for that date. + /// + /// The expiration date of the . Only its date component is used. + /// The expiration date of the as UTC midnight. public DateTime Expiration { get @@ -176,10 +183,17 @@ public DateTime Expiration return DateTime.ParseExact( GetTag("Expiration") ?? - DateTime.MaxValue.ToUniversalTime().ToString("r", CultureInfo.InvariantCulture) - , "r", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); + DateTime.MaxValue.ToString("r", CultureInfo.InvariantCulture) + , "r", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal); + } + set + { + if (!IsSigned) + { + DateTime normalizedUtcDate = new DateTime(value.Year, value.Month, value.Day, 0, 0, 0, DateTimeKind.Utc); + SetTag("Expiration", normalizedUtcDate.ToString("r", CultureInfo.InvariantCulture)); + } } - set { if (!IsSigned) SetTag("Expiration", value.ToUniversalTime().ToString("r", CultureInfo.InvariantCulture)); } } /// diff --git a/src/Standard.Licensing/LicenseBuilder.cs b/src/Standard.Licensing/LicenseBuilder.cs index 0a2c2d8..8e1a9e7 100644 --- a/src/Standard.Licensing/LicenseBuilder.cs +++ b/src/Standard.Licensing/LicenseBuilder.cs @@ -1,4 +1,4 @@ -// +// // Copyright © 2012 - 2013 Nauck IT KG http://www.nauck-it.de // // Author: @@ -69,11 +69,16 @@ public ILicenseBuilder As(LicenseType type) /// /// Sets the expiration date of the . /// - /// The expiration date of the . + /// + /// Only the year/month/day components are used. + /// Time-of-day and are ignored. + /// The value is normalized to UTC midnight for that date. + /// + /// The expiration date of the . Only its date component is used. /// The . public ILicenseBuilder ExpiresAt(DateTime date) { - license.Expiration = date.ToUniversalTime(); + license.Expiration = new DateTime(date.Year, date.Month, date.Day, 0, 0, 0, DateTimeKind.Utc); return this; } diff --git a/src/Standard.Licensing/Standard.Licensing.csproj b/src/Standard.Licensing/Standard.Licensing.csproj index ef2bebd..2e2c220 100644 --- a/src/Standard.Licensing/Standard.Licensing.csproj +++ b/src/Standard.Licensing/Standard.Licensing.csproj @@ -1,36 +1,40 @@  - net10.0;net9.0;net8.0;net6.0;netstandard2.0;net461 + net10.0;net9.0;net8.0;net6.0;netstandard2.0;net461;net481 Standard.Licensing Standard.Licensing - Junian Triajianto + Junian Triajianto; Stefan K.S. Tucker en - junian + junian; 12noon LLC https://www.junian.dev/Standard.Licensing/ - v1.2.2 - - - Add .NET 10.0 Support - - v1.2.1 - - - Add .NET 9.0 Support + v1.3.0 - v1.2.0 + - Update ExpirationDate to use only the date part, ignoring the time component, to prevent unexpected expiration issues due to time zone differences or time component discrepancies. - - Update to the latest BouncyCastle + v1.2.2 - v1.1.9 + - Add .NET 10.0 Support - - Add custom DateTime to check Expiration date + v1.2.1 + + - Add .NET 9.0 Support + + v1.2.0 + + - Update to the latest BouncyCastle + + v1.1.9 + + - Add custom DateTime to check Expiration date Easy-to-use licensing library for .NET and .NET Framework products. portable,licensing,key - Standard.Licensing + Standard.Licensing.12noon Easy-to-use licensing library for .NET and .NET Framework products. - Standard.Licensing + Standard.Licensing.12noon Copyright (c) 2018 - 2026 - https://github.com/junian/Standard.Licensing + https://github.com/12noonLLC/Standard.Licensing git true Standard.Licensing.snk @@ -42,10 +46,12 @@ True - 1.2.2.0 + 1.3.0.0 $(AssemblyVersion) $(AssemblyVersion) $(AssemblyVersion) + True + snupkg diff --git a/src/Standard.Licensing/Validation/LicenseValidationExtensions.cs b/src/Standard.Licensing/Validation/LicenseValidationExtensions.cs index f65cdbe..870afaf 100644 --- a/src/Standard.Licensing/Validation/LicenseValidationExtensions.cs +++ b/src/Standard.Licensing/Validation/LicenseValidationExtensions.cs @@ -59,14 +59,22 @@ public static IValidationChain ExpirationDate(this IStartValidationChain validat /// /// Validates if the license has been expired. /// + /// + /// This comparison uses calendar dates only. + /// The stored expiration value and system date are compared as values. + /// /// The current . - /// The System DateTime to compare to, default is DateTime.Now. Can be changed to NTP / other internet API times. + /// The system DateTime to compare to in local time or UTC, default is DateTime.Now. Can be changed to NTP / other internet API times. /// An instance of . public static IValidationChain ExpirationDate(this IStartValidationChain validationChain, DateTime systemDateTime) { var validationChainBuilder = (validationChain as ValidationChainBuilder); var validator = validationChainBuilder.StartValidatorChain(); - validator.Validate = license => license.Expiration > systemDateTime; +#if NET6_0_OR_GREATER + validator.Validate = license => DateOnly.FromDateTime(license.Expiration) > DateOnly.FromDateTime(systemDateTime.ToLocalTime()); +#else + validator.Validate = license => license.Expiration.Date > systemDateTime.ToLocalTime().Date; +#endif validator.FailureResult = new LicenseExpiredValidationFailure() {