Skip to content

feat: verify offline licenses locally (OfflineLicenseVerifier) - #5

Merged
catsika merged 1 commit into
mainfrom
feat/offline-verifier
Oct 26, 2026
Merged

catsika merged 1 commit into
mainfrom
feat/offline-verifier

Conversation

@catsika

@catsika catsika commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

What this adds

OfflineLicenseVerifier verifies an offline license (the .lic file from SignKey or the dashboard, or the bare token) locally against your public key PEMs, with no network call.

var license = OfflineLicenseVerifier.Verify(licText, new OfflineVerifyOptions
{
    PublicKeys = new[] { publicKeyPem },
    ProductId = productId,     // required
    MachineCode = machineCode, // required
    // Now = ..., ClockSkew = TimeSpan.FromSeconds(60)  (optional; 0 to 1 h)
});
  • Verify(license, options) returns OfflineLicense { LicenseKey, ProductId, MachineCode, KeyId, IssuedAt?, ExpiresAt }, or throws OfflineLicenseException with Code (OfflineLicenseErrorCode) and CodeString (the shared snake_case code, e.g. wrong_product; also Code.ToSpecString()).
  • TryVerify(license, options, out result, out error) returns false instead of throwing for an invalid license.
  • ComputeKeyId(pem) gives the kid Keymint uses for a public key.
  • Bad options (no keys, a key that isn't an Ed25519 SPKI PEM, empty ProductId/MachineCode) throw ArgumentException; ClockSkew outside 0..1 h throws ArgumentOutOfRangeException. Exception messages never contain the license key.

Spec

Implements the shared Keymint offline-verifier spec (the same one the Go, Node and Python SDKs follow): parse, alg exactly EdDSA with no crit, kid must match a trusted key's computed kid, 64-byte Ed25519 signature over the segments as received, then type, productId, machineCode (exact comparisons), exp/sub shape, and now < exp + skew. The first failing check decides the code. No JWT library: the three segments are hand-parsed (segments checked against [A-Za-z0-9_-] before decoding).

ProductId is required because one signing key covers every product in an org and the token has no aud; a verifier that skipped it would let a license for one product unlock another (security review finding L-04).

Passes all 34 shared fixtures (Tests/fixtures/offline-license-fixtures.json, copied unchanged).

New dependency

BouncyCastle.Cryptography 2.7.0 (MIT, managed, about 6 MB) for Ed25519, which .NET 8/9 don't provide. Chosen over NSec, which ships native libsodium per platform. Apps still referencing the old Portable.BouncyCastle and using its types directly may see ambiguous-type compile errors; noted in the CHANGELOG.

Other changes

  • README: new "Offline Licenses" section (getting the .lic, bundling the public key, handling each code, clock rollback via Now = max(system time, last trusted time)).
  • CHANGELOG: "Unreleased" entry. No version bump (that happens at release).
  • Live test: after SignKey, verifies the real file with the test product ID and machine code when KEYMINT_TEST_PUBLIC_KEY is set; skipped otherwise. CI passes KEYMINT_TEST_PUBLIC_KEY: ${{ vars.KEYMINT_TEST_PUBLIC_KEY }} to the live job; the repository variable (the test org's PEM from Developer → Public Keys) still needs to be set.
  • .gitignore ignores *.json, so it now un-ignores Tests/fixtures/*.json.

Testing

  • Fixture [Theory] over all 34 cases, comparing returned fields (times as Unix seconds) or the snake_case error code, through both Verify and TryVerify.
  • Argument-error tests: no keys, garbage PEMs, wrong PEM label, an RSA SPKI PEM, empty/null product and machine, skew of -1 s and 3601 s; plus kid computation, the code-string mapping, and that messages omit the license key.

Built and tested in this PR's GitHub Actions run (the library builds for net8.0 and net9.0 with 0 warnings; the contract suite passes 63/63 on net8.0, including all 34 fixtures) and locally in the mcr.microsoft.com/dotnet/sdk:9.0 container (0 warnings; 63/63 on net9.0). The live job runs only on push to main / manual dispatch, so the live offline check runs after merge once KEYMINT_TEST_PUBLIC_KEY is set.

Add OfflineLicenseVerifier.Verify/TryVerify, which checks a .lic file
(or bare token) from SignKey or the dashboard against trusted Ed25519
public key PEMs with no network call. ProductId and MachineCode are
required and compared exactly, so a license for one product cannot
unlock another product signed by the same org key (L-04).

Follows the shared Keymint offline-verifier spec: nine ordered checks
and eight reason codes, exposed as OfflineLicenseErrorCode plus the
shared snake_case string. Tests run all 34 shared fixtures, argument
errors, and verify the real signed file in the live suite when
KEYMINT_TEST_PUBLIC_KEY is set.

New dependency: BouncyCastle.Cryptography 2.7.0 for Ed25519.
@catsika
catsika merged commit e4ad4f8 into main Oct 26, 2026
2 checks passed
@catsika
catsika deleted the feat/offline-verifier branch October 26, 2026 02:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant