From 6d3b5c6be035b2275d9b4096e380c43935b1e8d4 Mon Sep 17 00:00:00 2001 From: catsika Date: Mon, 26 Oct 2026 02:31:14 +0000 Subject: [PATCH] feat: verify offline licenses locally (verify_offline_license) 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. --- .github/workflows/ci-cd.yml | 4 +- .ruff.toml | 3 + CHANGELOG.md | 7 + README.md | 76 +++ keymint/__init__.py | 16 +- keymint/offline.py | 298 ++++++++++++ setup.py | 1 + tests/fixtures/offline-license-fixtures.json | 487 +++++++++++++++++++ tests/test_offline.py | 252 ++++++++++ 9 files changed, 1141 insertions(+), 3 deletions(-) create mode 100644 keymint/offline.py create mode 100644 tests/fixtures/offline-license-fixtures.json create mode 100644 tests/test_offline.py diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 055b5cc..09d1865 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -31,8 +31,8 @@ jobs: - name: Verify Package Build run: python -m build - - name: Run Contract Tests - run: pytest -q tests/test_contracts.py + - name: Run Tests + run: pytest -q tests publish: name: Publish to PyPI diff --git a/.ruff.toml b/.ruff.toml index 8d1cf3c..dc227d5 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -1,2 +1,5 @@ +# Matches python_requires in setup.py. +target-version = "py310" + [lint] ignore = ["F401", "F403", "F405", "F541", "BLE001", "S110", "S112", "PLW1510", "UP035", "RUF013"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 8693668..561c606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- Add `verify_offline_license`, which verifies a signed offline license (the `.lic` file from the dashboard or `sign_key`, or the bare token) locally against your Ed25519 public keys, with no network call. It checks the algorithm, key, signature, license type, product ID, machine code and expiry, returns an `OfflineLicense`, and raises `OfflineLicenseError` with a `code` (`malformed`, `unsupported_algorithm`, `unknown_key`, `bad_signature`, `wrong_type`, `wrong_product`, `wrong_machine`, `expired`) on failure. +- `product_id` is required: one signing key covers every product in an organization, so a verifier that skips the product check would let a license for one product unlock another. +- New required dependency: `cryptography>=41`, for Ed25519. +- CI now runs every test under `tests/`, including the shared offline-license test vectors. + ## 2.6.0 - 2026-10-26 - Add `expirationDate` (ISO 8601 string, or `None` when the license never expires) to the `activate_key` and `floating_checkout` response types. On checkout it is separate from `expiresAt`, the seat lease. Additive; no other fields changed. diff --git a/README.md b/README.md index 76a2223..abebbdf 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ A professional, production-ready SDK for integrating with the Keymint API in Pyt - **Type hints**: Full type hint support for better IDE integration and code safety. - **Comprehensive**: Complete API coverage for all Keymint endpoints. - **Consistent error handling**: All API errors are returned as structured objects or exceptions. +- **Offline licenses**: Verify signed `.lic` files locally with `verify_offline_license`. - **Machine Identity**: Built-in utilities for hardware fingerprinting and stable installation IDs. ## Installation @@ -66,6 +67,81 @@ else: Store the last `expirationDate` you received. An expired license fails activation with the same generic invalid-key error as a wrong key, so the stored date is how your app can tell the user the license expired on that date instead of calling the key invalid. +## Offline Licenses + +An offline license is a `.lic` file holding a token signed with your organization's Ed25519 key. Your app verifies it locally with `verify_offline_license`, with no network call. + +1. **Get the `.lic` file** from the dashboard's Offline License dialog, or with `sign_key` from your own backend (it needs an admin API key, so never call it from the shipped app). The `hostId` you sign for becomes the license's machine code, so use the ID the customer's machine reports, e.g. `identity.get_or_create_installation_id()`. + + ```python + # On your server, with host_id sent by the customer's machine: + result = sdk.sign_key({'productId': product_id, 'licenseKey': license_key, 'hostId': host_id}) + lic = result['file'] # the .lic contents; deliver this to the customer + ``` + +2. **Bundle your public key** with the app: copy the PEM from **Developer → Public Keys** in the dashboard. It is a public key, so shipping it is safe. + +3. **Verify** with the product ID and this machine's code: + + ```python + from keymint import OfflineLicenseError, identity, verify_offline_license + + PUBLIC_KEY = """-----BEGIN PUBLIC KEY----- + MCowBQYDK2VwAyEA... + -----END PUBLIC KEY----- + """ + + with open('license.lic', encoding='utf-8') as f: + lic = f.read() + + try: + offline = verify_offline_license( + lic, + public_keys=[PUBLIC_KEY], + product_id='prod_...', # this app's product ID + machine_code=identity.get_or_create_installation_id(), + ) + print(f'Licensed until {offline.expires_at:%Y-%m-%d}') + except OfflineLicenseError as err: + if err.code == 'expired': + ... # ask the customer to renew or reconnect + elif err.code in ('wrong_product', 'wrong_machine'): + ... # a real license, but not for this product or this machine + else: + ... # malformed, unsupported_algorithm, unknown_key, bad_signature, wrong_type: not a valid license + ``` + +`verify_offline_license` accepts the `.lic` file contents or the bare token and returns an `OfflineLicense` with `license_key`, `product_id`, `machine_code`, `key_id`, `issued_at` (or `None`) and `expires_at` (timezone-aware UTC datetimes). On failure it raises `OfflineLicenseError`; `err.code` is one of: + +| Code | Meaning | +|---|---| +| `malformed` | Not a license file or token, or required claims (`exp`, `sub`) are missing or invalid. | +| `unsupported_algorithm` | The token is not signed with EdDSA (e.g. `none`, `HS256`). | +| `unknown_key` | Signed by a key that isn't in `public_keys`. | +| `bad_signature` | The signature doesn't match: the file was altered or forged. | +| `wrong_type` | The token is not an offline license. | +| `wrong_product` | The license is for a different product. | +| `wrong_machine` | The license is for a different machine. | +| `expired` | Past its expiry, plus `clock_skew` (default 60 seconds, max 3600). | + +Bad arguments (no public keys, a PEM that isn't an Ed25519 public key, an empty `product_id` or `machine_code`, `clock_skew` outside 0..3600, a naive `now`) raise `ValueError` or `TypeError`, not `OfflineLicenseError`. + +**`product_id` is required.** One signing key covers all of an organization's products, so without the product check a license for a cheap product would unlock an expensive one. + +**Clock rollback.** The SDK can't tell if the system clock was set back. To guard against it, store the latest time you have trusted (for example, after each successful run) and pass the later of the two as `now`. `now` must be timezone-aware: + +```python +from datetime import datetime, timezone + +now = max(datetime.now(timezone.utc), last_trusted_time) +offline = verify_offline_license(lic, public_keys=[PUBLIC_KEY], product_id=product_id, + machine_code=machine_code, now=now) +``` + +`public_keys` accepts several keys, so apps are ready for key rotation. An offline license stays valid until it expires; it can't be revoked before then. + +Offline verification uses the [`cryptography`](https://pypi.org/project/cryptography/) package, which `pip install keymint` installs (`cryptography>=41`). + ## Machine Identity Keymint provides utilities to uniquely identify machines for node-locking: diff --git a/keymint/__init__.py b/keymint/__init__.py index e6b9a31..10db559 100644 --- a/keymint/__init__.py +++ b/keymint/__init__.py @@ -1,9 +1,23 @@ import requests from ._version import __version__ +from .offline import ( + OfflineLicense, + OfflineLicenseError, + OfflineLicenseErrorCode, + verify_offline_license, +) from .types import * -__all__ = ['KeyMint', 'KeyMintApiError', '__version__'] +__all__ = [ + 'KeyMint', + 'KeyMintApiError', + 'OfflineLicense', + 'OfflineLicenseError', + 'OfflineLicenseErrorCode', + '__version__', + 'verify_offline_license', +] class KeyMint: def __init__(self, api_key: str, base_url: str = "https://api.keymint.dev"): diff --git a/keymint/offline.py b/keymint/offline.py new file mode 100644 index 0000000..3506e1b --- /dev/null +++ b/keymint/offline.py @@ -0,0 +1,298 @@ +""" +Offline license verification for the Keymint Python SDK. + +Verifies a signed offline license (the ``.lic`` file from the dashboard or +``KeyMint.sign_key``) locally, with no network call. The license is an Ed25519 +JWT signed by your organization's key; you bundle the public key PEM from +Developer -> Public Keys with your app. + +One signing key covers every product in an organization, so the product ID is +a required argument: without it a license for one product would unlock another. +""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import json +import math +import re +from collections.abc import Sequence +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from enum import Enum +from typing import Any + +from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from cryptography.hazmat.primitives.serialization import ( + Encoding, + PublicFormat, + load_pem_public_key, +) + +__all__ = [ + 'OfflineLicense', + 'OfflineLicenseError', + 'OfflineLicenseErrorCode', + 'verify_offline_license', +] + +DEFAULT_CLOCK_SKEW = 60 +MAX_CLOCK_SKEW = 3600 + +# fullmatch, not match: '$' would also accept a trailing newline. +_SEGMENT = re.compile(r'[A-Za-z0-9_-]*') +# The characters JavaScript's String.prototype.trim removes, so every SDK +# accepts the same inputs as the reference implementation. +_WHITESPACE = ( + ' \t\n\v\f\r         ' + '    

   ' +) +_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) + + +class OfflineLicenseErrorCode(str, Enum): + """Why an offline license was rejected. Compares equal to its string value.""" + + MALFORMED = 'malformed' + UNSUPPORTED_ALGORITHM = 'unsupported_algorithm' + UNKNOWN_KEY = 'unknown_key' + BAD_SIGNATURE = 'bad_signature' + WRONG_TYPE = 'wrong_type' + WRONG_PRODUCT = 'wrong_product' + WRONG_MACHINE = 'wrong_machine' + EXPIRED = 'expired' + + def __str__(self) -> str: + return self.value + + +_MESSAGES = { + OfflineLicenseErrorCode.MALFORMED: 'The offline license is not a valid license file or token.', + OfflineLicenseErrorCode.UNSUPPORTED_ALGORITHM: 'The offline license uses an unsupported signing algorithm.', + OfflineLicenseErrorCode.UNKNOWN_KEY: 'The offline license was not signed by a trusted public key.', + OfflineLicenseErrorCode.BAD_SIGNATURE: 'The offline license signature is invalid.', + OfflineLicenseErrorCode.WRONG_TYPE: 'The token is not an offline license.', + OfflineLicenseErrorCode.WRONG_PRODUCT: 'The offline license is for a different product.', + OfflineLicenseErrorCode.WRONG_MACHINE: 'The offline license is for a different machine.', + OfflineLicenseErrorCode.EXPIRED: 'The offline license has expired.', +} + + +class OfflineLicenseError(Exception): + """ + Raised when an offline license fails verification. + + ``code`` is an :class:`OfflineLicenseErrorCode` (a ``str``), e.g. ``"expired"``. + The message never contains the license key. + """ + + def __init__(self, code: OfflineLicenseErrorCode, message: str | None = None): + self.code = code + self.message = message or _MESSAGES[code] + super().__init__(self.message) + + +@dataclass(frozen=True) +class OfflineLicense: + """A verified offline license. Times are timezone-aware UTC datetimes.""" + + license_key: str = field(repr=False) + product_id: str + machine_code: str + key_id: str + issued_at: datetime | None + expires_at: datetime + + +def _key_id(key: Ed25519PublicKey) -> str: + """base64url (no padding) of SHA-256 over the key's SPKI DER, as the server computes it.""" + der = key.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + return base64.urlsafe_b64encode(hashlib.sha256(der).digest()).rstrip(b'=').decode('ascii') + + +def _load_public_key(pem: str, index: int) -> Ed25519PublicKey: + if not isinstance(pem, str): + raise TypeError(f'public_keys[{index}] must be a PEM string') + try: + key = load_pem_public_key(pem.encode('utf-8')) + except (ValueError, TypeError, UnsupportedAlgorithm) as err: + raise ValueError(f'public_keys[{index}] is not a valid PEM public key') from err + if not isinstance(key, Ed25519PublicKey): + # A valid PEM of the wrong key type is a bad value, not a bad type (spec). + raise ValueError(f'public_keys[{index}] is not an Ed25519 public key') # noqa: TRY004 + return key + + +def _b64url_decode(segment: str) -> bytes | None: + try: + return base64.urlsafe_b64decode(segment + '=' * (-len(segment) % 4)) + except (binascii.Error, ValueError): + return None + + +def _reject_constant(name: str) -> Any: + # JSON has no NaN or Infinity; Python's json module accepts them by default. + raise ValueError(f'invalid JSON constant {name}') + + +def _parse_json(text: str) -> Any: + """json.loads, raising ValueError for anything JSON.parse would reject.""" + try: + return json.loads(text, parse_constant=_reject_constant) + except RecursionError as err: # deeply nested input + raise ValueError('JSON nested too deeply') from err + + +def _decode_json_object(segment: str) -> dict[str, Any] | None: + raw = _b64url_decode(segment) + if raw is None: + return None + try: + value = _parse_json(raw.decode('utf-8', errors='replace')) + except ValueError: + return None + return value if isinstance(value, dict) else None + + +def _is_number(value: Any) -> bool: + # bool is a subclass of int in Python; JSON true/false are not numbers. + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _to_datetime(seconds: float) -> datetime | None: + try: + if not math.isfinite(seconds): + return None + return _EPOCH + timedelta(seconds=seconds) + except (OverflowError, ValueError): + return None + + +def verify_offline_license( + license: str, + *, + public_keys: Sequence[str], + product_id: str, + machine_code: str, + now: datetime | None = None, + clock_skew: float = DEFAULT_CLOCK_SKEW, +) -> OfflineLicense: + """ + Verifies an offline license locally and returns its claims. + + :param license: The ``.lic`` file contents (JSON string) or the bare signed token. + :param public_keys: One or more trusted Ed25519 public keys (SPKI PEM strings) + from Developer -> Public Keys. + :param product_id: The product this app is. Must equal the license's product. + :param machine_code: This machine's code. Must equal the license's machine code. + :param now: The time to check expiry against. Must be timezone-aware; naive + datetimes are rejected. Defaults to the system clock. To guard against + clock rollback, pass ``max(system time, last trusted time)``. + :param clock_skew: Seconds of leeway on expiry, 0 to 3600. Defaults to 60. + :returns: The verified :class:`OfflineLicense`. + :raises OfflineLicenseError: The license failed verification; see ``.code``. + :raises ValueError: An argument is invalid (no keys, a key that is not an + Ed25519 public key PEM, an empty product or machine code, a naive ``now``, + or ``clock_skew`` out of range). + :raises TypeError: An argument has the wrong type. + """ + if not isinstance(license, str): + raise TypeError('license must be a string') + if isinstance(public_keys, (str, bytes)): + raise TypeError('public_keys must be a sequence of PEM strings, not a single string') + public_keys = list(public_keys) + if not public_keys: + raise ValueError('public_keys must contain at least one key') + if not isinstance(product_id, str): + raise TypeError('product_id must be a string') + if not product_id: + raise ValueError('product_id is required') + if not isinstance(machine_code, str): + raise TypeError('machine_code must be a string') + if not machine_code: + raise ValueError('machine_code is required') + if not _is_number(clock_skew): + raise TypeError('clock_skew must be a number of seconds') + if not 0 <= clock_skew <= MAX_CLOCK_SKEW: + raise ValueError(f'clock_skew must be between 0 and {MAX_CLOCK_SKEW} seconds') + if now is None: + now = datetime.now(timezone.utc) + elif not isinstance(now, datetime): + raise TypeError('now must be a datetime') + elif now.tzinfo is None or now.utcoffset() is None: + raise ValueError('now must be timezone-aware, e.g. datetime.now(timezone.utc)') + + keys: dict[str, Ed25519PublicKey] = {} + for index, pem in enumerate(public_keys): + key = _load_public_key(pem, index) + keys[_key_id(key)] = key + + # 1. Parse: a .lic file (JSON) or a bare token. + token = license.strip(_WHITESPACE) + if token.startswith('{'): + try: + file = _parse_json(token) + except ValueError: + raise OfflineLicenseError(OfflineLicenseErrorCode.MALFORMED) from None + if not isinstance(file, dict) or not isinstance(file.get('signedKey'), str): + raise OfflineLicenseError(OfflineLicenseErrorCode.MALFORMED) + token = file['signedKey'] + parts = token.split('.') + if len(parts) != 3 or not all(_SEGMENT.fullmatch(part) for part in parts): + raise OfflineLicenseError(OfflineLicenseErrorCode.MALFORMED) + header_b64, payload_b64, signature_b64 = parts + header = _decode_json_object(header_b64) + payload = _decode_json_object(payload_b64) + if header is None or payload is None: + raise OfflineLicenseError(OfflineLicenseErrorCode.MALFORMED) + + # 2. Algorithm: exactly EdDSA, no critical extensions. + if header.get('alg') != 'EdDSA' or 'crit' in header: + raise OfflineLicenseError(OfflineLicenseErrorCode.UNSUPPORTED_ALGORITHM) + + # 3. Key: the kid must name a trusted key. + kid = header.get('kid') + key = keys.get(kid) if isinstance(kid, str) else None + if key is None: + raise OfflineLicenseError(OfflineLicenseErrorCode.UNKNOWN_KEY) + + # 4. Signature over the segments exactly as received. + signature = _b64url_decode(signature_b64) + if signature is None or len(signature) != 64: + raise OfflineLicenseError(OfflineLicenseErrorCode.BAD_SIGNATURE) + try: + key.verify(signature, f'{header_b64}.{payload_b64}'.encode('ascii')) + except InvalidSignature: + raise OfflineLicenseError(OfflineLicenseErrorCode.BAD_SIGNATURE) from None + + # 5-7. Claims, compared exactly (no trimming or case folding). + if payload.get('type') != 'offline': + raise OfflineLicenseError(OfflineLicenseErrorCode.WRONG_TYPE) + if payload.get('productId') != product_id: + raise OfflineLicenseError(OfflineLicenseErrorCode.WRONG_PRODUCT) + if payload.get('machineCode') != machine_code: + raise OfflineLicenseError(OfflineLicenseErrorCode.WRONG_MACHINE) + + # 8. Shape of the remaining claims, then 9. expiry. + exp = payload.get('exp') + sub = payload.get('sub') + iat = payload.get('iat') + expires_at = _to_datetime(exp) if _is_number(exp) else None + if expires_at is None: + raise OfflineLicenseError(OfflineLicenseErrorCode.MALFORMED) + if not isinstance(sub, str) or not sub: + raise OfflineLicenseError(OfflineLicenseErrorCode.MALFORMED) + if not now.timestamp() < exp + clock_skew: + raise OfflineLicenseError(OfflineLicenseErrorCode.EXPIRED) + + return OfflineLicense( + license_key=sub, + product_id=product_id, + machine_code=machine_code, + key_id=kid, + issued_at=_to_datetime(iat) if _is_number(iat) else None, + expires_at=expires_at, + ) diff --git a/setup.py b/setup.py index c504b5a..a868c52 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,7 @@ python_requires='>=3.10', install_requires=[ 'requests>=2.32.4', + 'cryptography>=41', ], extras_require={ 'dev': [ diff --git a/tests/fixtures/offline-license-fixtures.json b/tests/fixtures/offline-license-fixtures.json new file mode 100644 index 0000000..63cf758 --- /dev/null +++ b/tests/fixtures/offline-license-fixtures.json @@ -0,0 +1,487 @@ +{ + "description": "Shared test vectors for Keymint SDK offline-license verifiers. Generated by keymint-codebase scripts/offline-license-fixtures.mjs; spec in docs/internals/offline-sdk-verifiers.md. Keys are throwaway test keys. Times are Unix seconds.", + "defaults": { + "clockSkew": 60 + }, + "publicKeys": [ + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAByGIA6xLMplivs4DVsZ5om7vFAIB5bVwAU/XWDDpvpQ=\n-----END PUBLIC KEY-----\n", + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAKieZSKuGKB563QLn9aj+GIQKfR6MkQMDDzaeKFhgSwU=\n-----END PUBLIC KEY-----\n" + ], + "keyIds": [ + "shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ", + "FWSAs02SsqD6PpW6_N_nYfciRCGARq3htpqYX-Dvb-w" + ], + "cases": [ + { + "name": "valid .lic file", + "input": "{\n \"signedDate\": \"2027-01-14T08:00:00.000Z\",\n \"signedKey\": \"eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg\",\n \"keyId\": \"shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ\",\n \"publicKeyFingerprint\": \"shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ\"\n}", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": true, + "license": { + "licenseKey": "KM-ABCD-1234-EFGH-5678", + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "issuedAt": 1799913600, + "expiresAt": 1802592000, + "keyId": "shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ" + } + } + }, + { + "name": "valid bare token", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": true, + "license": { + "licenseKey": "KM-ABCD-1234-EFGH-5678", + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "issuedAt": 1799913600, + "expiresAt": 1802592000, + "keyId": "shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ" + } + } + }, + { + "name": "valid with the second trusted key", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6IkZXU0FzMDJTc3FENlBwVzZfTl9uWWZjaVJDR0FScTNodHBxWVgtRHZiLXcifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.bmGf_lC-y5DfEKUQtMjm29ytJXCoZQ_hyZzSONlp3IYihRWBAQ4v0x1Lz-AoBMMyZM6URexRwYpu5yXqee0zDg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": true, + "license": { + "licenseKey": "KM-ABCD-1234-EFGH-5678", + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "issuedAt": 1799913600, + "expiresAt": 1802592000, + "keyId": "FWSAs02SsqD6PpW6_N_nYfciRCGARq3htpqYX-Dvb-w" + } + } + }, + { + "name": "valid within clock skew after exp", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1802592030 + }, + "expect": { + "valid": true, + "license": { + "licenseKey": "KM-ABCD-1234-EFGH-5678", + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "issuedAt": 1799913600, + "expiresAt": 1802592000, + "keyId": "shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ" + } + } + }, + { + "name": "expired beyond clock skew", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1802592061 + }, + "expect": { + "valid": false, + "error": "expired" + } + }, + { + "name": "expired exactly at exp + skew", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1802592060 + }, + "expect": { + "valid": false, + "error": "expired" + } + }, + { + "name": "expired with clockSkew 0", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1802592000, + "clockSkew": 0 + }, + "expect": { + "valid": false, + "error": "expired" + } + }, + { + "name": "tampered payload", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kX2V4cGVuc2l2ZSIsIm1hY2hpbmVDb2RlIjoibWFjaGluZS0zZjlhMmM3ZSIsInR5cGUiOiJvZmZsaW5lIiwiaWF0IjoxNzk5OTEzNjAwLCJleHAiOjE4MDI1OTIwMDAsInN1YiI6IktNLUFCQ0QtMTIzNC1FRkdILTU2NzgifQ.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_expensive", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "bad_signature" + } + }, + { + "name": "tampered signature", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yyicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "bad_signature" + } + }, + { + "name": "signature of wrong length", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "bad_signature" + } + }, + { + "name": "signed by an untrusted key", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InBMa2VTM3NINjRsLVM2QXN5U0pjdGZOeHRGb3J0ekVTVncwZVAzWGRyR2cifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.k2WNZjQM3px13jGW7Qdzt7YnbvdG4NSKLZ3uTKUOeAz6FHbbSxxIexFwUcMK8gjzxC8c_SBfvVp5Q7CSqNfFCw", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "unknown_key" + } + }, + { + "name": "untrusted key claiming a trusted kid", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.tIcbaVpKdrnBLRrlrfx6swM3FBuV7P2P7uNm0lPkz00athIirQTSAApnxHTgVYg6SBX0tf3o_kjqcr1emULdBA", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "bad_signature" + } + }, + { + "name": "missing kid", + "input": "eyJhbGciOiJFZERTQSJ9.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.MS4fUklB_83MTvXDYsrzzYD7fZCkK2y_sMuoIx3krmVGa_a8UG10mxeUiAafpuRbBUrqaifbk-aK-Dw7uVDhBg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "unknown_key" + } + }, + { + "name": "alg none", + "input": "eyJhbGciOiJub25lIiwia2lkIjoic2hRbDNiY0pWOHhmOHhORzJGX2FuRnM4MU9BeEhGdXNJYWFWTW82TXJ5USJ9.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "unsupported_algorithm" + } + }, + { + "name": "alg HS256 keyed with the public PEM", + "input": "eyJhbGciOiJIUzI1NiIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.87sbQA6z8sstO_zLc_67lxOQWr4yw8QK3fPNRTCki54", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "unsupported_algorithm" + } + }, + { + "name": "alg in a different case", + "input": "eyJhbGciOiJlZGRzYSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.C2EOw4vDrIHtLI-84HEhxWHS50zQwoW61Pm31pBmO1_6PjjTcJ12mExUwRo7s0NoH2uGKUNy9z9kaE7aVwClBQ", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "unsupported_algorithm" + } + }, + { + "name": "crit header", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEiLCJjcml0IjpbIngtZXh0Il0sIngtZXh0IjoxfQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.5zwJQHfajAjYzu0YaXKweL9PkAcGUgd8PjYgdCU4ZsttT2hwzNQD6H0Z4cap5xLaYlTNrardbDd_TD43MT83Dg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "unsupported_algorithm" + } + }, + { + "name": "wrong type", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9ubGluZSIsImlhdCI6MTc5OTkxMzYwMCwiZXhwIjoxODAyNTkyMDAwLCJzdWIiOiJLTS1BQkNELTEyMzQtRUZHSC01Njc4In0.tMygXZYXEY3-pxFh6vK9S9uqA7_pW2TSzQg4bjOD7i4-Wl2qAkJ4hikbOGS4GHfSPAOgsLyHXP9unftxH41WBg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "wrong_type" + } + }, + { + "name": "missing type", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwiaWF0IjoxNzk5OTEzNjAwLCJleHAiOjE4MDI1OTIwMDAsInN1YiI6IktNLUFCQ0QtMTIzNC1FRkdILTU2NzgifQ.CWBCyUNYB9eVr-sPv-QqDTr5we_qQ-ce-e9PptTdHkKBkEcMHGAbTxNB7TlMllMMMZSEC0WEklKQwY3BnNTiDQ", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "wrong_type" + } + }, + { + "name": "wrong product", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_other", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "wrong_product" + } + }, + { + "name": "product compared exactly (case)", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "PROD_7HQ2LMX9VT4KPZ1AB3CD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "wrong_product" + } + }, + { + "name": "wrong machine", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-other", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "wrong_machine" + } + }, + { + "name": "machine compared exactly (whitespace)", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": " machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "wrong_machine" + } + }, + { + "name": "missing exp", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsInN1YiI6IktNLUFCQ0QtMTIzNC1FRkdILTU2NzgifQ.tucRm_7_BRKjbyv6QxWwTCff0g8i1YY9XmJfN7ihuPEbmrVb4cmjZiHVbVYV1cYbxIwZ7XtkYO18Uzye2cdrBA", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "exp is a string", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6IjE4MDI1OTIwMDAiLCJzdWIiOiJLTS1BQkNELTEyMzQtRUZHSC01Njc4In0._AVqmX5hc2nPsWqI7JJmJ49z5Tj729V8BA44as2sqqZl3tr8_K3IcJTIJfW3CwaM9a_OGjOdBavTdlL8inQ5Cw", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "missing sub", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMH0.c3YxlwPzwME4kTL63bmHVUbOWo-s7-eAT3RSu9Dk5MS9NGSAwltJ3sEXS0bgi2uGeiy36YdTbtfpbr1fdL2rAw", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "payload is not an object", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.WzEsMl0.6V5rHGuWVCPVB9bPRm6OPpH_CYyo8PbGAI_5ff2Ztw3Fz_L8cQRPA4h2l-cS_qVOb8BJE_qoU4P8FSVoNZh8BA", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "two segments", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "four segments", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg.x", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "padded base64", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9=.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "non-base64url characters", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_+/", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": ".lic that is not JSON", + "input": "{not json", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": ".lic without signedKey", + "input": "{\"keyId\":\"shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ\"}", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "empty input", + "input": "", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + } + ] +} diff --git a/tests/test_offline.py b/tests/test_offline.py new file mode 100644 index 0000000..beef3ed --- /dev/null +++ b/tests/test_offline.py @@ -0,0 +1,252 @@ +import base64 +import json +from datetime import datetime, timezone +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric import ed25519, rsa +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + PublicFormat, +) + +from keymint import ( + OfflineLicense, + OfflineLicenseError, + OfflineLicenseErrorCode, + verify_offline_license, +) +from keymint.offline import _key_id, _load_public_key + +FIXTURES = json.loads( + (Path(__file__).parent / "fixtures" / "offline-license-fixtures.json").read_text(encoding="utf-8") +) +PUBLIC_KEYS = FIXTURES["publicKeys"] +VALID_CASE = FIXTURES["cases"][0] +PRODUCT_ID = VALID_CASE["options"]["productId"] +MACHINE_CODE = VALID_CASE["options"]["machineCode"] +NOW = datetime.fromtimestamp(VALID_CASE["options"]["now"], tz=timezone.utc) + + +def unix(value): + return None if value is None else value.timestamp() + + +def verify_case(case): + options = case["options"] + kwargs = { + "public_keys": PUBLIC_KEYS, + "product_id": options["productId"], + "machine_code": options["machineCode"], + "now": datetime.fromtimestamp(options["now"], tz=timezone.utc), + } + if "clockSkew" in options: + kwargs["clock_skew"] = options["clockSkew"] + return verify_offline_license(case["input"], **kwargs) + + +def test_fixture_file_has_all_cases(): + assert len(FIXTURES["cases"]) == 34 + + +def test_key_ids_match_fixtures(): + assert [_key_id(_load_public_key(pem, i)) for i, pem in enumerate(PUBLIC_KEYS)] == FIXTURES["keyIds"] + + +@pytest.mark.parametrize("case", FIXTURES["cases"], ids=[c["name"] for c in FIXTURES["cases"]]) +def test_shared_fixture(case): + expect = case["expect"] + if expect["valid"]: + result = verify_case(case) + expected = expect["license"] + assert isinstance(result, OfflineLicense) + assert result.license_key == expected["licenseKey"] + assert result.product_id == expected["productId"] + assert result.machine_code == expected["machineCode"] + assert result.key_id == expected["keyId"] + assert unix(result.issued_at) == expected["issuedAt"] + assert unix(result.expires_at) == expected["expiresAt"] + assert result.expires_at.tzinfo is timezone.utc + else: + with pytest.raises(OfflineLicenseError) as excinfo: + verify_case(case) + assert excinfo.value.code == expect["error"] + assert isinstance(excinfo.value.code, OfflineLicenseErrorCode) + + +def test_errors_never_contain_the_license_key(): + case = next(c for c in FIXTURES["cases"] if c["name"] == "wrong product") + with pytest.raises(OfflineLicenseError) as excinfo: + verify_case(case) + assert "KM-ABCD" not in str(excinfo.value) + assert "KM-ABCD" not in repr(excinfo.value) + + +def test_repr_hides_the_license_key(): + result = verify_case(VALID_CASE) + assert "KM-ABCD" not in repr(result) + + +def test_accepts_now_in_another_timezone(): + from datetime import timedelta + + tz = timezone(timedelta(hours=-5)) + result = verify_offline_license( + VALID_CASE["input"], public_keys=PUBLIC_KEYS, product_id=PRODUCT_ID, + machine_code=MACHINE_CODE, now=NOW.astimezone(tz), + ) + assert result.license_key == "KM-ABCD-1234-EFGH-5678" + + +# ─── Tokens signed here, for Python-specific edge cases ───────────────── + +def b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def sign(payload_json: str): + private_key = ed25519.Ed25519PrivateKey.generate() + pem = private_key.public_key().public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo).decode() + header = json.dumps({"alg": "EdDSA", "kid": _key_id(private_key.public_key())}) + signing_input = f"{b64url(header.encode())}.{b64url(payload_json.encode())}" + token = f"{signing_input}.{b64url(private_key.sign(signing_input.encode('ascii')))}" + return token, pem + + +def claims(**overrides): + payload = { + "productId": PRODUCT_ID, "machineCode": MACHINE_CODE, "type": "offline", + "iat": 1799913600, "exp": 1802592000, "sub": "KM-TEST", + } + payload.update(overrides) + return json.dumps(payload) + + +def verify_signed(payload_json: str): + token, pem = sign(payload_json) + return verify_offline_license( + token, public_keys=[pem], product_id=PRODUCT_ID, machine_code=MACHINE_CODE, now=NOW, + ) + + +@pytest.mark.parametrize("payload_json", [ + claims(exp=True), + claims(exp=None), + claims(exp="1802592000"), + claims(sub=""), + claims(sub=123), + claims().replace("1802592000", "NaN"), + claims().replace("1802592000", "Infinity"), + claims().replace("1802592000", "1e400"), + claims().replace("1802592000", "1" + "0" * 400), +], ids=["bool", "null", "string", "empty sub", "number sub", "NaN", "Infinity", "huge float", "huge int"]) +def test_bad_exp_or_sub_is_malformed(payload_json): + with pytest.raises(OfflineLicenseError) as excinfo: + verify_signed(payload_json) + assert excinfo.value.code == "malformed" + + +def test_bool_iat_is_not_returned(): + assert verify_signed(claims(iat=True)).issued_at is None + + +def test_missing_iat_is_none(): + payload = json.loads(claims()) + del payload["iat"] + assert verify_signed(json.dumps(payload)).issued_at is None + + +def test_float_exp_is_accepted(): + result = verify_signed(claims(exp=1802592000.5)) + assert unix(result.expires_at) == 1802592000.5 + + +def test_trailing_newline_in_a_segment_is_malformed(): + token, pem = sign(claims()) + header, payload, signature = token.split(".") + with pytest.raises(OfflineLicenseError) as excinfo: + verify_offline_license( + f"{header}\n.{payload}.{signature}", public_keys=[pem], + product_id=PRODUCT_ID, machine_code=MACHINE_CODE, now=NOW, + ) + assert excinfo.value.code == "malformed" + + +@pytest.mark.parametrize("value", [ + '{"signedKey":' + "[" * 100_000, + b64url(b'{"a":' + b"[" * 100_000) + ".e30.", +], ids=[".lic", "token header"]) +def test_deeply_nested_json_is_malformed(value): + with pytest.raises(OfflineLicenseError) as excinfo: + verify_offline_license( + value, public_keys=PUBLIC_KEYS, product_id=PRODUCT_ID, machine_code=MACHINE_CODE, now=NOW, + ) + assert excinfo.value.code == "malformed" + + +# ─── Argument errors ──────────────────────────────────────────────────── + +def rsa_public_pem(): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return key.public_key().public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo).decode() + + +def ed25519_private_pem(): + key = ed25519.Ed25519PrivateKey.generate() + return key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()).decode() + + +def call(**overrides): + kwargs = { + "public_keys": PUBLIC_KEYS, "product_id": PRODUCT_ID, + "machine_code": MACHINE_CODE, "now": NOW, + } + kwargs.update(overrides) + return verify_offline_license(VALID_CASE["input"], **kwargs) + + +@pytest.mark.parametrize("overrides", [ + {"public_keys": []}, + {"public_keys": ["not a pem"]}, + {"public_keys": ["-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----\n"]}, + {"public_keys": [rsa_public_pem()]}, + {"public_keys": [ed25519_private_pem()]}, + {"product_id": ""}, + {"machine_code": ""}, + {"clock_skew": -1}, + {"clock_skew": 3601}, + {"clock_skew": float("nan")}, + {"now": datetime(2027, 1, 15, 8, 0, 0)}, +], ids=[ + "no keys", "garbage pem", "truncated pem", "rsa key", "private key", + "empty product", "empty machine", "negative skew", "skew over an hour", "nan skew", "naive now", +]) +def test_bad_arguments_raise_value_error(overrides): + with pytest.raises(ValueError): + call(**overrides) + + +@pytest.mark.parametrize("overrides", [ + {"public_keys": PUBLIC_KEYS[0]}, + {"public_keys": [b"bytes"]}, + {"product_id": None}, + {"machine_code": 1}, + {"clock_skew": True}, + {"clock_skew": "60"}, + {"now": 1800000000}, +], ids=["single string", "bytes key", "product none", "machine int", "bool skew", "string skew", "int now"]) +def test_wrong_argument_types_raise_type_error(overrides): + with pytest.raises(TypeError): + call(**overrides) + + +def test_clock_skew_bounds_are_inclusive(): + assert call(clock_skew=0).license_key + assert call(clock_skew=3600).license_key + + +def test_rsa_key_error_names_ed25519(): + with pytest.raises(ValueError, match="Ed25519"): + call(public_keys=[rsa_public_pem()])