Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -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.
114 changes: 114 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -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
103 changes: 103 additions & 0 deletions .github/workflows/publish-nuget.yml
Original file line number Diff line number Diff line change
@@ -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
}
59 changes: 52 additions & 7 deletions src/Standard.Licensing.Tests/LicenseSignatureTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//
//
// Copyright © 2012 - 2013 Nauck IT KG http://www.nauck-it.de
//
// Author:
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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<TestCaseData> 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<string, string>
{
{"Sales Module", "yes"},
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<TestCaseData> 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("<License></License>");
licenseViaProperty.Expiration = expirationDate;
Assert.That(licenseViaProperty.Expiration, Is.EqualTo(expectedUtcMidnight));
Assert.That(licenseViaProperty.Expiration.Kind, Is.EqualTo(DateTimeKind.Utc));
}
}
}
Loading