feat(07-license-core): RSA offline license core

Phase 07 complete:
- IMachineFingerprint + ILicenseValidator interfaces
- MachineFingerprint: WMI CPU ProcessorID + BaseBoard SerialNumber
- LicenseValidator: RSACryptoServiceProvider + SHA256, injectable public key
- LicenseFile.TryRead: out-param bool, no exceptions from file I/O
- 6 NUnit tests (valid/wrong-machine/tampered/empty/not-base64/missing-file)
- 118 tests total, 116 pass, 2 Fanuc FOCAS ignored (Windows DLL)
- Fix: missing 'using System' in CncMachineFactoryTests.cs
- Fix: Dockerfile.test updated for NcProgramManager rename

PublicKeyXml = PLACEHOLDER until Phase 09 generates real key pair.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dtrentin 2026-05-17 22:58:14 +02:00
parent a5d73db69a
commit 39a9566858
16 changed files with 851 additions and 42 deletions

View file

@ -13,8 +13,8 @@ Machinists and operators transfer CNC programs to/from any controller without ma
| Attribute | Value |
|-----------|-------|
| Type | Application |
| Version | 0.1.0 |
| Status | v0.1 complete — pending hardware testing |
| Version | 0.2.0-dev |
| Status | v0.2 in progress — Phase 07 complete |
| Last Updated | 2026-05-17 |
## Requirements
@ -36,8 +36,13 @@ Machinists and operators transfer CNC programs to/from any controller without ma
- [x] NC program validation before upload (all 4 manufacturers) — Phase 5
- [x] Project renamed FanucProgramManager → NcProgramManager — Phase 6
### Validated (Shipped) — continued
- [x] RSA offline license core: IMachineFingerprint, ILicenseValidator, LicenseValidator, LicenseFile — Phase 7
### Planned (Next)
- [ ] Hardware fingerprint license system (CPU ID + disk serial + encrypted file)
- [ ] Wire license into Program.cs — replace checkLicense, replace -chiave= with -licfile= (Phase 08)
- [ ] License generator tool — sign fingerprint with RSA private key, write .lic file (Phase 09)
- [ ] Replace PublicKeyXml PLACEHOLDER with real vendor key pair (Phase 09 output)
- [ ] ReadActiveProgramAsync / SelectMainProgram for Heidenhain and Siemens
- [ ] Integration test against real hardware (Heidenhain, Siemens, Mitsubishi)
@ -68,6 +73,8 @@ Machinists and operators transfer CNC programs to/from any controller without ma
| Offline license only | Operators may have no internet on shop floor | 2026-05-13 | Active |
| `#` = Warning in Fanuc/Mitsubishi | Macro variable indicator — valid in context | 2026-05-13 | Active |
| Constructor injection for validator | Internal ctor accepts mock — enables unit testing without Windows DLL | 2026-05-13 | Active |
| RSA asymmetric licensing | Private key vendor-only; public key embedded in binary — decompiler can't forge licenses | 2026-05-17 | Active |
| PublicKeyXml PLACEHOLDER until Phase 09 | Real key pair not yet generated; prevents blocking Phase 07 on Phase 09 | 2026-05-17 | Active |
| Heidenhain LSV2 port 19000 | Standard LSV2 port; needs real hardware verification | 2026-05-13 | Active |
| Siemens FTP dir `/_N_MPF_DIR/` | Sinumerik standard MPF directory; FTP option must be enabled | 2026-05-13 | Active |
@ -76,7 +83,7 @@ Machinists and operators transfer CNC programs to/from any controller without ma
| Metric | Target | Current | Status |
|--------|--------|---------|--------|
| Program transfer correctness | 100% (byte-verified) | Unknown | Pending hardware test |
| License validation (offline) | Works without network | Hardcoded | At risk — v0.2 target |
| License validation (offline) | Works without network | RSA core built, wiring Phase 08 | In progress |
| Multi-CN support | ≥2 manufacturers | 4 (Fanuc/Heidenhain/Siemens/Mitsubishi) | Done |
| NC validation coverage | All manufacturers | 4 manufacturers, 30+ tests | Done |
@ -92,9 +99,9 @@ Machinists and operators transfer CNC programs to/from any controller without ma
| CN Protocol (Mitsubishi) | FTP (FtpWebRequest) | Port 21, `/PRG/`, no extensions |
| Validation | NcProgramValidator | Universal + per-manufacturer rules |
| Encryption | AES (AesCrypt.cs) | Program file protection |
| Licensing | Machine fingerprint | Hardcoded now, v0.2 target |
| Licensing | RSA-SHA256 asymmetric | IMachineFingerprint + ILicenseValidator; wiring in Phase 08 |
| Testing | NUnit 3.13.3 + Moq 4.18.4 | .NET 4.7.2 test project |
---
*PROJECT.md — Updated when requirements or context change*
*Last updated: 2026-05-17 after Phase 6 (Rename Cleanup)*
*Last updated: 2026-05-17 after Phase 7 (License Core)*

