Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ The `NeoReports.Abstractions` contract follows SemVer strictly.
since `release.yml` now packs and pushes them with everything else.

### Security
- **`license-tool verify --license <key>`** closes the one gap the D83 guard could not: that the public
key committed to source is the *pair* of the private key in the vault. Nothing checked that — a
mismatched pair compiles, packs, passes CI and publishes, and surfaces as every customer's license
failing at once. Sign a throwaway license with the vaulted key and verify it before tagging a
release; the command runs the same code path a customer's process does. Maintainer tooling, not
shipped.
- **The Pro license-signing key was rotated (ADR D83).** The key the packages shipped with was a
placeholder whose private half had been generated inside a chat session, so it must never have signed
anything real. It never did — no customer license was issued under it — so nothing that exists stops
Expand Down
38 changes: 38 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1662,3 +1662,41 @@ The private key stays out of GitHub entirely — not a secret, not a variable. T
signing key in CI would let anyone who can run a workflow mint permanent licenses, and offline
validation means those could never be revoked. The only secret the release needs remains
`NUGET_API_KEY`.

## D84 — `verify`: proving the key pair before a release (2026-08-08)

D83 added a guard that the embedded key is not the burned placeholder. That catches a *revert*. It
does not catch the other way of getting the key wrong, which is at least as likely on a rotation:
committing a public key that belongs to a **different pair** than the private key in the vault — a
second `keygen` run, a copy from the wrong terminal scrollback.

Nothing detected that. It compiles, packs, passes CI, and publishes. The failure surfaces later, all
at once, as every license the maintainer issues being rejected by every customer — and since D83 a
`v*` tag publishes with no human step and NuGet versions are immutable.

The tool had `keygen` and `sign` but no way to exercise both halves together. `verify --license <key>`
runs `ProLicense.Validate` against the key **embedded in that build** — deliberately the same code
path a customer's process takes, so what it proves is what they will experience. The documented
pre-release ritual is: sign a throwaway one-day license with the vaulted key, verify it, expect
`VALID`.

It takes the license key, never a `.pem`: verification needs only the public half, so there is no
reason for the command to be able to read a private key at all.

**The test pins that it can fail.** A `verify` that reports success regardless would be worse than not
having one — it would launder the exact mistake it exists to catch into a green tick. A freshly
generated pair stands in for a mismatched one, since it is by construction not the embedded pair.
Verified by making `verify` always return 0 — that test, and only that test, fails.

**And the success path is executed too**, which the first cut of this got wrong. It shipped with the
`VALID` branch untested and the gap rationalized as inherent ("the positive path needs the vaulted
key, so CI cannot run it"). The coverage gate rejected that, correctly: the branch the maintainer
depends on before every release would have gone out having never run once, so a null licensee or a bad
format string in it would surface on the single occasion it matters. The body moved behind an internal
seam taking an explicit verifying key — `null` for every call the CLI makes, meaning the embedded key
— so a test can drive the same code with a generated pair.

A `--public-key` **flag** was considered for this and rejected: it would let the pre-release check be
run against the key it was just handed, which checks nothing, and the footgun would sit on the command
whose entire purpose is catching that class of mistake. An internal seam has the same testing benefit
with none of that surface.
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,72 @@ public void Dispose()
}
}

/// <summary>
/// The pre-release key-pair check (ADR D83) is only worth running if it can fail. A `verify` that
/// reports success regardless would be worse than having none — it would launder exactly the
/// mistake it exists to catch (a public key committed that is not the pair of the vaulted private
/// key) into a green tick.
/// </summary>
/// <remarks>
/// A freshly generated pair stands in for a mismatched one: it is, by construction, not the pair
/// of the key embedded in this build. The positive path cannot be tested here — it needs the real
/// private key, which lives in a vault and never touches CI. That asymmetry is the point: this
/// test pins that the command discriminates, and the maintainer runs the positive half by hand.
/// </remarks>
/// <summary>
/// The success path of `verify` — the branch the maintainer actually depends on before tagging a
/// release, and the one that would otherwise ship having never executed. Producing a license that
/// validates against the *embedded* key needs the vaulted private half, which never touches CI, so
/// the test drives the same code with an explicit key pair through the internal seam.
/// </summary>
[Fact]
public void Verify_reports_a_matching_pair_as_valid()
{
string keyPath = PathIn("matching-key.pem");
RunCapturingStdout("keygen", "--out", keyPath).ExitCode.ShouldBe(0);

(int signExit, string licenseKey) = RunCapturingStdout(
"sign", "--key", keyPath, "--licensee", "Acme Corp", "--days", "7");
signExit.ShouldBe(0);

using ECDsa verifyingKey = ECDsa.Create();
verifyingKey.ImportFromPem(File.ReadAllText(keyPath));

using var capture = new StringWriter();
Console.SetOut(capture);
int exitCode;
try
{
exitCode = Cli.VerifyWith(licenseKey.Trim(), verifyingKey);
}
finally
{
Console.SetOut(_originalOut);
}

exitCode.ShouldBe(0);
capture.ToString().ShouldContain("VALID");
// The licensee is echoed back, so a mismatch between what was signed and what verifies is
// visible to the eye and not just to the exit code.
capture.ToString().ShouldContain("Acme Corp");
}

[Fact]
public void Verify_rejects_a_license_signed_by_a_key_that_is_not_the_embedded_pair()
{
string keyPath = PathIn("foreign-key.pem");
RunCapturingStdout("keygen", "--out", keyPath).ExitCode.ShouldBe(0);

(int signExit, string licenseKey) = RunCapturingStdout(
"sign", "--key", keyPath, "--licensee", "Someone Else", "--days", "1");
signExit.ShouldBe(0);

(int verifyExit, string stdout) = RunCapturingStdout("verify", "--license", licenseKey.Trim());

verifyExit.ShouldNotBe(0, "a non-zero exit is what makes this usable as a release gate");
stdout.ShouldNotContain("VALID");
}

