keyvory_client.py is a single-file Python SDK for the Keyvory license server. Drop it into your project alongside pycryptodome, requests, and pynacl.
- Installation
- Quick Start
- KeyvoryClient
- LicenseStatus
- Exceptions
- Encryption Protocol
- Anti-Tamper
- Key Rotation
- Error Handling
- Examples
Copy keyvory_client.py from example/python/ into your project. Install the three dependencies if you haven't already:
pip install pycryptodome requests pynaclThese are already in the server's requirements.txt, so if you are running the server and the client on the same machine you are all set.
from keyvory_client import KeyvoryClient, LicenseStatus, TamperError, NetworkError
with KeyvoryClient(
server_url="https://licenses.yourapp.com",
secret_key="your-secret-key", # same SECRET_KEY as the server .env
) as client:
# 1. Handshake — must be called before anything else
if not client.initialize():
raise RuntimeError("Could not reach license server")
# 2. Validate the user's license key
result: LicenseStatus = client.login("KEYVORY-AAAAA-BBBBB-CCCCC-DDDDD")
if result.is_valid:
print(f"Welcome! Your license expires {result.expiration_date}")
if result.has_feature("dark_mode"):
enable_dark_mode()
else:
print(f"License denied: {result.status}")
sys.exit(1)
# 3. Keep the session alive while your app runs
while app_is_running():
do_work()
client.heartbeat(license_key="KEYVORY-AAAAA-BBBBB-CCCCC-DDDDD")
time.sleep(60)KeyvoryClient(
server_url: str,
secret_key: str,
api_version: str = "v1",
max_retries: int = 3,
timeout: int = 10,
)| Parameter | Type | Default | Description |
|---|---|---|---|
server_url |
str |
required | Base URL of your Keyvory server, e.g. https://licenses.yourapp.com |
secret_key |
str |
required | The SECRET_KEY from your server .env file |
api_version |
str |
"v1" |
API version string. Change only if you have a custom build |
max_retries |
int |
3 |
How many times to retry on ConnectionError or Timeout |
timeout |
int |
10 |
Per-request timeout in seconds |
KeyvoryClient is a context manager. Use it with with to ensure the underlying requests.Session is always closed:
with KeyvoryClient(...) as client:
...
# session is closed here automaticallyYou can also manage the lifecycle manually:
client = KeyvoryClient(...)
try:
...
finally:
client.close()client.initialize() -> boolPerforms the session bootstrap handshake with the server. This must succeed before any other SDK call.
What happens:
- The SDK computes a bootstrap key:
SHA-256("keyvory-bootstrap:{SECRET_KEY}") - Sends a POST to
/api/v1/license/initwith headers:X-Client-ID— AES-encrypted client identifierX-Bootstrap-Key— the bootstrap proof aboveX-Signature— HMAC-SHA256 of"{client_id}|{timestamp}"X-Timestamp,X-Signature-Version,X-Client-Version,X-Device-ID,X-Platform
- Server validates the bootstrap key, decrypts the client ID, verifies the HMAC signature, and rejects duplicates
- Server responds with:
key— AES-encrypted session key (encrypted with the client ID)x25519_private— X25519 private key (encrypted with the session key)server_public_key— Ed25519 verify keyserver_x25519_public— X25519 public keytimestamp— server timestamp (must be within 60s of client time)
- The SDK decrypts and stores the session key and X25519 private key, then marks itself as initialized
Returns True on success, False on any failure (network error, bad secret, clock skew > 60s). Raises TamperError if a debugger is detected.
if not client.initialize():
# wrong URL, wrong secret key, server down, or clock skew
raise RuntimeError("License server unreachable")Calling initialize() on an already-initialized client is a no-op and returns True immediately.
client.login(license_key: str) -> LicenseStatusValidates a license key and binds it to the current machine's hardware fingerprint. On first call with a given key, the server locks the key to this machine (or increments the device count up to max_devices). Subsequent calls from the same machine succeed; calls from a different machine are rejected once the slot is full.
What happens:
- Generates an ephemeral X25519 keypair
- Derives a shared secret from the ephemeral private key + the server's X25519 public key
- Derives AES and MAC keys from the shared secret via HKDF-SHA256
- Encrypts the payload (
license_key+hwid) with AES-256-GCM - Computes HMAC-SHA256 over the ciphertext
- Sends
{"e": ciphertext, "m": mac, "ek": ephemeral_public_key}with auth headers - Server opens the envelope, validates the license, and returns the result in a sealed envelope
- SDK verifies the Ed25519 signature, opens the response envelope, and constructs a
LicenseStatus
| Parameter | Type | Description |
|---|---|---|
license_key |
str |
The license key to validate, e.g. "KEYVORY-XXXXX-XXXXX-XXXXX-XXXXX" |
Returns a LicenseStatus object. Never raises on a denial — check result.is_valid. Raises NetworkError if the server is unreachable after all retries. Raises TamperError if anti-tamper checks fail.
result = client.login("KEYVORY-XXXXX-XXXXX-XXXXX-XXXXX")
if result.is_valid:
print(result.license_type) # "Enterprise"
print(result.expiration_date) # "2027-01-01"
print(result.max_devices) # 5
else:
# result.status is one of:
# "denied" — key invalid or revoked
# "not_initialized" — you forgot to call initialize() first
# "error" — unexpected server error
print("denied:", result.status, result.code)client.get_license_info(license_key: str) -> LicenseStatusFetches license metadata without modifying the HWID lock or device count. Useful for displaying "your license" information in a settings screen.
The caller must already own the license (matching client_id enforced server-side). If the key belongs to a different client, the server returns an error (no information is disclosed to non-owners).
| Response field | Maps to LicenseStatus |
|---|---|
status |
status |
type |
license_type |
application |
application |
expiration |
expiration_date |
features |
features |
days_until_expiry |
features["days_until_expiry"] |
info = client.get_license_info("KEYVORY-XXXXX-XXXXX-XXXXX-XXXXX")
if info.status != "error":
print(f"Type: {info.license_type}")
print(f"Expires: {info.expiration_date}")
print(f"Days left: {info.features.get('days_until_expiry', '?')}")client.heartbeat(license_key: Optional[str] = None) -> boolSends a keepalive ping and optionally re-validates the active license. Call this on a background thread or timer while your application is running.
If license_key is provided, the server re-checks validity (expiration, revocation). If the license has been revoked since the last check, the server responds with {"status": "revoked"} and heartbeat() returns False.
If license_key is omitted, the heartbeat is session-only — useful for keeping the session alive without the overhead of a full license re-check.
Returns True if the server responds with {"status": "ok"}, False otherwise. The SDK auto-rotates the session key every 100 heartbeat calls via _maybe_rotate().
import threading, time
def keep_alive(client, key):
while True:
alive = client.heartbeat(license_key=key)
if not alive:
# license was revoked — take action
disable_app()
break
time.sleep(60)
t = threading.Thread(target=keep_alive, args=(client, license_key), daemon=True)
t.start()client.rotate_key() -> boolRequests a new AES session key from the server. The flow:
- Server generates a new random 256-bit session key
- Server encrypts it with the current session key (
AES-GCM(current_key, new_key)) - Server increments
key_versionand commits to the database - SDK decrypts the new key using the current session key
- SDK swaps in the new key and increments the local
key_version - SDK updates the HMAC state chain
This is called automatically every 100 heartbeats via _maybe_rotate() — you rarely need to call it directly.
if client.rotate_key():
print("Session key rotated")
else:
print("Rotation failed — old key still in use")client.close() -> NoneCloses the underlying requests.Session. Called automatically when used as a context manager.
Returned by login() and get_license_info().
class LicenseStatus:
status: str # "valid", "denied", "error", "not_initialized"
expiration_date: str # ISO-8601 date string, e.g. "2027-01-01"
license_type: str # name of the license type, e.g. "Professional"
application: str # application name the license is scoped to
features: dict # {"dark_mode": True, "export_pdf": False, …}
code: str # short denial code for logging, e.g. "EXPIRED", "REVOKED"
max_devices: int # maximum number of hardware IDs this key allowsProperties:
is_valid -> bool — True when status == "valid". The only field you need to gate access.
if result.is_valid:
unlock_premium_features()has_feature(name: str) -> bool — returns True if features[name] is truthy.
if result.has_feature("unlimited_exports"):
remove_export_cap()from keyvory_client import KeyvoryError, TamperError, NetworkError| Exception | Inherits | Raised when |
|---|---|---|
KeyvoryError |
Exception |
Base class for all SDK exceptions |
TamperError |
KeyvoryError |
Debugger detected, timing anomaly, or session-state integrity violation |
NetworkError |
KeyvoryError |
Server unreachable after all retries |
Denials (wrong key, expired, wrong machine) are not exceptions — they are represented as LicenseStatus objects with is_valid == False. Only infrastructure failures and security violations raise exceptions.
try:
if not client.initialize():
show_error("Cannot reach license server")
sys.exit(1)
result = client.login(key)
if not result.is_valid:
show_error(f"License denied: {result.status}")
sys.exit(1)
except TamperError:
# SDK detected a debugger or in-memory state tampering
show_error("Integrity check failed. Please restart the application.")
sys.exit(1)
except NetworkError as e:
show_error(f"Network error: {e}")
sys.exit(1)The SDK implements a two-layer envelope encryption scheme matching the server.
Client Server
│ │
│ POST /api/v1/license/init │
│ Headers: │
│ X-Client-ID (AES-encrypted) │
│ X-Bootstrap-Key SHA256(...SK) │
│ X-Signature HMAC(client_id|ts)
│ X-Timestamp │
│ ─────────────────────────────────> │
│ │
│ Validate bootstrap key │
│ Decrypt client_id │
│ Verify HMAC signature │
│ Check for duplicate client_id │
│ Generate client_key + X25519 pair │
│ │
│ Response JSON: │
│ key (AES-encrypted client_key)
│ x25519_private (AES-encrypted with client_key)
│ server_public_key (Ed25519)
│ server_x25519_public │
│ timestamp │
│ <───────────────────────────────── │
│ │
│ Decrypt client_key with client_id │
│ Decrypt x25519_private with client_key
│ Store both keys │
Client Server
│ │
│ Generate ephemeral X25519 pair │
│ shared_secret = X25519(eph_priv, server_pub)
│ aes_key, mac_key = HKDF(shared_secret)
│ │
│ ciphertext = AES-GCM(aes_key, payload)
│ mac = HMAC-SHA256(mac_key, ciphertext)
│ │
│ POST with envelope: │
│ {"e": ciphertext, │
│ "m": mac, │
│ "ek": ephemeral_pub} │
│ + auth headers (X-Client-ID, etc) │
│ ─────────────────────────────────> │
│ │
│ Server opens envelope with │
│ X25519(server_priv, eph_pub) │
│ Verifies MAC │
│ Checks nonce + timestamp │
│ Processes request │
│ Seals response + Ed25519 sign │
│ │
│ Response: │
│ {"e": ct, "m": mac, "ek": eph, │
│ "s": ed25519_signature} │
│ <───────────────────────────────── │
│ │
│ Verify Ed25519 signature │
│ Open response envelope │
│ Check timestamp (30s window) │
All keys are derived via HKDF-SHA256:
| Purpose | Salt | Info | Source |
|---|---|---|---|
| Master key (AES encrypt/decrypt) | keyvory-kdf-v1 |
master-key |
SECRET_KEY string |
| AES encryption (envelope) | keyvory-x25519-v1 |
aes-from-x25519 |
X25519 shared secret |
| MAC signing (envelope) | keyvory-x25519-v1 |
mac-from-x25519 |
X25519 shared secret |
Encrypted data is: base64(nonce[12] + ciphertext + tag[16])
The SDK includes several layers of protection:
Module integrity — on import, the SDK hashes its own source file with SHA-256 and stores the hash in _SELF_INTEGRITY. This provides a reference hash that can be checked by external integrity verification tools.
Debugger detection — sys.gettrace() is checked before every sensitive operation (initialize, login, heartbeat, get_license_info). A non-None trace function (attached by pdb, pydevd, or any profiler) raises TamperError.
Timing detection — a fixed SHA-256 computation is timed using time.perf_counter_ns(). If it takes longer than 5ms (a generous threshold that covers slow VMs and Windows), a TamperError is raised. This catches single-step debuggers and emulators.
HMAC state chain — the session key, key version, initialization flag, and X25519 private key are chained into an HMAC-SHA256 computed from the shared secret:
state_mac = HMAC-SHA256(
secret_key,
"{client_key}:{key_version}:{is_initialized}:{x25519_private_key}"
)
Verified before every sensitive call via _verify_state_mac(). Updated after state changes via _update_state_mac(). Any in-memory modification to these fields is detected and raises TamperError.
These checks protect against casual tampering. They are not a substitute for server-side validation — always treat the server as the ground truth.
The AES session key rotates automatically every 100 heartbeat calls via _maybe_rotate(). You do not need to manage this.
If you call heartbeat() infrequently (e.g., once per hour), rotation may never trigger in a short-lived process. That is fine — the session key is strong (256-bit random). Rotation is an extra hardening measure for long-running services.
You can also force a rotation at any time:
if not client.rotate_key():
# rotation failed — the old key is still in use
logger.warning("Key rotation failed")A production integration should handle all three failure modes:
import os, sys, time
from keyvory_client import KeyvoryClient, TamperError, NetworkError
def start_app(license_key: str):
with KeyvoryClient(
server_url=os.environ["KEYVORY_SERVER"],
secret_key=os.environ["KEYVORY_SECRET"],
max_retries=3,
timeout=10,
) as client:
try:
initialized = client.initialize()
except TamperError:
print("ERROR: Integrity check failed.")
sys.exit(1)
except NetworkError as e:
print(f"ERROR: Cannot reach license server: {e}")
sys.exit(1)
if not initialized:
print("ERROR: License server initialization failed. Check URL and secret key.")
sys.exit(1)
result = client.login(license_key)
if not result.is_valid:
print(f"ERROR: License invalid — {result.status} ({result.code})")
sys.exit(1)
print(f"License valid. Expires {result.expiration_date}.")
run_main_loop(client, license_key)
def run_main_loop(client, license_key):
while True:
try:
alive = client.heartbeat(license_key=license_key)
if not alive:
print("License revoked. Shutting down.")
sys.exit(1)
except NetworkError:
# Don't kill the app on a transient network hiccup.
# The SDK already retried 3 times.
print("WARNING: Heartbeat failed. Will retry next cycle.")
time.sleep(60)result = client.login(license_key)
if result.is_valid and result.has_feature("api_access"):
return serve_api_request(request)
else:
return error_403("API access not included in your license.")info = client.get_license_info(license_key)
settings_panel.set({
"license_type": info.license_type,
"expires": info.expiration_date,
"max_devices": info.max_devices,
"features": [k for k, v in info.features.items() if v],
})import threading, time, sys, os
stop_event = threading.Event()
def heartbeat_loop(client, key):
while not stop_event.wait(timeout=60):
try:
if not client.heartbeat(license_key=key):
print("License revoked. Exiting.")
os._exit(1)
except NetworkError:
pass # transient; try again next cycle
thread = threading.Thread(target=heartbeat_loop, args=(client, license_key), daemon=True)
thread.start()
# ... run app ...
stop_event.set()# Manual rotation (rarely needed — happens every 100 heartbeats automatically)
if client.rotate_key():
print("Key rotated successfully")
else:
print("Rotation failed, continuing with current key")The example/python/example_client.py script walks through all five SDK operations interactively:
cd example/python
KEYVORY_SERVER=http://localhost:5000 \
KEYVORY_SECRET=your-secret-key \
python example_client.pyYou will be prompted to enter a license key. The demo walks through five steps: init handshake, license validation, info query, heartbeat keepalive, and session key rotation.