View file

@ -85,13 +85,13 @@ Completed: 2026-05-13
## Next Milestone
**v0.2 NcProgramManager** (in progress)
Status: Planning
Phases: 0 of 4 complete
Status: In Progress
Phases: 2 of 4 complete
| Phase | Name | Plans | Status |
|-------|------|-------|--------|
| 06 | rename-cleanup | 1 | ✅ Complete |
| 07 | license-core | 1 | Not started |
| 07 | license-core | 1 | ✅ Complete |
| 08 | license-integration | 1 | Not started |
| 09 | license-generator | 1 | Not started |
@ -100,9 +100,10 @@ Phases: 0 of 4 complete
**Completed:** 2026-05-17
**Plans:** [x] 06-01: Namespace + file rename
### Phase 07: license-core (proposed)
### Phase 07: license-core
**Goal:** `IMachineFingerprint`, `ILicenseValidator`, RSA signature verification, `.lic` file model
**Depends on:** Phase 06
**Completed:** 2026-05-17
**Plans:** [x] 07-01: RSA core + interfaces + 6 unit tests
### Phase 08: license-integration (proposed)
**Goal:** Wire license system into Program.cs, replace `checkLicense`, replace `-chiave=` with `-licfile=`
@ -113,4 +114,4 @@ Phases: 0 of 4 complete
**Depends on:** Phase 07
---
*Roadmap updated: 2026-05-17 — v0.1 milestone complete*
*Roadmap updated: 2026-05-17 — Phase 07 license-core complete*

View file

@ -10,15 +10,15 @@ See: .paul/PROJECT.md (updated 2026-05-17)
## Current Position
Milestone: v0.2 NcProgramManager
Phase: 1 of 4 (06-rename-cleanup) — Complete
Plan: 06-01 unified
Status: Phase 06 complete, ready to plan Phase 07
Last activity: 2026-05-17 — Phase 06 rename-cleanup unified
Phase: 3 of 4 (08-license-integration) — Not started
Plan: Not started
Status: Ready to plan Phase 08
Last activity: 2026-05-17 — Phase 07 complete, transitioned to Phase 08
Progress:
- Milestone v0.1: [██████████] 100% (complete)
- Milestone v0.2: [██░░░░░░░░] 25%
- Phase 06: [██████████] 100%
- Milestone v0.2: [█████░░░░░] 50%
- Phase 08: [░░░░░░░░░░] 0%
## Loop Position
@ -71,27 +71,32 @@ PLAN ──▶ APPLY ──▶ UNIFY
### Decisions
- .NET 4.7.2 locked — max compatibility
- Offline-only license check (AES, unchanged)
- RSA asymmetric offline licensing: private key vendor-only, public key embedded in binary
- PublicKeyXml = PLACEHOLDER until Phase 09 generates real key pair
- `RSACryptoServiceProvider` + `SHA256CryptoServiceProvider` (not HashAlgorithmName) — .NET 4.7.2 compat
- Constructor injection pattern: internal ctor accepts key XML — testable without Windows WMI
- Heidenhain: LSV2 port 19000, needs real hardware testing
- Siemens: FTP `/_N_MPF_DIR/`, needs Sinumerik FTP option enabled on machine
- `#` = Warning in Fanuc/Mitsubishi — macro variable indicator
- Constructor injection for validator: internal ctor accepts mock — testability without Windows DLL
### Deferred Issues
- License system: hardcoded AES key/IV — v0.2 target (hardware fingerprint + encrypted file)
- License wiring into Program.cs — Phase 08 target
- License generator tool — Phase 09 target
- Real RSA key pair — Phase 09 output; copy PublicKeyXml into LicenseValidator.cs
- Heidenhain: `ReadActiveProgramAsync` / `SelectMainProgram` not implemented
- Siemens: same as Heidenhain
- Build + integration not verifiable on Linux (fwlib32 Windows-only)
- MachineFingerprint WMI — code-review verified, needs real Windows hardware test
### Blockers/Concerns
None — code complete, pending hardware testing on real machines.
None — Phase 08 can start.
## Session Continuity
Last session: 2026-05-17
Stopped at: Phase 06 complete — rename-cleanup unified
Next action: /paul:plan for Phase 07 (license-core)
Resume file: .paul/phases/06-rename-cleanup/06-01-SUMMARY.md
Stopped at: Phase 07 complete (UNIFY done)
Next action: /paul:plan for Phase 08 (license-integration)
Resume file: .paul/ROADMAP.md
---
*STATE.md — Updated after every significant action*

View file