[Fact]
public void Keygen_writes_a_private_key_and_prints_the_public_half()
{
Expand Down
64 changes: 64 additions & 0 deletions tools/NeoReports.LicenseTool/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public static int Run(string[] args)
{
"keygen" => KeyGen(args),
"sign" => Sign(args),
"verify" => Verify(args),
_ => Unknown(args[0]),
};
}
Expand Down Expand Up @@ -56,6 +57,12 @@ Writes the PRIVATE key to <private-key.pem> and prints the PUBLIC key to stdout.
sign --key <private-key.pem> --licensee <name> [--days 30] [--from <yyyy-MM-dd>]
Issues a license key signed with the private key. Prints the key to stdout.

verify --license <license-key>
Validates a license key against the public key EMBEDDED in this build, exactly as a
customer's process would. Run this before every release: sign a throwaway license
with the vaulted private key and verify it. Nothing else checks that the key
committed in ProLicense.PublicKeyBase64 is the pair of the key in the vault.

Rotating the signing key invalidates every license already issued under the old one.
""");

Expand Down Expand Up @@ -114,6 +121,63 @@ private static int KeyGen(string[] args)
return null;
}

/// <summary>
/// Validates a license key against the public key <b>embedded in this build</b>
/// (<c>ProLicense.PublicKeyBase64</c>) — deliberately the same code path a customer's process
/// runs, so what it proves is what they will experience.
/// </summary>
/// <remarks>
/// Its real job is closing a gap nothing else covers: that the public key committed to source
/// is the pair of the private key in the vault. A mismatched pair compiles, packs, passes CI
/// and publishes — and then every license ever issued fails for every customer at once, found
/// out by a support ticket. Signing a throwaway license and verifying it here is the only
/// check that exercises both halves together, and it costs seconds.
/// <para>
/// Takes the license key, never the private key: verification needs only the public half, so
/// there is no reason for this command to be able to read a <c>.pem</c> at all.
/// </para>
/// </remarks>
private static int Verify(string[] args) =>
VerifyWith(RequireOption(args, "--license"), verifyingKey: null);

/// <summary>
/// The body of <c>verify</c>. <paramref name="verifyingKey"/> is <c>null</c> for every call
/// the CLI makes, meaning "the key embedded in this build".
/// </summary>
/// <remarks>
/// The parameter exists so the <b>success</b> path can be executed by a test. Producing a
/// license that validates against the embedded key requires the vaulted private half, which
/// never touches CI — so without a seam, the branch the maintainer actually depends on would
/// ship having never run once, and a null licensee or a bad format string in it would surface
/// on the one occasion it matters. The seam is internal and the CLI never reaches it, so the
/// command's meaning ("validated against what we shipped") is unchanged; exposing it as a
/// <c>--public-key</c> flag was rejected for the opposite reason — it would let the pre-release
/// check be run against the key it was just handed, checking nothing.
/// </remarks>
internal static int VerifyWith(string licenseKey, ECDsa? verifyingKey)
{
try
{
LicenseToken token = verifyingKey is null
? ProLicense.Validate(licenseKey)
: LicenseValidator.Validate(licenseKey, verifyingKey);

Console.WriteLine(
$"VALID — issued to \"{token.Licensee}\", " +
$"{token.IssuedAtUtc:yyyy-MM-dd} to {token.ExpiresAtUtc:yyyy-MM-dd}.");
Console.Error.WriteLine("The verifying key is the pair of the key that signed this license.");
return 0;
}
catch (NeoReportsLicenseException ex)
{
// Not rethrown into the generic handler: a failure here is the answer the command was
// asked for, and the reason (SignatureInvalid vs Expired vs Malformed) is the whole
// point — a mismatched key pair reports SignatureInvalid.
Console.Error.WriteLine($"INVALID ({ex.Reason}): {ex.Message}");
return 1;
}
}

private static int Sign(string[] args)
{
string keyPath = RequireOption(args, "--key");
Expand Down
24 changes: 23 additions & 1 deletion tools/NeoReports.LicenseTool/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,29 @@ Three guards back this up, but none of them replaces putting the key in a vault:
world-readable. **On Windows there is no equivalent**: the file inherits its directory's ACL, which
is why the command above targets a user-private directory rather than a shared or repo path.

## 2. Issue a license
## 2. Check the key pair before every release

**Run this before pushing a `vX.Y.Z` tag.** Nothing else verifies that the public key committed to
`ProLicense.PublicKeyBase64` is the pair of the private key in the vault — a mismatch compiles, packs,
passes CI and publishes, and is then discovered by the first customer whose license does not work.
Since D83 a tag publishes to nuget.org with no human step, and NuGet versions are immutable.

Sign a throwaway license with the vaulted key, then verify it against the key this build embeds:

```bash
dotnet run --project tools/NeoReports.LicenseTool -- \
sign --key <path-to-vaulted-key.pem> --licensee "Key pair check" --days 1 > /tmp/check.key

dotnet run --project tools/NeoReports.LicenseTool -- verify --license "$(cat /tmp/check.key)"
```

`VALID` (exit 0) means the two halves match. `INVALID (SignatureInvalid)` means the committed public
key belongs to a **different** pair — stop, and fix the constant before tagging.

`verify` runs the same code path a customer's process does, and takes only the license key: verifying
needs the public half, so the command has no reason to be able to read a `.pem` at all.

## 3. Issue a license

```bash
dotnet run --project tools/NeoReports.LicenseTool -- \
Expand Down