Skip to content

feat: verify offline licenses locally (verifyOfflineLicense) - #3

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

  • verifyOfflineLicense(license, options): OfflineLicense exported from the package entry (synchronous), plus KeyMint.verifyOfflineLicense as a static alias next to verifyWebhookSignature.
  • Types OfflineVerifyOptions { publicKeys, productId, machineCode, now?: Date, clockSkewSeconds? }, OfflineLicense { licenseKey, productId, machineCode, keyId, issuedAt: Date | null, expiresAt: Date }, OfflineLicenseErrorCode.
  • OfflineLicenseError extends Error with code: malformed, unsupported_algorithm, unknown_key, bad_signature, wrong_type, wrong_product, wrong_machine, expired. Messages never include the license key.
  • Invalid options (no keys, a key that isn't an Ed25519 SPKI public-key PEM, empty productId/machineCode, clockSkewSeconds outside 0..3600, invalid now) throw TypeError/RangeError, not OfflineLicenseError.
  • Only node:crypto (createPublicKey, verify(null, …), createHash). No new dependencies. Base64url is converted by hand rather than with the 'base64url' Buffer encoding, because package.json doesn't declare an engines floor.
  • README "Offline Licenses" section and a CHANGELOG "Unreleased" entry. No version bump.

Spec

Follows the shared Keymint offline-verifier spec: the nine checks run in order and the first failure sets the code. This is a step-for-step port of the server repo's reference implementation. productId is required. One signing key covers every product in an org, so without the product check a license for one product would unlock another (security review finding L-04).

tests/fixtures/offline-license-fixtures.json is the shared vector file, copied unchanged. All 34 cases pass.

Tests

  • tests/offline.test.cjs: one test per fixture (times compared as Unix seconds, or the expected OfflineLicenseError.code). It also covers argument errors (generated RSA key, garbage PEM, Ed25519 private-key PEM, empty product or machine, skew -1/3601/NaN, invalid now), inclusive skew bounds, a check that errors don't contain the key, and the static alias. Added to npm test.
  • tests/live.test.cjs: when KEYMINT_TEST_PUBLIC_KEY is set, it verifies the real /key/sign file with the test product and machine code and checks that another product ID gives wrong_product. It skips with a log line when the variable is unset. CI passes KEYMINT_TEST_PUBLIC_KEY: ${{ vars.KEYMINT_TEST_PUBLIC_KEY }} to the live job; the repository variable still needs to be set.

Commands run (Node 22.23.1)

  • npm run lint: pass
  • npx tsc --noEmit: pass
  • npm run build: pass. dist/index.d.ts exports the function, the error class and the types, and npm pack --dry-run includes dist/offline.{js,d.ts}
  • npm test: 47/47 pass (5 contract tests + 42 offline tests)
  • The live suite wasn't run locally (no credentials). node --check passes on it.

Add verifyOfflineLicense(license, options), also exposed as
KeyMint.verifyOfflineLicense. It checks a signed offline license (.lic
file or bare token) with node:crypto only, following the shared Keymint
offline-verifier spec: parse, EdDSA-only, trusted kid, Ed25519
signature, type, productId, machineCode, claim shape, expiry. productId
is required because one signing key covers every product in an org.

Failures throw OfflineLicenseError with a reason code; invalid options
throw TypeError/RangeError. Error messages never contain the license key.

Tests run the 34 shared test vectors plus argument-error cases. The live
test verifies the real /key/sign output when KEYMINT_TEST_PUBLIC_KEY is
set, wired from a repository variable in CI.
@catsika
catsika merged commit 9f942cf into main Oct 26, 2026
3 checks passed
@catsika
catsika deleted the feat/offline-verifier branch October 26, 2026 02:30
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