@ -1,15 +1,15 @@
{
"name": "FanucProgramManager",
"version": "0.0.0",
"name": "NcProgramManager",
"version": "0.2.0-dev",
"milestone": {
"name": "None",
"version": "0.0.0",
"status": "not_started"
"name": "v0.2 NcProgramManager",
"version": "0.2.0",
"status": "in_progress"
},
"phase": {
"number": 0,
"name": "None",
"status": "not_started"
"number": 7,
"name": "license-core",
"status": "complete"
},
"loop": {
"plan": null,
@ -17,7 +17,7 @@
},
"timestamps": {
"created_at": "2026-05-13T00:00:00Z",
"updated_at": "2026-05-13T00:00:00Z"
"updated_at": "2026-05-17T21:00:00Z"
},
"satellite": {
"groom": true

View file

@ -0,0 +1,441 @@
---
phase: 07-license-core
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- Licensing/IMachineFingerprint.cs
- Licensing/ILicenseValidator.cs
- Licensing/LicenseFile.cs
- Licensing/MachineFingerprint.cs
- Licensing/LicenseValidator.cs
- NcProgramManager.Tests/Unit/LicenseValidatorTests.cs
- NcProgramManager.csproj
- NcProgramManager.Tests/NcProgramManager.Tests.csproj
autonomous: true
---
<objective>
## Goal
Create RSA-based offline license core: machine fingerprint reader, license file model, RSA signature validator — all injectable via interfaces for testability.
## Purpose
Current `checkLicense` in Program.cs uses hardcoded AES key — anyone with source or decompiler can generate licenses for any machine. RSA asymmetric crypto means only the holder of the private key (vendor) can issue valid licenses. Public key is safe to embed in binary.
## Output
- `Licensing/` folder with 5 new files
- `LicenseValidator` verifies RSA-SHA256 signature of machine fingerprint against embedded public key
- `MachineFingerprint` reads CPU ProcessorID + Motherboard SerialNumber via WMI (same data as current `checkLicense`)
- `LicenseFile` reads base64 signature from a `.lic` file on disk
- Unit tests: valid license passes, tampered/wrong-machine license fails
- 5 Compile entries added to main csproj, 1 to tests csproj
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/STATE.md
## Source Files (existing license logic to extract from)
@Program.cs
@NcProgramManager.csproj
</context>
<acceptance_criteria>
## AC-1: Machine fingerprint readable via interface
```gherkin
Given a Windows machine with WMI available
When MachineFingerprint.GetFingerprint() is called
Then it returns a non-empty string concatenating CPU ProcessorID and Motherboard SerialNumber
```
## AC-2: Valid license passes validation
```gherkin
Given a fingerprint string and a license file containing the RSA-SHA256 signature of that fingerprint
When LicenseValidator.Validate(fingerprint, licenseBase64) is called with the matching public key
Then it returns true
```
## AC-3: Invalid license rejected
```gherkin
Given a fingerprint string and a license signed for a different fingerprint (wrong machine)
When LicenseValidator.Validate(fingerprint, licenseBase64) is called
Then it returns false
```
## AC-4: Corrupted/missing license file handled
```gherkin
Given a path to a non-existent or empty .lic file
When LicenseFile.TryRead(path, out string b64) is called
Then it returns false and b64 is null (no exception thrown)
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Create Licensing interfaces and LicenseFile</name>
<files>
Licensing/IMachineFingerprint.cs,
Licensing/ILicenseValidator.cs,
Licensing/LicenseFile.cs
</files>
<action>
Create folder `Licensing/` at project root.
**Licensing/IMachineFingerprint.cs**
```csharp
namespace NcProgramManager.Licensing
{
internal interface IMachineFingerprint
{
string GetFingerprint();
}
}
```
**Licensing/ILicenseValidator.cs**
```csharp
namespace NcProgramManager.Licensing
{
internal interface ILicenseValidator
{
bool Validate(string fingerprint, string licenseBase64);
}
}
```
**Licensing/LicenseFile.cs**
```csharp
using System;
using System.IO;
namespace NcProgramManager.Licensing
{
internal static class LicenseFile
{
internal static bool TryRead(string path, out string base64)
{
base64 = null;
try
{
if (!File.Exists(path))
return false;
string content = File.ReadAllText(path).Trim();
if (string.IsNullOrEmpty(content))
return false;
base64 = content;
return true;
}
catch
{
return false;
}
}
}
}
```
Avoid: throwing exceptions from TryRead — callers expect bool pattern.
</action>
<verify>
```bash
ls Licensing/IMachineFingerprint.cs Licensing/ILicenseValidator.cs Licensing/LicenseFile.cs
grep -n "namespace NcProgramManager.Licensing" Licensing/IMachineFingerprint.cs Licensing/ILicenseValidator.cs Licensing/LicenseFile.cs
```
All three files exist with correct namespace.
</verify>
<done>AC-4 satisfied: LicenseFile.TryRead handles missing/empty file without exception</done>
</task>
<task type="auto">
<name>Task 2: Implement MachineFingerprint and LicenseValidator</name>
<files>
Licensing/MachineFingerprint.cs,
Licensing/LicenseValidator.cs,
NcProgramManager.csproj
</files>
<action>
**Licensing/MachineFingerprint.cs**
Extract the WMI fingerprint logic from `Program.checkLicense`. Do NOT modify Program.cs yet — that is Phase 08.
```csharp
using System.Management;
namespace NcProgramManager.Licensing
{
internal class MachineFingerprint : IMachineFingerprint
{
public string GetFingerprint()
{
string pcInfo = string.Empty;
using (var mc = new ManagementClass("win32_processor"))
using (ManagementObjectCollection moc = mc.GetInstances())
{
foreach (ManagementObject mo in moc)
{
pcInfo = mo.Properties["processorID"].Value.ToString();
break;
}
}
using (var searcher = new ManagementObjectSearcher(
"root\\CIMV2", "SELECT * FROM Win32_BaseBoard"))
{
foreach (ManagementObject obj in searcher.Get())
{
pcInfo += obj["SerialNumber"].ToString();
break;
}
}
return pcInfo;
}
}
}
```
**Licensing/LicenseValidator.cs**
RSA-SHA256 verify. Public key is a placeholder XML constant — Phase 09 (generator) produces the real key pair; replace `PublicKeyXml` at that point.
```csharp
using System;
using System.Security.Cryptography;
using System.Text;
namespace NcProgramManager.Licensing
{
internal class LicenseValidator : ILicenseValidator
{
// Placeholder — replace with real vendor public key XML after Phase 09 generates the key pair.
// Generate with: openssl genrsa 2048 | openssl rsa -pubout, then convert PEM → XML,
// or use RSACryptoServiceProvider.ToXmlString(false) from the generator tool.
private const string PublicKeyXml =
"<RSAKeyValue>" +
"<Modulus>PLACEHOLDER</Modulus>" +
"<Exponent>AQAB</Exponent>" +
"</RSAKeyValue>";
private readonly string _publicKeyXml;
// Public constructor: uses embedded vendor public key.
internal LicenseValidator() : this(PublicKeyXml) { }
// Internal constructor: accepts any public key XML — used in unit tests.
internal LicenseValidator(string publicKeyXml)
{
_publicKeyXml = publicKeyXml;
}
public bool Validate(string fingerprint, string licenseBase64)
{
try
{
byte[] signature = Convert.FromBase64String(licenseBase64);
byte[] data = Encoding.UTF8.GetBytes(fingerprint);
using (var rsa = new RSACryptoServiceProvider())
{
rsa.FromXmlString(_publicKeyXml);
return rsa.VerifyData(data, new SHA256CryptoServiceProvider(), signature);
}
}
catch
{
return false;
}
}
}
}
```
Note: `RSACryptoServiceProvider.VerifyData(byte[], object, byte[])` — the hash algorithm
argument is `new SHA256CryptoServiceProvider()`, not `HashAlgorithmName` (that overload
requires .NET 4.6+ RSA base class; `RSACryptoServiceProvider` overload uses the older
`object` signature which is available in .NET 4.7.2).
**NcProgramManager.csproj** — add 5 Compile entries after last existing `<Compile>` in the
`Licensing` group (create new ItemGroup if none exists):
```xml
<Compile Include="Licensing\IMachineFingerprint.cs" />
<Compile Include="Licensing\ILicenseValidator.cs" />
<Compile Include="Licensing\LicenseFile.cs" />
<Compile Include="Licensing\MachineFingerprint.cs" />
<Compile Include="Licensing\LicenseValidator.cs" />
```
System.Management reference already present — no new reference needed.
</action>
<verify>
```bash
ls Licensing/MachineFingerprint.cs Licensing/LicenseValidator.cs
grep "LicenseValidator\|MachineFingerprint\|LicenseFile\|IMachineFingerprint\|ILicenseValidator" NcProgramManager.csproj | grep "Compile"
```
Both files exist. Five Compile entries present in csproj.
</verify>
<done>AC-1 satisfied: MachineFingerprint implements IMachineFingerprint with WMI logic. AC-2/AC-3 groundwork: LicenseValidator implements ILicenseValidator with RSA verify.</done>
</task>
<task type="auto">
<name>Task 3: Unit tests for LicenseValidator and LicenseFile</name>
<files>
NcProgramManager.Tests/Unit/LicenseValidatorTests.cs,
NcProgramManager.Tests/NcProgramManager.Tests.csproj
</files>
<action>
Tests use the internal `LicenseValidator(string publicKeyXml)` constructor to inject a
test-generated key pair — same pattern as NcProgramValidatorTests.
```csharp
using System;
using System.Security.Cryptography;
using System.Text;
using NUnit.Framework;
using NcProgramManager.Licensing;
namespace NcProgramManager.Tests.Unit
{
[TestFixture]
public class LicenseValidatorTests
{
private string _publicKeyXml;
private string _privateKeyXml;
private const string TestFingerprint = "BFEBFBFF000906EAM80-80001234";
[SetUp]
public void SetUp()
{
using (var rsa = new RSACryptoServiceProvider(2048))
{
_publicKeyXml = rsa.ToXmlString(false); // public only
_privateKeyXml = rsa.ToXmlString(true); // includes private
}
}
private string Sign(string fingerprint)
{
using (var rsa = new RSACryptoServiceProvider())
{
rsa.FromXmlString(_privateKeyXml);
byte[] sig = rsa.SignData(
Encoding.UTF8.GetBytes(fingerprint),
new SHA256CryptoServiceProvider());
return Convert.ToBase64String(sig);
}
}
[Test]
public void Validate_ValidLicense_ReturnsTrue()
{
string licenseB64 = Sign(TestFingerprint);
var validator = new LicenseValidator(_publicKeyXml);
Assert.IsTrue(validator.Validate(TestFingerprint, licenseB64));
}
[Test]
public void Validate_WrongMachineFingerprint_ReturnsFalse()
{
string licenseB64 = Sign("DIFFERENT_MACHINE_ID");
var validator = new LicenseValidator(_publicKeyXml);
Assert.IsFalse(validator.Validate(TestFingerprint, licenseB64));
}
[Test]
public void Validate_TamperedLicense_ReturnsFalse()
{
string licenseB64 = Sign(TestFingerprint);
// Flip one char
char[] chars = licenseB64.ToCharArray();
chars[10] = chars[10] == 'A' ? 'B' : 'A';
var validator = new LicenseValidator(_publicKeyXml);
Assert.IsFalse(validator.Validate(TestFingerprint, new string(chars)));
}
[Test]
public void Validate_EmptyLicense_ReturnsFalse()
{
var validator = new LicenseValidator(_publicKeyXml);
Assert.IsFalse(validator.Validate(TestFingerprint, ""));
}
[Test]
public void Validate_NotBase64_ReturnsFalse()
{
var validator = new LicenseValidator(_publicKeyXml);
Assert.IsFalse(validator.Validate(TestFingerprint, "not-valid-base64!!!"));
}
[Test]
public void LicenseFile_TryRead_NonExistentPath_ReturnsFalse()
{
bool result = LicenseFile.TryRead(@"C:\does\not\exist.lic", out string b64);
Assert.IsFalse(result);
Assert.IsNull(b64);
}
}
}
```
**NcProgramManager.Tests/NcProgramManager.Tests.csproj** — add 1 Compile entry:
```xml
<Compile Include="Unit\LicenseValidatorTests.cs" />
```
Avoid: creating real .lic files on disk in tests — use in-memory signing only.
Note: `InternalsVisibleTo("NcProgramManager.Tests")` already present in AssemblyInfo.cs
from Phase 5 — no change needed.
</action>
<verify>
```bash
ls NcProgramManager.Tests/Unit/LicenseValidatorTests.cs
grep "LicenseValidatorTests" NcProgramManager.Tests/NcProgramManager.Tests.csproj
grep "LicenseValidatorTests\|LicenseFile" NcProgramManager.Tests/Unit/LicenseValidatorTests.cs | head -5
```
File exists. Compile entry present. Class contains both LicenseValidator and LicenseFile tests.
</verify>
<done>AC-2 satisfied: valid license returns true. AC-3 satisfied: wrong machine returns false. AC-4 satisfied: missing file returns false.</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- `Program.cs` — checkLicense stays untouched until Phase 08
- `InputArgs.cs``-chiave=` field stays until Phase 08
- `AesCrypt.cs` — still used by program file encryption, not licensing
- All existing machine implementations (`Cnc/` folder)
- Existing test files
## SCOPE LIMITS
- No wiring into Program.cs (Phase 08)
- No license generator tool (Phase 09)
- No CLI changes (`-licfile=` arg is Phase 08)
- Public key stays as placeholder — real key generated in Phase 09
- `MachineFingerprint` is new class; do NOT delete WMI code from Program.cs yet
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `ls Licensing/*.cs` shows 5 files
- [ ] `grep "Compile" NcProgramManager.csproj | grep Licensing` shows 5 entries
- [ ] `ls NcProgramManager.Tests/Unit/LicenseValidatorTests.cs` exists
- [ ] `grep "LicenseValidatorTests" NcProgramManager.Tests/NcProgramManager.Tests.csproj` finds entry
- [ ] `grep "NcProgramManager.Licensing" Licensing/*.cs` — all files correct namespace
- [ ] All acceptance criteria met
</verification>
<success_criteria>
- All tasks completed
- All verification checks pass
- No changes to Program.cs, InputArgs.cs, or any existing .cs file outside Licensing/
- 6 new files created, 2 csproj files updated
</success_criteria>
<output>
After completion, create `.paul/phases/07-license-core/07-01-SUMMARY.md`
</output>

View file

@ -0,0 +1,148 @@
---
phase: 07-license-core
plan: 01
subsystem: licensing
tags: [rsa, cryptography, wmi, fingerprint, unit-tests, net472]
requires:
- phase: 06-rename-cleanup
provides: NcProgramManager namespace + renamed csproj files
provides:
- IMachineFingerprint interface + MachineFingerprint WMI implementation
- ILicenseValidator interface + LicenseValidator RSA-SHA256 verifier
- LicenseFile.TryRead helper
- 6 unit tests covering valid/invalid/tampered/missing license scenarios
affects:
- 08-license-integration (wires these into Program.cs)
- 09-license-generator (produces real key pair; PublicKeyXml PLACEHOLDER replaced here)
tech-stack:
added: []
patterns:
- Constructor injection for testability (internal ctor accepts public key XML)
- RSACryptoServiceProvider + SHA256CryptoServiceProvider for .NET 4.7.2 compat
key-files:
created:
- Licensing/IMachineFingerprint.cs
- Licensing/ILicenseValidator.cs
- Licensing/LicenseFile.cs
- Licensing/MachineFingerprint.cs
- Licensing/LicenseValidator.cs
- NcProgramManager.Tests/Unit/LicenseValidatorTests.cs
modified:
- NcProgramManager.csproj
- NcProgramManager.Tests/NcProgramManager.Tests.csproj
key-decisions:
- "RSA asymmetric: private key vendor-only, public key embedded in binary"
- "PublicKeyXml is PLACEHOLDER until Phase 09 generates real key pair"
- "Constructor injection: internal LicenseValidator(string publicKeyXml) for test isolation"
- "SHA256CryptoServiceProvider (not HashAlgorithmName) — .NET 4.7.2 RSACryptoServiceProvider overload"
patterns-established:
- "LicenseValidator(string publicKeyXml) internal ctor mirrors NcProgramValidator injection pattern"
- "TryRead out-param bool pattern: no exceptions escape from file I/O"
duration: ~2h (split across sessions)
started: 2026-05-17T00:00:00Z
completed: 2026-05-17T21:00:00Z
---
# Phase 07 Plan 01: license-core Summary
**RSA offline license core shipped: IMachineFingerprint + ILicenseValidator interfaces, WMI fingerprint reader, RSA-SHA256 verifier with injectable key, LicenseFile TryRead helper — 118 tests pass (116 run, 2 Fanuc FOCAS ignored).**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~2h |
| Started | 2026-05-17 |
| Completed | 2026-05-17 |
| Tasks | 3 completed |
| Files modified | 8 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Machine fingerprint readable via interface | Pass | MachineFingerprint : IMachineFingerprint — WMI logic extracted (not testable on Linux; verified via code review) |
| AC-2: Valid license passes validation | Pass | Validate_ValidLicense_ReturnsTrue — in-memory RSA sign+verify |
| AC-3: Invalid license rejected | Pass | Validate_WrongMachineFingerprint_ReturnsFalse, Validate_TamperedLicense_ReturnsFalse |
| AC-4: Corrupted/missing license file handled | Pass | LicenseFile_TryRead_NonExistentPath_ReturnsFalse |
## Accomplishments
- 5 new files in `Licensing/` — interfaces, implementations, file helper — clean DI-ready layer
- 6 LicenseValidator tests all pass in Docker (Mono 6.12 / .NET 4.7.2)
- PublicKeyXml PLACEHOLDER documented — Phase 09 replaces with real key pair
- Full test suite: 118 total, 116 pass, 2 ignored (Fanuc FOCAS, Windows-only DLL, expected)
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `Licensing/IMachineFingerprint.cs` | Created | Interface for fingerprint provider |
| `Licensing/ILicenseValidator.cs` | Created | Interface for RSA validator |
| `Licensing/LicenseFile.cs` | Created | TryRead: reads .lic base64, returns false on missing/empty |
| `Licensing/MachineFingerprint.cs` | Created | WMI: CPU ProcessorID + BaseBoard SerialNumber |
| `Licensing/LicenseValidator.cs` | Created | RSACryptoServiceProvider VerifyData with SHA256 |
| `NcProgramManager.Tests/Unit/LicenseValidatorTests.cs` | Created | 6 NUnit tests; generates ephemeral key pair in SetUp |
| `NcProgramManager.csproj` | Modified | +5 Compile entries for Licensing/*.cs |
| `NcProgramManager.Tests/NcProgramManager.Tests.csproj` | Modified | +1 Compile entry for LicenseValidatorTests.cs |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| RSA asymmetric (not AES) | Private key vendor-only; embedded public key safe to decompile | Phase 09 must generate real 2048-bit key pair |
| `RSACryptoServiceProvider` + `SHA256CryptoServiceProvider` | .NET 4.7.2: `VerifyData(byte[], object, byte[])` overload — `HashAlgorithmName` variant requires RSA base class | Locked to this API; no upgrade path issue |
| PublicKeyXml = PLACEHOLDER | Real key not yet generated; prevents Phase 07 from blocking on Phase 09 | Phase 09 outputs real XML; copy-paste into LicenseValidator.cs |
| Internal constructor injection | Testable without Windows WMI DLL | Same pattern as NcProgramValidator — consistent across codebase |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 1 | Pre-existing bug, zero scope creep |
| Scope additions | 0 | — |
| Deferred | 0 | — |
### Auto-fixed Issues
**1. Build — Missing `using System;` in CncMachineFactoryTests.cs**
- **Found during:** Docker build (msbuild step)
- **Issue:** `InvalidOperationException` unresolved — `using System;` absent from `CncMachineFactoryTests.cs`
- **Fix:** Added `using System;` at top of file
- **Files:** `NcProgramManager.Tests/Unit/CncMachineFactoryTests.cs`
- **Verification:** Docker build passed; all 118 tests ran
**2. Dockerfile.test — Stale FanucProgramManager paths**
- **Found during:** First Docker build attempt
- **Issue:** Dockerfile.test still referenced `FanucProgramManager.*` paths from before Phase 06 rename
- **Fix:** Updated all paths to `NcProgramManager.*`
- **Files:** `Dockerfile.test`
- **Verification:** Docker build and test run succeeded
## Next Phase Readiness
**Ready:**
- `IMachineFingerprint` / `ILicenseValidator` injectable into any caller
- `LicenseValidator` verified correct with real RSA operations
- `LicenseFile.TryRead` ready for `-licfile=` arg wiring
**Concerns:**
- PublicKeyXml is PLACEHOLDER — production build will silently reject all licenses until Phase 09 provides real key and it's copied in
- `MachineFingerprint` uses WMI — only tested via code review on Linux; validate on Windows hardware
**Blockers:**
- None — Phase 08 can proceed
---
*Phase: 07-license-core, Plan: 01*
*Completed: 2026-05-17*

View file

@ -6,16 +6,16 @@ WORKDIR /build
RUN cert-sync /etc/ssl/certs/ca-certificates.crt
# Copy solution + projects first for layer caching
COPY FanucProgramManager.csproj ./
COPY NcProgramManager.csproj ./
COPY packages.config ./
COPY FanucProgramManager.Tests/FanucProgramManager.Tests.csproj ./FanucProgramManager.Tests/
COPY FanucProgramManager.Tests/packages.config ./FanucProgramManager.Tests/
COPY FanucProgramManager.sln ./
COPY NcProgramManager.Tests/NcProgramManager.Tests.csproj ./NcProgramManager.Tests/
COPY NcProgramManager.Tests/packages.config ./NcProgramManager.Tests/
COPY NcProgramManager.sln ./
# Restore all packages (sln now includes test project)
RUN nuget restore FanucProgramManager.sln -NonInteractive
RUN nuget restore NcProgramManager.sln -NonInteractive
# Explicit fallback: restore test packages.config directly to same packages dir
RUN nuget restore FanucProgramManager.Tests/packages.config \
RUN nuget restore NcProgramManager.Tests/packages.config \
-OutputDirectory packages -NonInteractive
# Grab NUnit console runner
RUN nuget install NUnit.ConsoleRunner -Version 3.16.3 -OutputDirectory packages -NonInteractive
@ -27,11 +27,11 @@ COPY . .
RUN ls Cnc/Fanuc/libs/ || true
# Build test project (Debug)
RUN msbuild FanucProgramManager.Tests/FanucProgramManager.Tests.csproj \
RUN msbuild NcProgramManager.Tests/NcProgramManager.Tests.csproj \
/p:Configuration=Debug /p:Platform=AnyCPU \
/v:minimal /nologo
# Run tests — skip Fanuc FOCAS tests (marked [Ignore] — need fwlib32.dll)
CMD mono packages/NUnit.ConsoleRunner.3.16.3/tools/nunit3-console.exe \
FanucProgramManager.Tests/bin/Debug/FanucProgramManager.Tests.dll \
NcProgramManager.Tests/bin/Debug/NcProgramManager.Tests.dll \
--labels=All

View file

@ -0,0 +1,7 @@
namespace NcProgramManager.Licensing
{
internal interface ILicenseValidator
{
bool Validate(string fingerprint, string licenseBase64);
}
}

View file

@ -0,0 +1,7 @@
namespace NcProgramManager.Licensing
{
internal interface IMachineFingerprint
{
string GetFingerprint();
}
}

26
Licensing/LicenseFile.cs Normal file
View file

@ -0,0 +1,26 @@
using System.IO;
namespace NcProgramManager.Licensing
{
internal static class LicenseFile
{
internal static bool TryRead(string path, out string base64)
{
base64 = null;
try
{
if (!File.Exists(path))
return false;
string content = File.ReadAllText(path).Trim();
if (string.IsNullOrEmpty(content))
return false;
base64 = content;
return true;
}
catch
{
return false;
}
}
}
}

View file

@ -0,0 +1,46 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace NcProgramManager.Licensing
{
internal class LicenseValidator : ILicenseValidator
{
// Placeholder — replace with real vendor public key XML after Phase 09 generates the key pair.
// Generate key pair with the LicenseGenerator tool, then copy the public XML here.
private const string PublicKeyXml =
"<RSAKeyValue>" +
"<Modulus>PLACEHOLDER</Modulus>" +
"<Exponent>AQAB</Exponent>" +
"</RSAKeyValue>";
private readonly string _publicKeyXml;
// Uses embedded vendor public key.
internal LicenseValidator() : this(PublicKeyXml) { }
// Accepts any public key XML — used in unit tests.
internal LicenseValidator(string publicKeyXml)
{
_publicKeyXml = publicKeyXml;
}
public bool Validate(string fingerprint, string licenseBase64)
{
try
{
byte[] signature = Convert.FromBase64String(licenseBase64);
byte[] data = Encoding.UTF8.GetBytes(fingerprint);
using (var rsa = new RSACryptoServiceProvider())
{
rsa.FromXmlString(_publicKeyXml);
return rsa.VerifyData(data, new SHA256CryptoServiceProvider(), signature);
}
}
catch
{
return false;
}
}
}
}

View file

@ -0,0 +1,34 @@
using System.Management;
namespace NcProgramManager.Licensing
{
internal class MachineFingerprint : IMachineFingerprint
{
public string GetFingerprint()
{
string pcInfo = string.Empty;
using (var mc = new ManagementClass("win32_processor"))
using (ManagementObjectCollection moc = mc.GetInstances())
{
foreach (ManagementObject mo in moc)
{
pcInfo = mo.Properties["processorID"].Value.ToString();
break;
}
}
using (var searcher = new ManagementObjectSearcher(
"root\\CIMV2", "SELECT * FROM Win32_BaseBoard"))
{
foreach (ManagementObject obj in searcher.Get())
{
pcInfo += obj["SerialNumber"].ToString();
break;
}
}
return pcInfo;
}
}
}

View file

@ -52,6 +52,7 @@
</ProjectReference>
</ItemGroup>
<ItemGroup>
<Compile Include="Unit\LicenseValidatorTests.cs" />
<Compile Include="Unit\NcProgramValidatorTests.cs" />
<Compile Include="Unit\FanucProgramParsingTests.cs" />
<Compile Include="Unit\IsoProgramFormatterTests.cs" />

View file

@ -1,3 +1,4 @@
using System;
using NUnit.Framework;
using NcProgramManager.Cnc;
using NcProgramManager.Cnc.Fanuc;

View file

@ -0,0 +1,80 @@
using System;
using System.Security.Cryptography;
using System.Text;
using NUnit.Framework;
using NcProgramManager.Licensing;
namespace NcProgramManager.Tests.Unit
{
[TestFixture]
internal class LicenseValidatorTests
{
private RSACryptoServiceProvider _rsa;
private string _publicKeyXml;
private LicenseValidator _validator;
[SetUp]
public void SetUp()
{
_rsa = new RSACryptoServiceProvider(2048);
_publicKeyXml = _rsa.ToXmlString(false);
_validator = new LicenseValidator(_publicKeyXml);
}
[TearDown]
public void TearDown()
{
_rsa.Dispose();
}
private string Sign(string fingerprint)
{
byte[] data = Encoding.UTF8.GetBytes(fingerprint);
byte[] sig = _rsa.SignData(data, new SHA256CryptoServiceProvider());
return Convert.ToBase64String(sig);
}
[Test]
public void Validate_ValidLicense_ReturnsTrue()
{
string fingerprint = "CPU123BOARD456";
string license = Sign(fingerprint);
Assert.IsTrue(_validator.Validate(fingerprint, license));
}
[Test]
public void Validate_WrongMachineFingerprint_ReturnsFalse()
{
string license = Sign("CPU123BOARD456");
Assert.IsFalse(_validator.Validate("DIFFERENT_MACHINE", license));
}
[Test]
public void Validate_TamperedLicense_ReturnsFalse()
{
string license = Sign("CPU123BOARD456");
string tampered = license.Substring(0, license.Length - 4) + "AAAA";
Assert.IsFalse(_validator.Validate("CPU123BOARD456", tampered));
}
[Test]
public void Validate_EmptyLicense_ReturnsFalse()
{
Assert.IsFalse(_validator.Validate("CPU123BOARD456", string.Empty));
}
[Test]
public void Validate_NotBase64_ReturnsFalse()
{
Assert.IsFalse(_validator.Validate("CPU123BOARD456", "not-base64!!!"));
}
[Test]
public void LicenseFile_TryRead_NonExistentPath_ReturnsFalse()
{
bool result = LicenseFile.TryRead(@"C:\nonexistent\path\license.lic", out string base64);
Assert.IsFalse(result);
Assert.IsNull(base64);
}
}
}

View file

@ -118,6 +118,11 @@
<Compile Include="Cnc\Mitsubishi\IMitsubishiFtpClient.cs" />
<Compile Include="Cnc\Mitsubishi\MitsubishiFtpClient.cs" />
<Compile Include="Cnc\Mitsubishi\MitsubishiMachine.cs" />
<Compile Include="Licensing\IMachineFingerprint.cs" />
<Compile Include="Licensing\ILicenseValidator.cs" />
<Compile Include="Licensing\LicenseFile.cs" />
<Compile Include="Licensing\MachineFingerprint.cs" />
<Compile Include="Licensing\LicenseValidator.cs" />
</ItemGroup>
<ItemGroup>
<None Include="App.config" />