Initial public commit
This commit is contained in:
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# Byte-compiled / optimized
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
.eggs/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
.env
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Testing / coverage
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.tox/
|
||||||
|
htmlcov/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Editor / OS cruft
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Keysat
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# keysat-licensing-client (Python)
|
||||||
|
|
||||||
|
Python client for [Keysat](https://github.com/keysat-xyz/keysat) — a
|
||||||
|
self-hosted Bitcoin-paid software licensing server that runs on Start9.
|
||||||
|
|
||||||
|
Verifies signed license keys offline using the issuer's public key, and
|
||||||
|
(optionally) wraps the HTTP API for live validation, purchase, and
|
||||||
|
free-license redemption.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install keysat-licensing-client # offline only
|
||||||
|
pip install keysat-licensing-client[online] # + httpx for the online client
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Python 3.10+.
|
||||||
|
|
||||||
|
## Five-line offline check
|
||||||
|
|
||||||
|
```python
|
||||||
|
from keysat_licensing_client import Verifier, PublicKey
|
||||||
|
|
||||||
|
ISSUER_PUBKEY_PEM = open("assets/issuer.pub").read() # bake this into your app
|
||||||
|
verifier = Verifier(PublicKey.from_pem(ISSUER_PUBKEY_PEM))
|
||||||
|
|
||||||
|
ok = verifier.verify(key_from_user) # raises LicensingError on bad sig
|
||||||
|
print(f"licensed for product {ok.product_id}, expires {ok.expires_at}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Online check (with revocation + fingerprint binding)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from keysat_licensing_client import Client
|
||||||
|
|
||||||
|
client = Client("https://license.example.com")
|
||||||
|
r = client.validate(
|
||||||
|
key_from_user,
|
||||||
|
product_slug="my-product",
|
||||||
|
fingerprint="machine-fingerprint",
|
||||||
|
)
|
||||||
|
if not r.ok:
|
||||||
|
print("server rejected:", r.reason)
|
||||||
|
# 'revoked', 'fingerprint_mismatch', 'not_found', 'product_mismatch', etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
The recommended pattern is **offline-first, online-augmented**: do the
|
||||||
|
offline `Verifier.verify()` at boot. If that succeeds, also do an
|
||||||
|
async/background `client.validate()` to catch revocations and seat
|
||||||
|
mismatches. If the network fails, treat it as "status unknown" — don't
|
||||||
|
gate the user on your server's uptime.
|
||||||
|
|
||||||
|
## Purchase flow (drives the whole BTCPay round trip)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from keysat_licensing_client import Client, StartPurchaseOptions
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
client = Client("https://license.example.com")
|
||||||
|
session = client.start_purchase(
|
||||||
|
"my-product",
|
||||||
|
StartPurchaseOptions(buyer_email="bob@example.com"),
|
||||||
|
)
|
||||||
|
webbrowser.open(session.checkout_url)
|
||||||
|
license_key = client.wait_for_license(session.invoice_id)
|
||||||
|
# Save license_key wherever you decided to store keys (config dir, keychain, env).
|
||||||
|
```
|
||||||
|
|
||||||
|
## Free-license code redemption
|
||||||
|
|
||||||
|
For codes the seller created with kind `free_license` (no payment):
|
||||||
|
|
||||||
|
```python
|
||||||
|
result = client.redeem_free_license(
|
||||||
|
"my-product",
|
||||||
|
"PRESSPASS",
|
||||||
|
)
|
||||||
|
print("redeemed:", result.license_key)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fingerprint binding
|
||||||
|
|
||||||
|
The SDK doesn't decide WHAT to use as a fingerprint — that's a product
|
||||||
|
choice. Common sources, ordered by robustness:
|
||||||
|
|
||||||
|
- Linux: `/etc/machine-id`
|
||||||
|
- macOS: `ioreg -d2 -c IOPlatformExpertDevice`
|
||||||
|
- Windows: registry `MachineGuid`
|
||||||
|
- Fallback: random UUID written into your app's config dir on first run
|
||||||
|
|
||||||
|
Mix in a per-product salt so fingerprints from your app can't be
|
||||||
|
replayed against someone else's licensing server:
|
||||||
|
|
||||||
|
```python
|
||||||
|
fp_input = f"{APP_NAME}|{machine_id}"
|
||||||
|
# The SDK SHA-256s this for you when you pass it to validate(...).
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT OR Apache-2.0. See the upstream `LICENSE` file at
|
||||||
|
[github.com/keysat-xyz/keysat](https://github.com/keysat-xyz/keysat).
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Example: verify a Keysat license key offline.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
KEYSAT_PUBKEY_PEM=$(cat issuer.pub) python examples/offline_verify.py "LIC1-...-..."
|
||||||
|
|
||||||
|
Or set the PEM string inline. The output reports the parsed payload
|
||||||
|
fields and exits non-zero on bad signature / bad format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from keysat_licensing_client import LicensingError, PublicKey, Verifier
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
pem = os.environ.get("KEYSAT_PUBKEY_PEM")
|
||||||
|
if not pem:
|
||||||
|
print("error: set KEYSAT_PUBKEY_PEM to the issuer's PEM-encoded public key.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print("usage: offline_verify.py <license-key>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
verifier = Verifier(PublicKey.from_pem(pem))
|
||||||
|
try:
|
||||||
|
ok = verifier.verify(sys.argv[1])
|
||||||
|
except LicensingError as e:
|
||||||
|
print(f"INVALID: {e.kind}: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("VALID")
|
||||||
|
print(f" product_id: {ok.product_id}")
|
||||||
|
print(f" license_id: {ok.license_id}")
|
||||||
|
print(f" issued_at: {ok.issued_at}")
|
||||||
|
print(f" expires_at: {ok.expires_at if ok.expires_at != 0 else 'perpetual'}")
|
||||||
|
print(f" trial: {ok.is_trial}")
|
||||||
|
print(f" fp_bound: {ok.is_fingerprint_bound}")
|
||||||
|
print(f" entitlements: {ok.entitlements or '(none)'}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "keysat-licensing-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Python client for Keysat — a self-hosted Bitcoin-paid software licensing server that runs on Start9. Verifies signed license keys offline and wraps the HTTP API for purchase, redemption, and revocation checks."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
license = { text = "MIT OR Apache-2.0" }
|
||||||
|
authors = [{ name = "Keysat", email = "licensing@keysat.xyz" }]
|
||||||
|
keywords = ["bitcoin", "licensing", "btcpay", "start9", "ed25519"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"License :: OSI Approved :: Apache Software License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Topic :: Security :: Cryptography",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"cryptography>=42",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
online = ["httpx>=0.27"]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8",
|
||||||
|
"ruff>=0.5",
|
||||||
|
"mypy>=1.10",
|
||||||
|
"httpx>=0.27",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://keysat.xyz"
|
||||||
|
Repository = "https://github.com/keysat-xyz/keysat"
|
||||||
|
Documentation = "https://github.com/keysat-xyz/keysat/blob/main/docs/INTEGRATION.md"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-q"
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""
|
||||||
|
keysat_licensing_client
|
||||||
|
=======================
|
||||||
|
|
||||||
|
Python client for Keysat — a self-hosted Bitcoin-paid software licensing
|
||||||
|
server that runs on Start9. Verifies signed license keys offline, and
|
||||||
|
(via the optional `online` extra) wraps the HTTP API for purchase,
|
||||||
|
free-license redemption, and revocation checks.
|
||||||
|
|
||||||
|
Five-line offline check::
|
||||||
|
|
||||||
|
from keysat_licensing_client import Verifier, PublicKey
|
||||||
|
|
||||||
|
verifier = Verifier(PublicKey.from_pem(ISSUER_PUBKEY_PEM))
|
||||||
|
ok = verifier.verify(key_from_user)
|
||||||
|
print("licensed for product", ok.product_id)
|
||||||
|
|
||||||
|
Online client (requires the `online` extra: `pip install keysat-licensing-client[online]`)::
|
||||||
|
|
||||||
|
from keysat_licensing_client import Client
|
||||||
|
client = Client("https://license.example.com")
|
||||||
|
r = client.validate(key_from_user, product_slug="my-product",
|
||||||
|
fingerprint=machine_fingerprint)
|
||||||
|
if not r.ok:
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .errors import LicensingError
|
||||||
|
from .key import (
|
||||||
|
FLAG_FINGERPRINT_BOUND,
|
||||||
|
FLAG_TRIAL,
|
||||||
|
KEY_PREFIX,
|
||||||
|
KEY_VERSION_V1,
|
||||||
|
KEY_VERSION_V2,
|
||||||
|
LicensePayload,
|
||||||
|
has_entitlement,
|
||||||
|
is_expired_at,
|
||||||
|
parse_license_key,
|
||||||
|
)
|
||||||
|
from .pubkey import PublicKey
|
||||||
|
from .verify import VerifyOk, Verifier
|
||||||
|
from .fingerprint import hash_fingerprint
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LicensingError",
|
||||||
|
"PublicKey",
|
||||||
|
"Verifier",
|
||||||
|
"VerifyOk",
|
||||||
|
"LicensePayload",
|
||||||
|
"parse_license_key",
|
||||||
|
"is_expired_at",
|
||||||
|
"has_entitlement",
|
||||||
|
"hash_fingerprint",
|
||||||
|
"FLAG_FINGERPRINT_BOUND",
|
||||||
|
"FLAG_TRIAL",
|
||||||
|
"KEY_PREFIX",
|
||||||
|
"KEY_VERSION_V1",
|
||||||
|
"KEY_VERSION_V2",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Online client is gated on optional dependency `httpx`. We try to
|
||||||
|
# expose it but degrade silently if httpx isn't installed — the offline
|
||||||
|
# verifier (above) doesn't need network at all.
|
||||||
|
try:
|
||||||
|
from .online import ( # noqa: F401
|
||||||
|
Client,
|
||||||
|
ValidateResponse,
|
||||||
|
ValidateOptions,
|
||||||
|
StartPurchaseOptions,
|
||||||
|
PurchaseSession,
|
||||||
|
PollResponse,
|
||||||
|
RedeemFreeOptions,
|
||||||
|
RedeemFreeResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ += [
|
||||||
|
"Client",
|
||||||
|
"ValidateResponse",
|
||||||
|
"ValidateOptions",
|
||||||
|
"StartPurchaseOptions",
|
||||||
|
"PurchaseSession",
|
||||||
|
"PollResponse",
|
||||||
|
"RedeemFreeOptions",
|
||||||
|
"RedeemFreeResponse",
|
||||||
|
]
|
||||||
|
except ImportError:
|
||||||
|
# httpx not installed — that's fine, online client is optional.
|
||||||
|
pass
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Exception types for keysat_licensing_client."""
|
||||||
|
|
||||||
|
|
||||||
|
class LicensingError(Exception):
|
||||||
|
"""Base exception for all keysat_licensing_client errors.
|
||||||
|
|
||||||
|
Subclasses (or `kind` strings on the base class) distinguish
|
||||||
|
specific failure modes:
|
||||||
|
|
||||||
|
- `bad_signature`: the key's Ed25519 signature didn't verify.
|
||||||
|
- `bad_format`: the key string isn't a parseable LIC1-... key.
|
||||||
|
- `expired`: the key parsed and verified but is past its expiry.
|
||||||
|
- `revoked`: the server reported the key as revoked.
|
||||||
|
- `fingerprint_mismatch`: the key was bound to a different machine.
|
||||||
|
- `not_found`: the server doesn't know about this key.
|
||||||
|
- `product_mismatch`: the key is for a different product than checked.
|
||||||
|
- `network`: network error talking to the server (transient).
|
||||||
|
- `server_error`: server returned an unexpected response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, kind: str, message: str = ""):
|
||||||
|
self.kind = kind
|
||||||
|
super().__init__(message or kind)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Machine-fingerprint helper.
|
||||||
|
|
||||||
|
The SDK doesn't decide WHAT to use as a fingerprint — that's a product
|
||||||
|
choice, see PORTING_SDK_TO_NEW_LANGUAGES.md and UPGRADING_EXISTING_SOFTWARE.md
|
||||||
|
for tradeoffs. The SDK only standardizes how the chosen string is hashed
|
||||||
|
before being sent to the server (so the server never sees the raw value).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
|
||||||
|
def hash_fingerprint(raw: str) -> str:
|
||||||
|
"""SHA-256 the raw fingerprint string and return a hex digest.
|
||||||
|
|
||||||
|
Output is the same as Python's
|
||||||
|
`hashlib.sha256(raw.encode()).hexdigest()` — 64 lowercase hex chars.
|
||||||
|
Cross-language SDKs all use this exact hashing so a fingerprint
|
||||||
|
bound by (say) the TS client validates correctly against the same
|
||||||
|
machine's Python client.
|
||||||
|
"""
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""License key payload struct and parser.
|
||||||
|
|
||||||
|
Wire format reference: see PORTING_SDK_TO_NEW_LANGUAGES.md and
|
||||||
|
licensing-service/src/crypto/mod.rs. Both v1 (legacy fixed-74) and v2
|
||||||
|
(variable-length with expires_at + entitlements) layouts are supported.
|
||||||
|
|
||||||
|
Format summary::
|
||||||
|
|
||||||
|
LIC1-<base32(payload)>-<base32(signature)>
|
||||||
|
|
||||||
|
base32: RFC 4648, uppercase, no padding.
|
||||||
|
signature: 64 bytes Ed25519 over the raw payload bytes.
|
||||||
|
|
||||||
|
v1 payload (74 bytes, fixed):
|
||||||
|
[0] version (=1)
|
||||||
|
[1] flags (bit 0 FINGERPRINT_BOUND, bit 1 TRIAL)
|
||||||
|
[2..18] product_id (UUID big-endian)
|
||||||
|
[18..34] license_id (UUID big-endian)
|
||||||
|
[34..42] issued_at (u64 big-endian, unix seconds)
|
||||||
|
[42..74] fingerprint_hash (SHA-256 hex digest, 32 raw bytes)
|
||||||
|
|
||||||
|
v2 payload (83+ bytes):
|
||||||
|
[0] version (=2)
|
||||||
|
[1] flags
|
||||||
|
[2..18] product_id
|
||||||
|
[18..34] license_id
|
||||||
|
[34..42] issued_at (u64 big-endian)
|
||||||
|
[42..50] expires_at (u64 big-endian; 0 = perpetual)
|
||||||
|
[50..82] fingerprint_hash
|
||||||
|
[82] num_entitlements (u8)
|
||||||
|
[83..] for each: <u8 len><len bytes UTF-8 entitlement slug>
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from .errors import LicensingError
|
||||||
|
|
||||||
|
KEY_PREFIX = "LIC1"
|
||||||
|
KEY_VERSION_V1 = 1
|
||||||
|
KEY_VERSION_V2 = 2
|
||||||
|
|
||||||
|
FLAG_FINGERPRINT_BOUND = 0b0000_0001
|
||||||
|
FLAG_TRIAL = 0b0000_0010
|
||||||
|
|
||||||
|
V1_LEN = 74
|
||||||
|
V2_HEAD_LEN = 83 # bytes 0..82 fixed, then variable entitlement tail
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LicensePayload:
|
||||||
|
"""Decoded license payload. Returned by `parse_license_key()`."""
|
||||||
|
|
||||||
|
version: int
|
||||||
|
flags: int
|
||||||
|
product_id: uuid.UUID
|
||||||
|
license_id: uuid.UUID
|
||||||
|
issued_at: int # unix seconds
|
||||||
|
expires_at: int # unix seconds; 0 = perpetual; v1 keys always 0
|
||||||
|
fingerprint_hash: bytes # 32 bytes; all-zero if unbound
|
||||||
|
entitlements: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_fingerprint_bound(self) -> bool:
|
||||||
|
return bool(self.flags & FLAG_FINGERPRINT_BOUND)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_trial(self) -> bool:
|
||||||
|
return bool(self.flags & FLAG_TRIAL)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ParsedKey:
|
||||||
|
"""Parsed (but NOT signature-verified) license key.
|
||||||
|
|
||||||
|
Carries the payload and the raw payload bytes (needed for signature
|
||||||
|
verification — the signature is over the bytes, not the parsed
|
||||||
|
struct).
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload: LicensePayload
|
||||||
|
payload_bytes: bytes
|
||||||
|
signature: bytes # 64 bytes
|
||||||
|
|
||||||
|
|
||||||
|
def _b32_decode_nopad(s: str) -> bytes:
|
||||||
|
"""Decode RFC4648 base32, uppercase, with no padding. Adds padding
|
||||||
|
if needed to satisfy stdlib's strict decoder."""
|
||||||
|
s = s.upper()
|
||||||
|
pad = (-len(s)) % 8
|
||||||
|
return base64.b32decode(s + "=" * pad)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_license_key(key: str) -> ParsedKey:
|
||||||
|
"""Parse a `LIC1-<payload>-<sig>` key string. Does NOT verify the
|
||||||
|
signature — use `Verifier.verify()` for that.
|
||||||
|
|
||||||
|
Raises `LicensingError(kind="bad_format")` on malformed input.
|
||||||
|
"""
|
||||||
|
if not isinstance(key, str):
|
||||||
|
raise LicensingError("bad_format", "key must be a string")
|
||||||
|
parts = key.strip().split("-")
|
||||||
|
if len(parts) != 3:
|
||||||
|
raise LicensingError(
|
||||||
|
"bad_format",
|
||||||
|
f"expected `LIC1-<payload>-<sig>`, got {len(parts)} dash-separated parts",
|
||||||
|
)
|
||||||
|
prefix, payload_b32, sig_b32 = parts
|
||||||
|
if prefix != KEY_PREFIX:
|
||||||
|
raise LicensingError("bad_format", f"unknown prefix '{prefix}'; expected '{KEY_PREFIX}'")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload_bytes = _b32_decode_nopad(payload_b32)
|
||||||
|
signature = _b32_decode_nopad(sig_b32)
|
||||||
|
except Exception as e:
|
||||||
|
raise LicensingError("bad_format", f"base32 decode failed: {e}") from e
|
||||||
|
|
||||||
|
if len(signature) != 64:
|
||||||
|
raise LicensingError(
|
||||||
|
"bad_format",
|
||||||
|
f"signature must be 64 bytes (got {len(signature)})",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ParsedKey(
|
||||||
|
payload=_decode_payload(payload_bytes),
|
||||||
|
payload_bytes=payload_bytes,
|
||||||
|
signature=signature,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_payload(payload: bytes) -> LicensePayload:
|
||||||
|
if len(payload) < 2:
|
||||||
|
raise LicensingError("bad_format", "payload too short")
|
||||||
|
version = payload[0]
|
||||||
|
flags = payload[1]
|
||||||
|
if version == KEY_VERSION_V1:
|
||||||
|
if len(payload) != V1_LEN:
|
||||||
|
raise LicensingError(
|
||||||
|
"bad_format",
|
||||||
|
f"v1 payload must be exactly {V1_LEN} bytes (got {len(payload)})",
|
||||||
|
)
|
||||||
|
product_id = uuid.UUID(bytes=payload[2:18])
|
||||||
|
license_id = uuid.UUID(bytes=payload[18:34])
|
||||||
|
issued_at = int.from_bytes(payload[34:42], "big")
|
||||||
|
fingerprint_hash = payload[42:74]
|
||||||
|
return LicensePayload(
|
||||||
|
version=version,
|
||||||
|
flags=flags,
|
||||||
|
product_id=product_id,
|
||||||
|
license_id=license_id,
|
||||||
|
issued_at=issued_at,
|
||||||
|
expires_at=0, # v1 has no expiry
|
||||||
|
fingerprint_hash=fingerprint_hash,
|
||||||
|
entitlements=[],
|
||||||
|
)
|
||||||
|
elif version == KEY_VERSION_V2:
|
||||||
|
if len(payload) < V2_HEAD_LEN:
|
||||||
|
raise LicensingError(
|
||||||
|
"bad_format",
|
||||||
|
f"v2 payload header is {V2_HEAD_LEN} bytes; got {len(payload)}",
|
||||||
|
)
|
||||||
|
product_id = uuid.UUID(bytes=payload[2:18])
|
||||||
|
license_id = uuid.UUID(bytes=payload[18:34])
|
||||||
|
issued_at = int.from_bytes(payload[34:42], "big")
|
||||||
|
expires_at = int.from_bytes(payload[42:50], "big")
|
||||||
|
fingerprint_hash = payload[50:82]
|
||||||
|
num_ents = payload[82]
|
||||||
|
entitlements: list[str] = []
|
||||||
|
cursor = 83
|
||||||
|
for _ in range(num_ents):
|
||||||
|
if cursor >= len(payload):
|
||||||
|
raise LicensingError("bad_format", "v2 entitlement length byte missing")
|
||||||
|
slen = payload[cursor]
|
||||||
|
cursor += 1
|
||||||
|
if cursor + slen > len(payload):
|
||||||
|
raise LicensingError("bad_format", "v2 entitlement extends past payload")
|
||||||
|
slug = payload[cursor : cursor + slen].decode("utf-8")
|
||||||
|
cursor += slen
|
||||||
|
entitlements.append(slug)
|
||||||
|
if cursor != len(payload):
|
||||||
|
raise LicensingError(
|
||||||
|
"bad_format",
|
||||||
|
f"v2 payload has {len(payload) - cursor} trailing bytes after entitlements",
|
||||||
|
)
|
||||||
|
return LicensePayload(
|
||||||
|
version=version,
|
||||||
|
flags=flags,
|
||||||
|
product_id=product_id,
|
||||||
|
license_id=license_id,
|
||||||
|
issued_at=issued_at,
|
||||||
|
expires_at=expires_at,
|
||||||
|
fingerprint_hash=fingerprint_hash,
|
||||||
|
entitlements=entitlements,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise LicensingError("bad_format", f"unknown key version: {version}")
|
||||||
|
|
||||||
|
|
||||||
|
def is_expired_at(payload: LicensePayload, when_unix: int) -> bool:
|
||||||
|
"""Pure helper: is this key expired AT a given unix timestamp?
|
||||||
|
|
||||||
|
Returns False for perpetual keys (expires_at == 0) regardless of
|
||||||
|
when. Caller supplies `when_unix` so this is a pure function — no
|
||||||
|
clock dependency. Typically the caller passes `int(time.time())`.
|
||||||
|
"""
|
||||||
|
if payload.expires_at == 0:
|
||||||
|
return False
|
||||||
|
return when_unix >= payload.expires_at
|
||||||
|
|
||||||
|
|
||||||
|
def has_entitlement(payload: LicensePayload, slug: str) -> bool:
|
||||||
|
"""Does the license grant the given entitlement slug?"""
|
||||||
|
return slug in payload.entitlements
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
"""Online HTTP client for the Keysat REST API.
|
||||||
|
|
||||||
|
Wraps the public endpoints (`/v1/validate`, `/v1/purchase`,
|
||||||
|
`/v1/redeem`, `/v1/machines/*`) over HTTPS. All methods are synchronous;
|
||||||
|
an async variant can be added later if anyone asks.
|
||||||
|
|
||||||
|
Requires `httpx`. Install via the `online` extra::
|
||||||
|
|
||||||
|
pip install keysat-licensing-client[online]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx # imported only when this module is loaded; gated in __init__.py
|
||||||
|
|
||||||
|
from .errors import LicensingError
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Response shapes ----------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValidateResponse:
|
||||||
|
ok: bool
|
||||||
|
reason: str | None = None
|
||||||
|
license_id: str | None = None
|
||||||
|
product_id: str | None = None
|
||||||
|
product_slug: str | None = None
|
||||||
|
issued_at: str | None = None
|
||||||
|
expires_at: str | None = None
|
||||||
|
grace_until: str | None = None
|
||||||
|
in_grace_period: bool | None = None
|
||||||
|
is_trial: bool | None = None
|
||||||
|
entitlements: list[str] = field(default_factory=list)
|
||||||
|
status: str | None = None
|
||||||
|
machine_id: str | None = None
|
||||||
|
max_machines: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValidateOptions:
|
||||||
|
product_slug: str | None = None
|
||||||
|
fingerprint: str | None = None
|
||||||
|
hostname: str | None = None
|
||||||
|
platform: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StartPurchaseOptions:
|
||||||
|
buyer_email: str | None = None
|
||||||
|
buyer_note: str | None = None
|
||||||
|
redirect_url: str | None = None
|
||||||
|
code: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PurchaseSession:
|
||||||
|
invoice_id: str
|
||||||
|
btcpay_invoice_id: str
|
||||||
|
checkout_url: str
|
||||||
|
amount_sats: int
|
||||||
|
base_price_sats: int
|
||||||
|
discount_applied_sats: int
|
||||||
|
poll_url: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PollResponse:
|
||||||
|
invoice_id: str
|
||||||
|
status: str # 'pending' | 'settled' | 'expired' | 'invalid'
|
||||||
|
product_id: str
|
||||||
|
amount_sats: int
|
||||||
|
license_key: str | None
|
||||||
|
license_id: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RedeemFreeOptions:
|
||||||
|
buyer_email: str | None = None
|
||||||
|
buyer_note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RedeemFreeResponse:
|
||||||
|
license_id: str
|
||||||
|
license_key: str
|
||||||
|
invoice_id: str
|
||||||
|
redemption_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MachineResponse:
|
||||||
|
ok: bool
|
||||||
|
reason: str | None = None
|
||||||
|
machine_id: str | None = None
|
||||||
|
active_count: int | None = None
|
||||||
|
max_machines: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Client ----------
|
||||||
|
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
"""HTTP client pinned to one Keysat server URL.
|
||||||
|
|
||||||
|
Construct with the public base URL (e.g. ``https://license.example.com``).
|
||||||
|
All methods raise `LicensingError(kind="network")` on transport
|
||||||
|
failure and `LicensingError(kind="server_error")` on unexpected
|
||||||
|
server responses; semantic-failure cases (revoked / fingerprint
|
||||||
|
mismatch / bad signature) come back as `ValidateResponse(ok=False,
|
||||||
|
reason="...")` so the caller can render different messaging per
|
||||||
|
reason without try/except.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, *, timeout: float = 15.0):
|
||||||
|
self._base = base_url.rstrip("/")
|
||||||
|
self._timeout = timeout
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_url(self) -> str:
|
||||||
|
return self._base
|
||||||
|
|
||||||
|
# ----- Public endpoints -----
|
||||||
|
|
||||||
|
def fetch_pubkey_pem(self) -> str:
|
||||||
|
data = self._get("/v1/pubkey")
|
||||||
|
return data["public_key_pem"]
|
||||||
|
|
||||||
|
def validate(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
product_slug: str | None = None,
|
||||||
|
fingerprint: str | None = None,
|
||||||
|
opts: ValidateOptions | None = None,
|
||||||
|
) -> ValidateResponse:
|
||||||
|
merged = opts or ValidateOptions()
|
||||||
|
body = {
|
||||||
|
"key": key,
|
||||||
|
"product_slug": merged.product_slug or product_slug,
|
||||||
|
"fingerprint": merged.fingerprint or fingerprint,
|
||||||
|
"hostname": merged.hostname,
|
||||||
|
"platform": merged.platform,
|
||||||
|
}
|
||||||
|
body = {k: v for k, v in body.items() if v is not None}
|
||||||
|
raw = self._post("/v1/validate", body)
|
||||||
|
ents = raw.get("entitlements") or []
|
||||||
|
return ValidateResponse(
|
||||||
|
ok=bool(raw.get("ok")),
|
||||||
|
reason=raw.get("reason"),
|
||||||
|
license_id=raw.get("license_id"),
|
||||||
|
product_id=raw.get("product_id"),
|
||||||
|
product_slug=raw.get("product_slug"),
|
||||||
|
issued_at=raw.get("issued_at"),
|
||||||
|
expires_at=raw.get("expires_at"),
|
||||||
|
grace_until=raw.get("grace_until"),
|
||||||
|
in_grace_period=raw.get("in_grace_period"),
|
||||||
|
is_trial=raw.get("is_trial"),
|
||||||
|
entitlements=[e for e in ents if isinstance(e, str)],
|
||||||
|
status=raw.get("status"),
|
||||||
|
machine_id=raw.get("machine_id"),
|
||||||
|
max_machines=raw.get("max_machines"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----- Machine seat management -----
|
||||||
|
|
||||||
|
def heartbeat(self, key: str, fingerprint: str) -> MachineResponse:
|
||||||
|
raw = self._post("/v1/machines/heartbeat", {"key": key, "fingerprint": fingerprint})
|
||||||
|
return _to_machine_response(raw)
|
||||||
|
|
||||||
|
def activate(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
fingerprint: str,
|
||||||
|
hostname: str | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
) -> MachineResponse:
|
||||||
|
body = {"key": key, "fingerprint": fingerprint, "hostname": hostname, "platform": platform}
|
||||||
|
body = {k: v for k, v in body.items() if v is not None}
|
||||||
|
raw = self._post("/v1/machines/activate", body)
|
||||||
|
return _to_machine_response(raw)
|
||||||
|
|
||||||
|
def deactivate(
|
||||||
|
self, key: str, fingerprint: str, reason: str | None = None
|
||||||
|
) -> MachineResponse:
|
||||||
|
body = {"key": key, "fingerprint": fingerprint, "reason": reason}
|
||||||
|
body = {k: v for k, v in body.items() if v is not None}
|
||||||
|
raw = self._post("/v1/machines/deactivate", body)
|
||||||
|
return _to_machine_response(raw)
|
||||||
|
|
||||||
|
# ----- Purchase flow -----
|
||||||
|
|
||||||
|
def start_purchase(
|
||||||
|
self,
|
||||||
|
product_slug: str,
|
||||||
|
opts: StartPurchaseOptions | None = None,
|
||||||
|
) -> PurchaseSession:
|
||||||
|
merged = opts or StartPurchaseOptions()
|
||||||
|
body = {
|
||||||
|
"product": product_slug,
|
||||||
|
"buyer_email": merged.buyer_email,
|
||||||
|
"buyer_note": merged.buyer_note,
|
||||||
|
"redirect_url": merged.redirect_url,
|
||||||
|
"code": merged.code,
|
||||||
|
}
|
||||||
|
body = {k: v for k, v in body.items() if v is not None}
|
||||||
|
raw = self._post("/v1/purchase", body)
|
||||||
|
return PurchaseSession(
|
||||||
|
invoice_id=raw["invoice_id"],
|
||||||
|
btcpay_invoice_id=raw["btcpay_invoice_id"],
|
||||||
|
checkout_url=raw["checkout_url"],
|
||||||
|
amount_sats=raw["amount_sats"],
|
||||||
|
base_price_sats=raw.get("base_price_sats", raw["amount_sats"]),
|
||||||
|
discount_applied_sats=raw.get("discount_applied_sats", 0),
|
||||||
|
poll_url=raw["poll_url"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def poll_purchase(self, invoice_id: str) -> PollResponse:
|
||||||
|
raw = self._get(f"/v1/purchase/{invoice_id}")
|
||||||
|
return PollResponse(
|
||||||
|
invoice_id=raw["invoice_id"],
|
||||||
|
status=raw["status"],
|
||||||
|
product_id=raw["product_id"],
|
||||||
|
amount_sats=raw["amount_sats"],
|
||||||
|
license_key=raw.get("license_key"),
|
||||||
|
license_id=raw.get("license_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def wait_for_license(
|
||||||
|
self,
|
||||||
|
invoice_id: str,
|
||||||
|
interval_s: float = 5.0,
|
||||||
|
timeout_s: float | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Poll `poll_purchase` until the license_key is non-null.
|
||||||
|
|
||||||
|
Raises LicensingError on terminal invoice states (expired /
|
||||||
|
invalid) or timeout. Returns the license_key string on success.
|
||||||
|
"""
|
||||||
|
deadline = time.monotonic() + timeout_s if timeout_s else None
|
||||||
|
while True:
|
||||||
|
poll = self.poll_purchase(invoice_id)
|
||||||
|
if poll.license_key:
|
||||||
|
return poll.license_key
|
||||||
|
if poll.status in ("expired", "invalid"):
|
||||||
|
raise LicensingError(
|
||||||
|
"server_error", f"invoice ended in status {poll.status}"
|
||||||
|
)
|
||||||
|
if deadline is not None and time.monotonic() > deadline:
|
||||||
|
raise LicensingError(
|
||||||
|
"server_error", "timed out waiting for license issuance"
|
||||||
|
)
|
||||||
|
time.sleep(interval_s)
|
||||||
|
|
||||||
|
# ----- Free-license redemption -----
|
||||||
|
|
||||||
|
def redeem_free_license(
|
||||||
|
self,
|
||||||
|
product_slug: str,
|
||||||
|
code: str,
|
||||||
|
opts: RedeemFreeOptions | None = None,
|
||||||
|
) -> RedeemFreeResponse:
|
||||||
|
merged = opts or RedeemFreeOptions()
|
||||||
|
body = {
|
||||||
|
"product": product_slug,
|
||||||
|
"code": code,
|
||||||
|
"buyer_email": merged.buyer_email,
|
||||||
|
"buyer_note": merged.buyer_note,
|
||||||
|
}
|
||||||
|
body = {k: v for k, v in body.items() if v is not None}
|
||||||
|
raw = self._post("/v1/redeem", body)
|
||||||
|
return RedeemFreeResponse(
|
||||||
|
license_id=raw["license_id"],
|
||||||
|
license_key=raw["license_key"],
|
||||||
|
invoice_id=raw["invoice_id"],
|
||||||
|
redemption_id=raw["redemption_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----- Internals -----
|
||||||
|
|
||||||
|
def _get(self, path: str) -> dict[str, Any]:
|
||||||
|
return self._request("GET", path, json_body=None)
|
||||||
|
|
||||||
|
def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return self._request("POST", path, json_body=body)
|
||||||
|
|
||||||
|
def _request(self, method: str, path: str, json_body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
url = self._base + path
|
||||||
|
try:
|
||||||
|
resp = httpx.request(method, url, json=json_body, timeout=self._timeout)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise LicensingError("network", f"{method} {url}: {e}") from e
|
||||||
|
if resp.status_code >= 500:
|
||||||
|
raise LicensingError(
|
||||||
|
"server_error",
|
||||||
|
f"{method} {url}: HTTP {resp.status_code} — {resp.text[:200]}",
|
||||||
|
)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
raise LicensingError(
|
||||||
|
"server_error",
|
||||||
|
f"{method} {url}: HTTP {resp.status_code} — {resp.text[:200]}",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
raise LicensingError(
|
||||||
|
"server_error",
|
||||||
|
f"{method} {url}: response was not JSON: {e}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
def _to_machine_response(raw: dict[str, Any]) -> MachineResponse:
|
||||||
|
return MachineResponse(
|
||||||
|
ok=bool(raw.get("ok")),
|
||||||
|
reason=raw.get("reason"),
|
||||||
|
machine_id=raw.get("machine_id"),
|
||||||
|
active_count=raw.get("active_count"),
|
||||||
|
max_machines=raw.get("max_machines"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Server public key loading and management."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||||
|
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
||||||
|
|
||||||
|
from .errors import LicensingError
|
||||||
|
|
||||||
|
|
||||||
|
class PublicKey:
|
||||||
|
"""An Ed25519 public key issued by the Keysat server.
|
||||||
|
|
||||||
|
Holds the underlying `cryptography.Ed25519PublicKey` and exposes
|
||||||
|
convenience constructors. Pass an instance to `Verifier(...)`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, key: Ed25519PublicKey):
|
||||||
|
self._key = key
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_pem(cls, pem: str | bytes) -> "PublicKey":
|
||||||
|
"""Load from a PEM string as returned by the server's `GET /v1/pubkey`.
|
||||||
|
|
||||||
|
Raises `LicensingError(kind="bad_format")` if the PEM is not a
|
||||||
|
valid Ed25519 public key.
|
||||||
|
"""
|
||||||
|
if isinstance(pem, str):
|
||||||
|
pem = pem.encode("utf-8")
|
||||||
|
try:
|
||||||
|
loaded = load_pem_public_key(pem)
|
||||||
|
except Exception as e:
|
||||||
|
raise LicensingError("bad_format", f"could not parse PEM: {e}") from e
|
||||||
|
if not isinstance(loaded, Ed25519PublicKey):
|
||||||
|
raise LicensingError(
|
||||||
|
"bad_format",
|
||||||
|
"PEM is not an Ed25519 public key (got "
|
||||||
|
f"{type(loaded).__name__})",
|
||||||
|
)
|
||||||
|
return cls(loaded)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_raw(cls, raw: bytes) -> "PublicKey":
|
||||||
|
"""Load from a 32-byte raw Ed25519 public key."""
|
||||||
|
if len(raw) != 32:
|
||||||
|
raise LicensingError(
|
||||||
|
"bad_format",
|
||||||
|
f"raw Ed25519 public key must be 32 bytes (got {len(raw)})",
|
||||||
|
)
|
||||||
|
return cls(Ed25519PublicKey.from_public_bytes(raw))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def underlying(self) -> Ed25519PublicKey:
|
||||||
|
"""Access the underlying cryptography object (for advanced use)."""
|
||||||
|
return self._key
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Offline signature verification — the bulk of the value of the SDK."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
|
||||||
|
from .errors import LicensingError
|
||||||
|
from .key import LicensePayload, parse_license_key
|
||||||
|
from .pubkey import PublicKey
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VerifyOk:
|
||||||
|
"""Result of a successful offline `Verifier.verify()` call."""
|
||||||
|
|
||||||
|
license_id: uuid.UUID
|
||||||
|
product_id: uuid.UUID
|
||||||
|
issued_at: int
|
||||||
|
expires_at: int # 0 = perpetual
|
||||||
|
is_trial: bool
|
||||||
|
is_fingerprint_bound: bool
|
||||||
|
fingerprint_hash: bytes # 32 bytes; all-zero if unbound
|
||||||
|
entitlements: list[str]
|
||||||
|
payload: LicensePayload # the full parsed payload
|
||||||
|
|
||||||
|
|
||||||
|
class Verifier:
|
||||||
|
"""Offline license-key verifier.
|
||||||
|
|
||||||
|
Given the issuer's PEM-encoded public key, verifies the cryptographic
|
||||||
|
integrity of a license key string with no network access. Suitable
|
||||||
|
for boot-time license checks where the licensing server may be
|
||||||
|
unreachable.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
verifier = Verifier(PublicKey.from_pem(ISSUER_PUBKEY_PEM))
|
||||||
|
ok = verifier.verify(key_from_user)
|
||||||
|
# raises LicensingError on bad signature / bad format
|
||||||
|
|
||||||
|
For revocation and fingerprint binding, layer the online
|
||||||
|
`Client.validate(...)` on top of this — but only AFTER offline
|
||||||
|
verification has passed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, public_key: PublicKey):
|
||||||
|
self._pubkey = public_key
|
||||||
|
|
||||||
|
def verify(self, key: str) -> VerifyOk:
|
||||||
|
"""Verify a `LIC1-...-...` key.
|
||||||
|
|
||||||
|
On success, returns a `VerifyOk` with all parsed fields. On
|
||||||
|
failure, raises `LicensingError`:
|
||||||
|
|
||||||
|
- `kind="bad_format"`: the key string is malformed.
|
||||||
|
- `kind="bad_signature"`: signature didn't verify against
|
||||||
|
the issuer's public key (key was edited, fabricated, or
|
||||||
|
issued by a different server).
|
||||||
|
"""
|
||||||
|
parsed = parse_license_key(key)
|
||||||
|
try:
|
||||||
|
self._pubkey.underlying.verify(parsed.signature, parsed.payload_bytes)
|
||||||
|
except InvalidSignature as e:
|
||||||
|
raise LicensingError(
|
||||||
|
"bad_signature",
|
||||||
|
"signature did not verify against the issuer's public key",
|
||||||
|
) from e
|
||||||
|
p = parsed.payload
|
||||||
|
return VerifyOk(
|
||||||
|
license_id=p.license_id,
|
||||||
|
product_id=p.product_id,
|
||||||
|
issued_at=p.issued_at,
|
||||||
|
expires_at=p.expires_at,
|
||||||
|
is_trial=p.is_trial,
|
||||||
|
is_fingerprint_bound=p.is_fingerprint_bound,
|
||||||
|
fingerprint_hash=p.fingerprint_hash,
|
||||||
|
entitlements=list(p.entitlements),
|
||||||
|
payload=p,
|
||||||
|
)
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Cross-check the Python SDK against the canonical wire-format test
|
||||||
|
vectors at ../../tests/crosscheck/vector.json.
|
||||||
|
|
||||||
|
These vectors are also exercised by the Rust and TS SDKs. Any new SDK
|
||||||
|
must pass every fixture in vector.json — that's how we guarantee
|
||||||
|
wire-format compatibility across languages.
|
||||||
|
|
||||||
|
Run with: `pytest -q` from the package root, OR `python -m pytest`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from keysat_licensing_client import (
|
||||||
|
PublicKey,
|
||||||
|
Verifier,
|
||||||
|
LicensingError,
|
||||||
|
has_entitlement,
|
||||||
|
hash_fingerprint,
|
||||||
|
is_expired_at,
|
||||||
|
parse_license_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Locate vector.json relative to this file. The tests directory lives
|
||||||
|
# at licensing-client-python/tests/, the vector lives at
|
||||||
|
# tests/crosscheck/vector.json (repo root). Walk up until we find it.
|
||||||
|
def _vectors_path() -> Path:
|
||||||
|
here = Path(__file__).resolve().parent
|
||||||
|
for ancestor in [here, *here.parents]:
|
||||||
|
candidate = ancestor / "tests" / "crosscheck" / "vector.json"
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
|
pytest.skip("crosscheck vector.json not found; run from repo with tests/ tree")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def vectors() -> dict:
|
||||||
|
with _vectors_path().open() as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def verifier(vectors: dict) -> Verifier:
|
||||||
|
return Verifier(PublicKey.from_pem(vectors["publicKeyPem"]))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# v1 fixture: legacy fixed-74 layout, fingerprint-bound, no expiry,
|
||||||
|
# no entitlements.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_v1_parses(vectors: dict) -> None:
|
||||||
|
parsed = parse_license_key(vectors["v1"]["licenseKey"])
|
||||||
|
exp = vectors["v1"]["expected"]
|
||||||
|
assert parsed.payload.version == exp["version"]
|
||||||
|
assert str(parsed.payload.product_id) == exp["productUuid"]
|
||||||
|
assert str(parsed.payload.license_id) == exp["licenseUuid"]
|
||||||
|
assert parsed.payload.issued_at == exp["issuedAt"]
|
||||||
|
assert parsed.payload.expires_at == exp["expiresAt"]
|
||||||
|
assert parsed.payload.flags == exp["flags"]
|
||||||
|
assert parsed.payload.is_fingerprint_bound is exp["isFingerprintBound"]
|
||||||
|
assert parsed.payload.is_trial is exp["isTrial"]
|
||||||
|
assert parsed.payload.entitlements == exp["entitlements"]
|
||||||
|
assert parsed.payload.fingerprint_hash.hex() == exp["fingerprintHashHex"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_v1_verifies(verifier: Verifier, vectors: dict) -> None:
|
||||||
|
ok = verifier.verify(vectors["v1"]["licenseKey"])
|
||||||
|
assert str(ok.product_id) == vectors["v1"]["expected"]["productUuid"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_v1_tamper_detected(verifier: Verifier, vectors: dict) -> None:
|
||||||
|
key = vectors["v1"]["licenseKey"]
|
||||||
|
# Flip one char in the payload section. The signature won't match.
|
||||||
|
payload_start = key.index("-") + 1
|
||||||
|
tampered = key[:payload_start] + ("B" if key[payload_start] != "B" else "C") + key[payload_start + 1 :]
|
||||||
|
with pytest.raises(LicensingError) as excinfo:
|
||||||
|
verifier.verify(tampered)
|
||||||
|
assert excinfo.value.kind in ("bad_signature", "bad_format")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# v2 fixture: trial, fingerprint-bound, explicit expiry, two entitlements.
|
||||||
|
# Stresses the variable-length tail parser.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_parses(vectors: dict) -> None:
|
||||||
|
parsed = parse_license_key(vectors["v2"]["licenseKey"])
|
||||||
|
exp = vectors["v2"]["expected"]
|
||||||
|
assert parsed.payload.version == exp["version"]
|
||||||
|
assert str(parsed.payload.product_id) == exp["productUuid"]
|
||||||
|
assert str(parsed.payload.license_id) == exp["licenseUuid"]
|
||||||
|
assert parsed.payload.issued_at == exp["issuedAt"]
|
||||||
|
assert parsed.payload.expires_at == exp["expiresAt"]
|
||||||
|
assert parsed.payload.flags == exp["flags"]
|
||||||
|
assert parsed.payload.is_fingerprint_bound is exp["isFingerprintBound"]
|
||||||
|
assert parsed.payload.is_trial is exp["isTrial"]
|
||||||
|
assert parsed.payload.entitlements == exp["entitlements"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_verifies(verifier: Verifier, vectors: dict) -> None:
|
||||||
|
ok = verifier.verify(vectors["v2"]["licenseKey"])
|
||||||
|
assert ok.is_trial
|
||||||
|
assert ok.is_fingerprint_bound
|
||||||
|
assert len(ok.entitlements) == len(vectors["v2"]["expected"]["entitlements"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_expiry_boundary(vectors: dict) -> None:
|
||||||
|
parsed = parse_license_key(vectors["v2"]["licenseKey"])
|
||||||
|
expires_at = parsed.payload.expires_at
|
||||||
|
assert is_expired_at(parsed.payload, expires_at) is True
|
||||||
|
assert is_expired_at(parsed.payload, expires_at - 1) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_entitlements(vectors: dict) -> None:
|
||||||
|
parsed = parse_license_key(vectors["v2"]["licenseKey"])
|
||||||
|
for slug in vectors["v2"]["expected"]["entitlements"]:
|
||||||
|
assert has_entitlement(parsed.payload, slug)
|
||||||
|
assert has_entitlement(parsed.payload, "definitely-not-a-real-entitlement") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# v2_perpetual_unbound — common case for paid purchase: v2, no expiry,
|
||||||
|
# no fingerprint binding, no entitlements.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_perpetual_unbound_parses(vectors: dict) -> None:
|
||||||
|
if "v2_perpetual_unbound" not in vectors:
|
||||||
|
pytest.skip("vector.json doesn't include v2_perpetual_unbound")
|
||||||
|
parsed = parse_license_key(vectors["v2_perpetual_unbound"]["licenseKey"])
|
||||||
|
assert parsed.payload.version == 2
|
||||||
|
assert parsed.payload.expires_at == 0
|
||||||
|
assert parsed.payload.is_fingerprint_bound is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_perpetual_unbound_verifies(verifier: Verifier, vectors: dict) -> None:
|
||||||
|
if "v2_perpetual_unbound" not in vectors:
|
||||||
|
pytest.skip("vector.json doesn't include v2_perpetual_unbound")
|
||||||
|
verifier.verify(vectors["v2_perpetual_unbound"]["licenseKey"])
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Cross-language fingerprint-hash compatibility.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_fingerprint_matches_python_stdlib() -> None:
|
||||||
|
import hashlib
|
||||||
|
raw = "hello"
|
||||||
|
expected = hashlib.sha256(raw.encode()).hexdigest()
|
||||||
|
assert hash_fingerprint(raw) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Negative cases.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_format_short_key() -> None:
|
||||||
|
with pytest.raises(LicensingError) as excinfo:
|
||||||
|
parse_license_key("notakey")
|
||||||
|
assert excinfo.value.kind == "bad_format"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_format_wrong_prefix() -> None:
|
||||||
|
with pytest.raises(LicensingError) as excinfo:
|
||||||
|
parse_license_key("LIC9-AAAA-BBBB")
|
||||||
|
assert excinfo.value.kind == "bad_format"
|
||||||
Reference in New Issue
Block a user