Skip to content

feat: verify offline licenses locally (verify_offline_license) - #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

keymint.verify_offline_license verifies a signed offline license (the .lic file from the dashboard or sign_key, or the bare token) locally, with no network call.

verify_offline_license(
    license: str, *, public_keys: Sequence[str], product_id: str, machine_code: str,
    now: datetime | None = None, clock_skew: float = 60,
) -> OfflineLicense
  • OfflineLicense (frozen dataclass): license_key, product_id, machine_code, key_id, issued_at (datetime | None), expires_at (datetime), times timezone-aware UTC. license_key is left out of the repr so it doesn't end up in logs.
  • Failures raise OfflineLicenseError with .code, an OfflineLicenseErrorCode (str Enum, so err.code == "expired" works): malformed, unsupported_algorithm, unknown_key, bad_signature, wrong_type, wrong_product, wrong_machine, expired. Messages never contain the license key.
  • Bad arguments raise ValueError (no keys, a PEM that isn't an Ed25519 public key, including RSA keys and private keys, empty product_id or machine_code, clock_skew outside 0..3600 or NaN, a naive now) or TypeError (wrong types, e.g. a single PEM string instead of a list, a bool clock_skew).
  • A naive now is rejected, not treated as UTC: datetime.now() is naive local time, so guessing would shift expiry by the UTC offset.

Follows the shared spec

It follows the cross-SDK offline-verifier spec and its TypeScript reference implementation step for step: the nine ordered checks, the reason codes and the argument rules. It passes all 34 shared test vectors, which are copied unchanged into tests/fixtures/offline-license-fixtures.json. Python-specific handling:

  • Each segment is checked against [A-Za-z0-9_-]* with fullmatch before decoding (re.match with $ would let a trailing newline through).
  • JSON true/false are not numbers: isinstance(True, int) is true in Python, so exp/iat exclude bool.
  • NaN/Infinity literals, which Python's json accepts but JSON doesn't, and nesting deep enough to raise RecursionError are both malformed.
  • An exp outside the datetime range is malformed.
  • Input is trimmed with the same whitespace set as JavaScript's trim(), which includes a BOM.

product_id is required (L-04)

One signing key covers every product in an org, and the token has no iss/aud. A verifier that skipped the product check would let a license for a cheap product unlock an expensive one, so product_id is required, not optional.

New dependency: cryptography>=41 (required, not an extra)

pip install keymint should cover offline verification; with an extra, a forgotten install would only fail on the customer's machine. Why the floor is 41:

  • The APIs used (load_pem_public_key, Ed25519PublicKey, public_bytes) have existed for years, so the floor is about coexisting with apps that pin cryptography, not about features.
  • 41.0 (May 2023) is three-plus years old, so it covers the pins apps realistically still carry.
  • It ships cp37-abi3 wheels (Linux, macOS, Windows), so it installs on 3.10 through 3.14 without a Rust toolchain, and its wheels bundle OpenSSL 3.1.
  • Going lower would admit older releases with no real compatibility gain.
  • No upper bound.
  • The full suite was run against both cryptography 41.0.0 and 50.0.2.

Other changes

  • CI runs pytest -q tests instead of only tests/test_contracts.py, so the new tests run.
  • .ruff.toml sets target-version = "py310" to match python_requires. Without it, current ruff assumes 3.11 and suggests datetime.UTC, which is 3.11+.
  • tests/test_offline.py is force-added, because .gitignore ignores test_*.py (as with test_contracts.py).
  • README has an "Offline Licenses" section covering:
    • getting the .lic from the dashboard or sign_key on your backend
    • bundling the public key from Developer → Public Keys
    • verifying with the product ID and machine code
    • handling each error code
    • clock rollback via now = max(system time, last trusted time)
    • why product_id is required
    • the new dependency
  • CHANGELOG has an "Unreleased" entry.
  • No version bump, tag or publish. Python has no live sign test, so this adds no live tests.

Commands run

On Python 3.14 locally (CI uses 3.12), latest ruff 0.17.2:

Command Result
ruff check . All checks passed
pytest -q with cryptography 50.0.2 81 passed (34 fixtures, plus argument-error and edge-case tests)
pytest -q with cryptography 41.0.0 81 passed
python -m build built sdist and wheel; Requires-Dist: cryptography>=41

Add keymint.offline with verify_offline_license, OfflineLicense,
OfflineLicenseError and OfflineLicenseErrorCode. It verifies a .lic file
or bare Ed25519 JWT against trusted SPKI PEM public keys, following the
shared offline-verifier spec: parse, alg EdDSA with no crit, kid match,
signature, type, product, machine, exp/sub shape, expiry with clock skew.

product_id is required (L-04): one signing key covers all of an org's
products, so skipping the product check would let one product's license
unlock another.

Adds cryptography>=41 as a required dependency. Tests run the 34 shared
fixtures plus argument-error and Python-specific edge cases; CI now runs
every test under tests/. README and CHANGELOG updated.
@catsika
catsika merged commit eaf9044 into main Oct 26, 2026
2 checks passed
@catsika
catsika deleted the feat/offline-verifier branch October 26, 2026 02:35
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