Pure-Python ECDSA license tokens for PyArmor outer keys (bind-data), with plan/feature policy helpers.
Project description
licensekit
Pure-Python ECDSA license tokens for PyArmor outer keys (bind-data), with plan/feature policy helpers. Designed to work nicely with PyArmor --outer + --bind-data
current_version = "v0.1.4"
Flow
graph TD
A["1. Vendor generates<br/>keys"] -->|license_signing_private.pem<br/>license_signing_public.pem| B["2. Issue tokens<br/>per customer<br/>can create with multiple products allowed"]
B -->|Token:<br/>payload.signature| C["3. Put token into<br/>PyArmor runtime license key<br/>--bind-data"]
C -->|Ship<br/> license_signing_public.pem<br/> + pyarmor.rkey <br/> separately<br/> which can hold multiple<br/>products in 1 key | F["5b. Customer receives<br/>license key"]
D["4. Obfuscate app<br/>with PyArmor<br/>pyarmor gen --outer"]
D -->|Ship obfuscated app<br/>separately this could have trial period key | E["5a. Customer receives<br/>obfuscated app"]
E --> G["6. App verifies<br/>token signature"]
F --> G
G -->|Extract claims<br/>at runtime| H["7. Enforce plan<br/>& features"]
H -->|Allow/Deny| I["App runs<br/>or blocks"]
style A fill:#e1f5ff
style B fill:#e1f5ff
style C fill:#e1f5ff
style D fill:#fff3e0
style E fill:#fff3e0
style F fill:#fff3e0
style G fill:#f3e5f5
style H fill:#f3e5f5
style I fill:#c8e6c9
This package issues and verifies small signed license tokens using pure-Python ECDSA (ecdsa package).
It is designed to work nicely with PyArmor --outer + --bind-data:
- Vendor generates a signed token (payload JSON + signature).
- Vendor stores that token inside
pyarmor.rkeyusingpyarmor gen key --bind-data. - App reads the token from the runtime key (
__pyarmor__(..., b"keyinfo", 1)), verifies it, and enforces claims.
It also provides:
- Feature flags helper (
ctx.feature("export")) - Plan gating (
ctx.require_plan("pro")) - Optional public-key file loading + SHA-256 fingerprint pinning to reduce key-swap risk.
Vendor workflow
- Install Pip
pip install licensekit
Poetry:
poetry cache clear pypi --all -n
poetry add licensekit
- Generate keys (once)
Using installed CLI:
licensekit-make-keys --out-dir .
This writes:
</absolute/path/to/>/license_signing_private.pem
</absolute/path/to/>/license_signing_public.pem
- license_signing_private.pem (keep secret)
- license_signing_public.pem (safe to ship)
- Issue a token (per customer)
licensekit-issue-license --product my_product --customer customerA --plan pro --days 60 --features export,sync,api --private-key license_signing_private.pem
This prints a token like:
<base64url(payload_json)>.<base64url(signature)>
- Put token into PyArmor runtime key (outer key)
licensekit-make-pyarmor-key --token "PASTE_TOKEN_HERE" --customer customerA --expire-days 60
# outputs: </absolute/path/to/>licenses/customerA/pyarmor.rkey
- LicenseContext in the python Entrypoint
Fingerprint pinning (recommended)
If you load the public key from disk, an attacker could try to replace it with their own. To reduce that risk, pin an allowlist of public-key fingerprints in your app.
To compute fingerprint:
licensekit-show-public-key-fingerprint
Then embed that hex digest string in your app as PINNED = ["..."].
App usage (PyArmor hook / earliest startup) Load public key from file + fingerprint pinning
from licensekit import LicenseContext
print("Started Test.py")
PINNED = ["<sha256 hex fingerprint>"]
print("Getting License Context")
ctx = LicenseContext.from_pyarmor_files(
pubkey_path="license_signing_public.pem",
expected_product="my_product",
require_customer=True,
require_plan=True,
pinned_fingerprints_sha256=PINNED,
search=True,
base_file=__file__,
)
print("Checking Pro plan")
ctx.require_plan("pro")
print("has Pro plan")
if ctx.feature("export"):
print("Export enabled")
Use embedded public key bytes (no file loading)
from licensekit import LicenseContext
PUBLIC_KEY_PEM = b\"\"\"-----BEGIN PUBLIC KEY-----
... contents ...
-----END PUBLIC KEY-----\"\"\"
ctx = LicenseContext.from_pyarmor(
public_key_pem=PUBLIC_KEY_PEM,
expected_product="my_product",
require_customer=True,
require_plan=True,
)
- Obfuscate with PyArmor (outer key required)
pyarmor gen --outer -O dist test.py
Ship dist/ without the key. Provide customer pyarmor.rkey.
Shipping a public key file next to the runtime key
You may also ship:
- license_pub.pem (public key PEM)
- pyarmor.rkey
in the same folder, then load the public key from disk at runtime.
Testing with licensekit
When testing packages that use licensekit, you don't have access to the PyArmor runtime (unless code is obfuscated), so LicenseContext.from_pyarmor_files() will fail.
licensekit provides two approaches to mock LicenseContext during testing:
- Recommended: sys.modules mocking at import time (most reliable)
- Alternative: pytest fixtures (simpler but requires careful setup)
Approach 1: sys.modules Mocking (Recommended)
This approach is the most reliable because it mocks licensekit before any code tries to import it, avoiding PyArmor runtime detection entirely.
Create a conftest.py at your project root (before pytest discovers test files):
# conftest.py (at project root)
"""
Root-level pytest configuration for mocking PyArmor license validation.
This file MUST be at the root of the project so pytest discovers and loads it
BEFORE scanning test directories. This ensures mocks are in place before any
code tries to import licensekit.
"""
from licensekit.testing_utils import install_mocks
# Install mocks at module load time, before pytest discovers any tests
install_mocks()
That's it! Now all your tests will use mocked licensekit without any license validation.
# test_my_feature.py
from myapp import my_licensed_feature
def test_my_feature():
# LicenseContext is mocked at import time, so this works without a real license
result = my_licensed_feature()
assert result is not None
Approach 2: pytest Fixtures (Alternative)
If you prefer the pytest fixtures approach:
# conftest.py
from licensekit import patch_license_context
# The autouse fixture will apply to all tests automatically
This works but is less reliable than Approach 1 because it depends on fixture timing.
How the mocks work
The mocks provide:
- product: "forced_mock_test_product"
- customer: "forced_mock_test_customer"
- plan: "forced_mock_pro_plan"
- features: ["forced_mock_export", "forced_mock_sync", "forced_mock_api"]
All license checks (.require_plan(), .feature(), etc.) return True or succeed without error.
Customizing the mock
If you need different claim values for specific tests, you can create custom mocks:
# conftest.py
from licensekit.testing_utils import create_mock_licensekit_context
import sys
# Create a custom mock with your desired claims
custom_context_module = create_mock_licensekit_context()
class MyCustomMockContext(custom_context_module.LicenseContext):
@staticmethod
def from_pyarmor(*args, **kwargs):
return MyCustomMockContext(payload={
"product": "my_product",
"customer": "test_customer",
"plan": "free", # Test free tier features
"features": ["basic"],
})
# Replace the mock
sys.modules["licensekit.context"].LicenseContext = MyCustomMockContext
Testing obfuscated code
Once your code is obfuscated with PyArmor:
- The PyArmor runtime will be available
- License validation will work normally
- Your production code doesn't need any test-specific logic
This means you can safely:
- Write tests that pass with the mocked
LicenseContext - Obfuscate and ship the code
- License validation works correctly in production
Payload schema (suggested)
Typical payload fields:
product(string) requiredcustomer(string) optional but recommendedplan("free" | "pro" | "enterprise") optional but recommendedissued_at(unix epoch int) recommendedexpires_at(unix epoch int; 0 means no expiry) recommendedfeatures(list of strings) optional
You can add extra claims (like seat counts, feature bundles, etc.) and enforce them in your app.
Development
Install the local environment
python -m venv venv
Windows
venv/scripts/activate
Mac/Linux
source venv/bin/activate
Install the local licensekit project
Install poetry package manager
pip install poetry
Lock poetry dependencies
poetry cache clear pypi --all -n
poetry lock
Install licensekit package via poetry (including dependencies)
poetry install --all-extras
Test
pytest
coverage run -m pytest
coverage report
coverage html
mypy --html-report mypy_report .
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --format=html --htmldir="flake8_report/basic" --exclude=venv
flake8 . --count --exit-zero --max-complexity=11 --max-line-length=127 --statistics --format=html --htmldir="flake8_report/complexity" --exclude=venv
BumpVer
With the CLI command bumpver, you can search for and update version strings in your project files. It has a flexible pattern syntax to support many version schemes (SemVer, CalVer or otherwise).
Run BumbVer with:
bumpver update --major
bumpver update --minor
bumpver update --patch
Build
poetry build
Publish
poetry publish
Automated PyPI Publishing
This project uses GitHub Actions to automatically publish to PyPI when a new version tag is pushed.
Setup (One-time configuration)
- Register a Trusted Publisher on PyPI:
- Go to https://pypi.org/manage/account/publishing/
- Click "Add a new pending publisher"
- Fill in the following details:
- PyPI Project Name:
licensekit - Owner:
systemizing-solutions(your GitHub username) - Repository name:
licensekit - Workflow name:
publish.yml - Environment name:
pypi
- PyPI Project Name:
- Click "Add pending publisher"
How it works
When you use bumpver to update the version:
bumpver update --patch # or --minor, --major
This will:
- Update the version in
pyproject.toml,src/licensekit/__init__.py, andREADME.md - Create a git commit with the version bump
- Create a git tag (e.g.,
4.0.1) - Push the tag to GitHub
GitHub Actions will automatically detect the new tag and:
- Build the distribution packages (wheel and source)
- Publish to PyPI using the trusted publisher authentication
Security
This approach uses OpenID Connect (OIDC) Trusted Publishers, which is more secure than API tokens because:
- ✅ No credentials are stored in GitHub secrets
- ✅ Only this specific workflow can publish
- ✅ Only from this specific repository
- ✅ PyPI automatically verifies the request is legitimate
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file licensekit-0.1.4.tar.gz.
File metadata
- Download URL: licensekit-0.1.4.tar.gz
- Upload date:
- Size: 25.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
03416099203007166cc5e3a3751bbacb31a9103f714912d3b20225a0dc75d82b
|
|
| MD5 |
ee00b167814b66886fe2245432e9ce28
|
|
| BLAKE2b-256 |
356a4ecb2d8e16215ee93368db3b2fbf2522e28e66eafe2a093ddf0504c45c47
|
Provenance
The following attestation bundles were made for licensekit-0.1.4.tar.gz:
Publisher:
publish.yml on systemizing-solutions/licensekit
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
licensekit-0.1.4.tar.gz -
Subject digest:
03416099203007166cc5e3a3751bbacb31a9103f714912d3b20225a0dc75d82b - Sigstore transparency entry: 1310440221
- Sigstore integration time:
-
Permalink:
systemizing-solutions/licensekit@d52302716dc60a523b63c49961e05c0f574b463d -
Branch / Tag:
refs/tags/0.1.4 - Owner: https://github.com/systemizing-solutions
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@d52302716dc60a523b63c49961e05c0f574b463d -
Trigger Event:
push
-
Statement type:
File details
Details for the file licensekit-0.1.4-py3-none-any.whl.
File metadata
- Download URL: licensekit-0.1.4-py3-none-any.whl
- Upload date:
- Size: 27.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9bca42cd2cb2556f947e2ffe07b400af19615ccefe7e50397692976caf9f9abf
|
|
| MD5 |
77319889730b71fd1b783d24efc5eb72
|
|
| BLAKE2b-256 |
c4cdc232ceeefa834bf6ab8ea6f4aa2875be7a12efe24e3ae905a4407f2c993b
|
Provenance
The following attestation bundles were made for licensekit-0.1.4-py3-none-any.whl:
Publisher:
publish.yml on systemizing-solutions/licensekit
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
licensekit-0.1.4-py3-none-any.whl -
Subject digest:
9bca42cd2cb2556f947e2ffe07b400af19615ccefe7e50397692976caf9f9abf - Sigstore transparency entry: 1310440269
- Sigstore integration time:
-
Permalink:
systemizing-solutions/licensekit@d52302716dc60a523b63c49961e05c0f574b463d -
Branch / Tag:
refs/tags/0.1.4 - Owner: https://github.com/systemizing-solutions
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@d52302716dc60a523b63c49961e05c0f574b463d -
Trigger Event:
push
-
Statement type: