Repository navigation
feat: verify offline licenses locally (verify_offline_license) - #3
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
keymint.verify_offline_licenseverifies a signed offline license (the.licfile from the dashboard orsign_key, or the bare token) locally, with no network call.OfflineLicense(frozen dataclass):license_key,product_id,machine_code,key_id,issued_at(datetime | None),expires_at(datetime), times timezone-aware UTC.license_keyis left out of the repr so it doesn't end up in logs.OfflineLicenseErrorwith.code, anOfflineLicenseErrorCode(strEnum, soerr.code == "expired"works):malformed,unsupported_algorithm,unknown_key,bad_signature,wrong_type,wrong_product,wrong_machine,expired. Messages never contain the license key.ValueError(no keys, a PEM that isn't an Ed25519 public key, including RSA keys and private keys, emptyproduct_idormachine_code,clock_skewoutside 0..3600 or NaN, a naivenow) orTypeError(wrong types, e.g. a single PEM string instead of a list, a boolclock_skew).nowis 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:[A-Za-z0-9_-]*withfullmatchbefore decoding (re.matchwith$would let a trailing newline through).true/falseare not numbers:isinstance(True, int)is true in Python, soexp/iatexclude bool.NaN/Infinityliterals, which Python'sjsonaccepts but JSON doesn't, and nesting deep enough to raiseRecursionErrorare bothmalformed.expoutside thedatetimerange ismalformed.trim(), which includes a BOM.product_idis 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, soproduct_idis required, not optional.New dependency:
cryptography>=41(required, not an extra)pip install keymintshould cover offline verification; with an extra, a forgotten install would only fail on the customer's machine. Why the floor is 41:load_pem_public_key,Ed25519PublicKey,public_bytes) have existed for years, so the floor is about coexisting with apps that pincryptography, not about features.cp37-abi3wheels (Linux, macOS, Windows), so it installs on 3.10 through 3.14 without a Rust toolchain, and its wheels bundle OpenSSL 3.1.Other changes
pytest -q testsinstead of onlytests/test_contracts.py, so the new tests run..ruff.tomlsetstarget-version = "py310"to matchpython_requires. Without it, current ruff assumes 3.11 and suggestsdatetime.UTC, which is 3.11+.tests/test_offline.pyis force-added, because.gitignoreignorestest_*.py(as withtest_contracts.py)..licfrom the dashboard orsign_keyon your backendnow = max(system time, last trusted time)product_idis requiredCommands run
On Python 3.14 locally (CI uses 3.12), latest ruff 0.17.2:
ruff check .pytest -qwith cryptography 50.0.2pytest -qwith cryptography 41.0.0python -m buildRequires-Dist: cryptography>=41