latte-py 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- latte_py-0.1.0/LICENSE +8 -0
- latte_py-0.1.0/PKG-INFO +297 -0
- latte_py-0.1.0/README.md +271 -0
- latte_py-0.1.0/pyproject.toml +38 -0
- latte_py-0.1.0/setup.cfg +4 -0
- latte_py-0.1.0/src/latte/__init__.py +58 -0
- latte_py-0.1.0/src/latte/appid.py +45 -0
- latte_py-0.1.0/src/latte/domain.py +42 -0
- latte_py-0.1.0/src/latte/errors.py +152 -0
- latte_py-0.1.0/src/latte/http.py +297 -0
- latte_py-0.1.0/src/latte/jwt.py +108 -0
- latte_py-0.1.0/src/latte/key.py +54 -0
- latte_py-0.1.0/src/latte/license.py +77 -0
- latte_py-0.1.0/src/latte/storage.py +95 -0
- latte_py-0.1.0/src/latte/validate.py +69 -0
- latte_py-0.1.0/src/latte/verify.py +132 -0
- latte_py-0.1.0/src/latte_py.egg-info/PKG-INFO +297 -0
- latte_py-0.1.0/src/latte_py.egg-info/SOURCES.txt +25 -0
- latte_py-0.1.0/src/latte_py.egg-info/dependency_links.txt +1 -0
- latte_py-0.1.0/src/latte_py.egg-info/requires.txt +10 -0
- latte_py-0.1.0/src/latte_py.egg-info/top_level.txt +1 -0
- latte_py-0.1.0/tests/test_fixtures.py +98 -0
- latte_py-0.1.0/tests/test_http.py +321 -0
- latte_py-0.1.0/tests/test_key.py +49 -0
- latte_py-0.1.0/tests/test_storage.py +77 -0
- latte_py-0.1.0/tests/test_validate.py +121 -0
- latte_py-0.1.0/tests/test_verify.py +173 -0
latte_py-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
Copyright © 2026 LicenseLatte
|
|
3
|
+
|
|
4
|
+
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:
|
|
5
|
+
|
|
6
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
7
|
+
|
|
8
|
+
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.
|
latte_py-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: latte-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for LicenseLatte license activation and offline verification
|
|
5
|
+
License: The MIT License (MIT)
|
|
6
|
+
Copyright © 2026 LicenseLatte
|
|
7
|
+
|
|
8
|
+
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:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
11
|
+
|
|
12
|
+
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.
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: cryptography>=42
|
|
17
|
+
Requires-Dist: requests>=2.31
|
|
18
|
+
Requires-Dist: platformdirs>=4
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
22
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
23
|
+
Requires-Dist: types-requests>=2.31; extra == "dev"
|
|
24
|
+
Requires-Dist: responses>=0.25; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# latte-py
|
|
28
|
+
|
|
29
|
+
Python SDK for [LicenseLatte](https://licenselatte.com), the software
|
|
30
|
+
licensing platform. An idiomatic, from-scratch Python implementation of
|
|
31
|
+
license activation and verification.
|
|
32
|
+
|
|
33
|
+
**Read the [Threat Model](#threat-model) section below before relying on
|
|
34
|
+
this package for anything security-sensitive.**
|
|
35
|
+
|
|
36
|
+
> [!NOTE]
|
|
37
|
+
> The Python SDK versions independently from the other language bindings and follows semver. It's currently on v0.x, meaning the public API may still change without a major version bump. It moves to 1.0.0 once the API is validated across real integrations.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## What this package verifies
|
|
42
|
+
|
|
43
|
+
LicenseLatte licenses are issued as a chain of Ed25519-signed JWTs:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
Master (root, hardcoded in the SDK)
|
|
47
|
+
-> Submaster cert
|
|
48
|
+
-> Project cert
|
|
49
|
+
-> Daily cert
|
|
50
|
+
-> Activation token (what you actually check against a machine)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Each link is a standard compact-serialization JWT
|
|
54
|
+
(`base64url(header).base64url(payload).base64url(signature)`, `alg: EdDSA`,
|
|
55
|
+
signed with Ed25519, see [RFC 8037]). Verifying a license means:
|
|
56
|
+
|
|
57
|
+
1. Verify the submaster cert's signature against the hardcoded master public
|
|
58
|
+
key, extract the submaster's own public key from its `spk` claim.
|
|
59
|
+
2. Verify the project cert's signature against the submaster's public key,
|
|
60
|
+
extract `ppk`.
|
|
61
|
+
3. Verify the daily cert's signature against the project's public key,
|
|
62
|
+
extract `dpk`.
|
|
63
|
+
4. Verify the activation token's signature against the daily key.
|
|
64
|
+
5. Cross-check the claims (project ID agreement, timing consistency between
|
|
65
|
+
the activation token and the daily cert that signed it).
|
|
66
|
+
6. Apply grace-period math: is the token still within its hard expiry, and,
|
|
67
|
+
if the device has been offline, still within its configured grace
|
|
68
|
+
window (30–90 days, chosen when the license is issued)?
|
|
69
|
+
|
|
70
|
+
This is a standard certificate-chain-of-trust design (the same shape as an
|
|
71
|
+
X.509 chain, just JWTs instead of X.509 certs), documented publicly here per
|
|
72
|
+
Kerckhoffs's principle: the *mechanism* is not the secret, the master
|
|
73
|
+
private key is. This SDK ships only the master **public** key; key rotation
|
|
74
|
+
cadence, key storage, and the tooling that issues certs are intentionally
|
|
75
|
+
not documented in any SDK repo.
|
|
76
|
+
|
|
77
|
+
[RFC 8037]: https://www.rfc-editor.org/rfc/rfc8037
|
|
78
|
+
|
|
79
|
+
## Cryptography
|
|
80
|
+
|
|
81
|
+
- **Ed25519** signature verification via `cryptography`'s hazmat primitives
|
|
82
|
+
(`cryptography.hazmat.primitives.asymmetric.ed25519`): an audited,
|
|
83
|
+
widely used library; no hand-rolled crypto anywhere in this package.
|
|
84
|
+
- JWT compact-serialization parsing is hand-written (`src/latte/jwt.py`):
|
|
85
|
+
this is structural (base64url + JSON), not cryptographic, so
|
|
86
|
+
implementing it directly instead of pulling in a general-purpose JWT
|
|
87
|
+
library is a reasonable, minimal-dependency choice for four call sites
|
|
88
|
+
with one fixed algorithm.
|
|
89
|
+
|
|
90
|
+
## Installation
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
pip install -e .
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Quick start: activating a license
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from latte import Config, Sdk, LatteError
|
|
100
|
+
|
|
101
|
+
sdk = Sdk(Config(app_id="pk_live_...")) # from the LicenseLatte dashboard
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
lic = sdk.activate("USER-PROVIDED-LICENSE-KEY", "opaque-machine-id")
|
|
105
|
+
print("license OK, expires", lic.expires_at)
|
|
106
|
+
if lic.in_grace_period:
|
|
107
|
+
print("warning: offline a while, please reconnect soon")
|
|
108
|
+
# Keep lic.activation_id around (in your own storage) so you can call
|
|
109
|
+
# sdk.renew(lic.activation_id, ...) later.
|
|
110
|
+
except LatteError as e:
|
|
111
|
+
print("activation failed:", e)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
By default, a successful `activate`/`renew` is written to an on-disk
|
|
115
|
+
cache, and a later `activate` call for the same key returns the cached
|
|
116
|
+
result without a network round trip as long as it's still valid. There's
|
|
117
|
+
no background renewal: call `renew` yourself on whatever schedule fits
|
|
118
|
+
your application. Set `Config(cache=False)` to disable the cache entirely
|
|
119
|
+
(e.g. a sandboxed environment with no writable filesystem).
|
|
120
|
+
|
|
121
|
+
## Checking a cached activation without a network call
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from latte import LicenseExpiredError, NotActivatedError
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
lic = sdk.check("opaque-machine-id")
|
|
128
|
+
print("license OK, expires", lic.expires_at)
|
|
129
|
+
except LicenseExpiredError:
|
|
130
|
+
print("license expired, please renew")
|
|
131
|
+
except NotActivatedError:
|
|
132
|
+
print("not activated, call activate()")
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## The cache file
|
|
136
|
+
|
|
137
|
+
By default, `Sdk` stores an activated license as a small JSON file under
|
|
138
|
+
your OS's per-user config directory (via `platformdirs`), named
|
|
139
|
+
`{project_key}.json`:
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"timestamp": 1700000000,
|
|
144
|
+
"token": "<activation JWT>",
|
|
145
|
+
"submaster": "<submaster cert JWT>",
|
|
146
|
+
"project": "<project cert JWT>",
|
|
147
|
+
"daily": "<daily cert JWT>"
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Writes go to a temp file in the same directory and get renamed into place,
|
|
152
|
+
so a crash or a concurrent write can't leave a half-written file behind.
|
|
153
|
+
`Config.cache_path` overrides the location if you want it somewhere else.
|
|
154
|
+
|
|
155
|
+
## Re-verifying a token you're storing yourself
|
|
156
|
+
|
|
157
|
+
If you'd rather manage persistence yourself instead of using the built-in
|
|
158
|
+
cache, `check_license_at`/`check_license` run the same verify+validate
|
|
159
|
+
pipeline `Sdk.activate`/`Sdk.check` do, against a token/chain you already
|
|
160
|
+
have:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
import time
|
|
164
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
165
|
+
from latte import check_license, CertChain, VerifyError, ValidateError
|
|
166
|
+
|
|
167
|
+
master_pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(MASTER_PUBLIC_KEY_HEX))
|
|
168
|
+
chain = CertChain(submaster=..., project=..., daily=...)
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
lic = check_license(master_pub, token, chain, machine_id)
|
|
172
|
+
print("license OK, expires", lic.expires_at)
|
|
173
|
+
if lic.in_grace_period:
|
|
174
|
+
print("warning: offline a while, please reconnect soon")
|
|
175
|
+
except VerifyError as e:
|
|
176
|
+
print("could not verify license:", e) # chain/signature/format problem
|
|
177
|
+
except ValidateError as e:
|
|
178
|
+
print("license rejected:", e) # verified fine, but expired/out of grace/wrong machine
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`check_license_at(..., now)` is also available for callers who want to pass
|
|
182
|
+
an explicit timestamp instead of the real system clock: this is what makes
|
|
183
|
+
this package's test suite fully reproducible against a fixed set of test
|
|
184
|
+
vectors in `testdata/`.
|
|
185
|
+
|
|
186
|
+
## Offline grace period
|
|
187
|
+
|
|
188
|
+
The grace period is an offline tolerance window measured **from the
|
|
189
|
+
license's last issuance/renewal**, not from its expiry:
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
issued_at ------------------------------------> expires_at
|
|
193
|
+
| |
|
|
194
|
+
└── grace_period ───┘
|
|
195
|
+
^ offline window
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
While `now <= issued_at + grace_period`, the license is still usable without
|
|
199
|
+
a network call. Once that deadline passes, verification raises
|
|
200
|
+
`GraceExpiredError`; once `now > expires_at`, it raises `HardExpiredError`
|
|
201
|
+
(checked first: hard expiry always wins).
|
|
202
|
+
|
|
203
|
+
`PublicLicense.in_grace_period` is a softer, earlier warning signal: it
|
|
204
|
+
turns `True` once more than 60 minutes have passed since the last
|
|
205
|
+
issuance/renewal without a fresh one arriving, while still inside the grace
|
|
206
|
+
window: surface it as a "please reconnect soon" hint, distinct from an
|
|
207
|
+
outright rejection.
|
|
208
|
+
|
|
209
|
+
## What this package does *not* do
|
|
210
|
+
|
|
211
|
+
OS-level machine-ID fingerprinting and background renewal scheduling are
|
|
212
|
+
intentionally out of scope. Pass your own machine-ID string into
|
|
213
|
+
`activate`/`renew`/`check`/`check_license`; only the opaque string compared
|
|
214
|
+
against the token's `mid` claim matters, not the algorithm that produces
|
|
215
|
+
it. For renewal, there's no scheduler here: `Sdk.renew` is the building
|
|
216
|
+
block; call it on a timer, in response to a UI action, or whatever fits
|
|
217
|
+
your application.
|
|
218
|
+
|
|
219
|
+
## Threat model
|
|
220
|
+
|
|
221
|
+
**Read this before you rely on `latte-py` for anything where tamper
|
|
222
|
+
resistance, not just cryptographic correctness, matters.**
|
|
223
|
+
|
|
224
|
+
This is a statement of fact about the architecture, not a disclaimer to
|
|
225
|
+
skim past:
|
|
226
|
+
|
|
227
|
+
- Python source and compiled bytecode (`.pyc`) ship human-readable or
|
|
228
|
+
trivially decompilable. Anyone with a text editor and basic familiarity
|
|
229
|
+
with Python can open your application's installed package, find the call
|
|
230
|
+
to `check_license`/`check_license_at`, and delete it, or monkeypatch
|
|
231
|
+
`latte.check_license` to always return a fabricated `PublicLicense`
|
|
232
|
+
before your application code ever runs. This requires no reverse
|
|
233
|
+
engineering tools beyond a text editor: this is fundamentally different
|
|
234
|
+
from a compiled binary (Go, Rust, C, C++, D), where bypassing a license
|
|
235
|
+
check requires actual binary patching or a debugger.
|
|
236
|
+
- This is a known, accepted tradeoff for an interpreted-environment SDK,
|
|
237
|
+
not a bug in this package. **No amount of obfuscation, code-signing the
|
|
238
|
+
`.py` files, or "clever" runtime tricks closes this gap**: Python's
|
|
239
|
+
execution model means the interpreter always has the actual source (or
|
|
240
|
+
bytecode, which trivially decompiles back to source) available to
|
|
241
|
+
inspect and modify at runtime.
|
|
242
|
+
- What this package *does* guarantee: the cryptographic verification
|
|
243
|
+
itself is correct. A forged license (wrong signature, broken chain,
|
|
244
|
+
tampered claims) will fail verification exactly as it would in
|
|
245
|
+
`latte-go`, `latte-rs`, or `latte-c`. What it does *not* guarantee is
|
|
246
|
+
that a determined user can't simply remove the call to this package
|
|
247
|
+
from your application entirely.
|
|
248
|
+
- **If this distinction matters for your deployment** (e.g. you're
|
|
249
|
+
protecting revenue from a motivated, technically capable user base, not
|
|
250
|
+
just casual copying), the mitigation is **server-side re-validation** —
|
|
251
|
+
but only if you draw the trust boundary in the right place. The
|
|
252
|
+
mitigation isn't "run the check again" (a re-run of `check_license` is
|
|
253
|
+
just as patchable as the first run, and a text editor doesn't care how
|
|
254
|
+
many times you call the function you're deleting). It's "run the check
|
|
255
|
+
somewhere the attacker's text editor can't reach": on your server,
|
|
256
|
+
invoked by your server's own code, gating a resource your server
|
|
257
|
+
actually controls (an API response, a file download, a feature flag
|
|
258
|
+
your backend decides). A locally-patched client can lie to itself all
|
|
259
|
+
day; it can't make your server hand over a server-mediated resource
|
|
260
|
+
without the server independently confirming a valid, unexpired license
|
|
261
|
+
first.
|
|
262
|
+
- This only holds if the server does its own verification. If your
|
|
263
|
+
server instead just *trusts* something the client reports (a
|
|
264
|
+
`"licensed": true` field, a header, a cached result), you've moved
|
|
265
|
+
the trust boundary back onto the attacker's machine and you're back
|
|
266
|
+
to square one — that flag is exactly as easy to fabricate as deleting
|
|
267
|
+
the local check was.
|
|
268
|
+
- `GracePeriod`/`in_grace_period` are what your server uses to decide
|
|
269
|
+
*when* to insist on a fresh activation check, not a mechanism that
|
|
270
|
+
makes a client-side check itself tamper-resistant.
|
|
271
|
+
- This tradeoff is specific to Python (and, separately, to Electron/JS;
|
|
272
|
+
see `latte-js`'s equivalent Threat Model section). The compiled SDKs
|
|
273
|
+
(`latte-go`, `latte-rs`, `latte-c`, and C++/D bindings) require actual
|
|
274
|
+
binary reverse engineering to bypass, which is a meaningfully higher bar
|
|
275
|
+
even though none of them are literally unbreakable either.
|
|
276
|
+
|
|
277
|
+
## Testing
|
|
278
|
+
|
|
279
|
+
```sh
|
|
280
|
+
pip install -e ".[dev]"
|
|
281
|
+
pytest
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Runs unit tests for the checksum algorithm and AppID parsing, chain
|
|
285
|
+
verification (valid chains, tampered signatures, broken intermediate links,
|
|
286
|
+
cross-check failures, clock-skew edge cases), grace-period math (including
|
|
287
|
+
exact boundary conditions), plus the full shared cross-language fixture
|
|
288
|
+
suite in `testdata/` (see `../latte-testvectors/README.md`).
|
|
289
|
+
|
|
290
|
+
```sh
|
|
291
|
+
ruff check .
|
|
292
|
+
mypy src
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## License
|
|
296
|
+
|
|
297
|
+
MIT, see [LICENSE](LICENSE).
|
latte_py-0.1.0/README.md
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# latte-py
|
|
2
|
+
|
|
3
|
+
Python SDK for [LicenseLatte](https://licenselatte.com), the software
|
|
4
|
+
licensing platform. An idiomatic, from-scratch Python implementation of
|
|
5
|
+
license activation and verification.
|
|
6
|
+
|
|
7
|
+
**Read the [Threat Model](#threat-model) section below before relying on
|
|
8
|
+
this package for anything security-sensitive.**
|
|
9
|
+
|
|
10
|
+
> [!NOTE]
|
|
11
|
+
> The Python SDK versions independently from the other language bindings and follows semver. It's currently on v0.x, meaning the public API may still change without a major version bump. It moves to 1.0.0 once the API is validated across real integrations.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## What this package verifies
|
|
16
|
+
|
|
17
|
+
LicenseLatte licenses are issued as a chain of Ed25519-signed JWTs:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
Master (root, hardcoded in the SDK)
|
|
21
|
+
-> Submaster cert
|
|
22
|
+
-> Project cert
|
|
23
|
+
-> Daily cert
|
|
24
|
+
-> Activation token (what you actually check against a machine)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Each link is a standard compact-serialization JWT
|
|
28
|
+
(`base64url(header).base64url(payload).base64url(signature)`, `alg: EdDSA`,
|
|
29
|
+
signed with Ed25519, see [RFC 8037]). Verifying a license means:
|
|
30
|
+
|
|
31
|
+
1. Verify the submaster cert's signature against the hardcoded master public
|
|
32
|
+
key, extract the submaster's own public key from its `spk` claim.
|
|
33
|
+
2. Verify the project cert's signature against the submaster's public key,
|
|
34
|
+
extract `ppk`.
|
|
35
|
+
3. Verify the daily cert's signature against the project's public key,
|
|
36
|
+
extract `dpk`.
|
|
37
|
+
4. Verify the activation token's signature against the daily key.
|
|
38
|
+
5. Cross-check the claims (project ID agreement, timing consistency between
|
|
39
|
+
the activation token and the daily cert that signed it).
|
|
40
|
+
6. Apply grace-period math: is the token still within its hard expiry, and,
|
|
41
|
+
if the device has been offline, still within its configured grace
|
|
42
|
+
window (30–90 days, chosen when the license is issued)?
|
|
43
|
+
|
|
44
|
+
This is a standard certificate-chain-of-trust design (the same shape as an
|
|
45
|
+
X.509 chain, just JWTs instead of X.509 certs), documented publicly here per
|
|
46
|
+
Kerckhoffs's principle: the *mechanism* is not the secret, the master
|
|
47
|
+
private key is. This SDK ships only the master **public** key; key rotation
|
|
48
|
+
cadence, key storage, and the tooling that issues certs are intentionally
|
|
49
|
+
not documented in any SDK repo.
|
|
50
|
+
|
|
51
|
+
[RFC 8037]: https://www.rfc-editor.org/rfc/rfc8037
|
|
52
|
+
|
|
53
|
+
## Cryptography
|
|
54
|
+
|
|
55
|
+
- **Ed25519** signature verification via `cryptography`'s hazmat primitives
|
|
56
|
+
(`cryptography.hazmat.primitives.asymmetric.ed25519`): an audited,
|
|
57
|
+
widely used library; no hand-rolled crypto anywhere in this package.
|
|
58
|
+
- JWT compact-serialization parsing is hand-written (`src/latte/jwt.py`):
|
|
59
|
+
this is structural (base64url + JSON), not cryptographic, so
|
|
60
|
+
implementing it directly instead of pulling in a general-purpose JWT
|
|
61
|
+
library is a reasonable, minimal-dependency choice for four call sites
|
|
62
|
+
with one fixed algorithm.
|
|
63
|
+
|
|
64
|
+
## Installation
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
pip install -e .
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Quick start: activating a license
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from latte import Config, Sdk, LatteError
|
|
74
|
+
|
|
75
|
+
sdk = Sdk(Config(app_id="pk_live_...")) # from the LicenseLatte dashboard
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
lic = sdk.activate("USER-PROVIDED-LICENSE-KEY", "opaque-machine-id")
|
|
79
|
+
print("license OK, expires", lic.expires_at)
|
|
80
|
+
if lic.in_grace_period:
|
|
81
|
+
print("warning: offline a while, please reconnect soon")
|
|
82
|
+
# Keep lic.activation_id around (in your own storage) so you can call
|
|
83
|
+
# sdk.renew(lic.activation_id, ...) later.
|
|
84
|
+
except LatteError as e:
|
|
85
|
+
print("activation failed:", e)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
By default, a successful `activate`/`renew` is written to an on-disk
|
|
89
|
+
cache, and a later `activate` call for the same key returns the cached
|
|
90
|
+
result without a network round trip as long as it's still valid. There's
|
|
91
|
+
no background renewal: call `renew` yourself on whatever schedule fits
|
|
92
|
+
your application. Set `Config(cache=False)` to disable the cache entirely
|
|
93
|
+
(e.g. a sandboxed environment with no writable filesystem).
|
|
94
|
+
|
|
95
|
+
## Checking a cached activation without a network call
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from latte import LicenseExpiredError, NotActivatedError
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
lic = sdk.check("opaque-machine-id")
|
|
102
|
+
print("license OK, expires", lic.expires_at)
|
|
103
|
+
except LicenseExpiredError:
|
|
104
|
+
print("license expired, please renew")
|
|
105
|
+
except NotActivatedError:
|
|
106
|
+
print("not activated, call activate()")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## The cache file
|
|
110
|
+
|
|
111
|
+
By default, `Sdk` stores an activated license as a small JSON file under
|
|
112
|
+
your OS's per-user config directory (via `platformdirs`), named
|
|
113
|
+
`{project_key}.json`:
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"timestamp": 1700000000,
|
|
118
|
+
"token": "<activation JWT>",
|
|
119
|
+
"submaster": "<submaster cert JWT>",
|
|
120
|
+
"project": "<project cert JWT>",
|
|
121
|
+
"daily": "<daily cert JWT>"
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Writes go to a temp file in the same directory and get renamed into place,
|
|
126
|
+
so a crash or a concurrent write can't leave a half-written file behind.
|
|
127
|
+
`Config.cache_path` overrides the location if you want it somewhere else.
|
|
128
|
+
|
|
129
|
+
## Re-verifying a token you're storing yourself
|
|
130
|
+
|
|
131
|
+
If you'd rather manage persistence yourself instead of using the built-in
|
|
132
|
+
cache, `check_license_at`/`check_license` run the same verify+validate
|
|
133
|
+
pipeline `Sdk.activate`/`Sdk.check` do, against a token/chain you already
|
|
134
|
+
have:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
import time
|
|
138
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
139
|
+
from latte import check_license, CertChain, VerifyError, ValidateError
|
|
140
|
+
|
|
141
|
+
master_pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(MASTER_PUBLIC_KEY_HEX))
|
|
142
|
+
chain = CertChain(submaster=..., project=..., daily=...)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
lic = check_license(master_pub, token, chain, machine_id)
|
|
146
|
+
print("license OK, expires", lic.expires_at)
|
|
147
|
+
if lic.in_grace_period:
|
|
148
|
+
print("warning: offline a while, please reconnect soon")
|
|
149
|
+
except VerifyError as e:
|
|
150
|
+
print("could not verify license:", e) # chain/signature/format problem
|
|
151
|
+
except ValidateError as e:
|
|
152
|
+
print("license rejected:", e) # verified fine, but expired/out of grace/wrong machine
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`check_license_at(..., now)` is also available for callers who want to pass
|
|
156
|
+
an explicit timestamp instead of the real system clock: this is what makes
|
|
157
|
+
this package's test suite fully reproducible against a fixed set of test
|
|
158
|
+
vectors in `testdata/`.
|
|
159
|
+
|
|
160
|
+
## Offline grace period
|
|
161
|
+
|
|
162
|
+
The grace period is an offline tolerance window measured **from the
|
|
163
|
+
license's last issuance/renewal**, not from its expiry:
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
issued_at ------------------------------------> expires_at
|
|
167
|
+
| |
|
|
168
|
+
└── grace_period ───┘
|
|
169
|
+
^ offline window
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
While `now <= issued_at + grace_period`, the license is still usable without
|
|
173
|
+
a network call. Once that deadline passes, verification raises
|
|
174
|
+
`GraceExpiredError`; once `now > expires_at`, it raises `HardExpiredError`
|
|
175
|
+
(checked first: hard expiry always wins).
|
|
176
|
+
|
|
177
|
+
`PublicLicense.in_grace_period` is a softer, earlier warning signal: it
|
|
178
|
+
turns `True` once more than 60 minutes have passed since the last
|
|
179
|
+
issuance/renewal without a fresh one arriving, while still inside the grace
|
|
180
|
+
window: surface it as a "please reconnect soon" hint, distinct from an
|
|
181
|
+
outright rejection.
|
|
182
|
+
|
|
183
|
+
## What this package does *not* do
|
|
184
|
+
|
|
185
|
+
OS-level machine-ID fingerprinting and background renewal scheduling are
|
|
186
|
+
intentionally out of scope. Pass your own machine-ID string into
|
|
187
|
+
`activate`/`renew`/`check`/`check_license`; only the opaque string compared
|
|
188
|
+
against the token's `mid` claim matters, not the algorithm that produces
|
|
189
|
+
it. For renewal, there's no scheduler here: `Sdk.renew` is the building
|
|
190
|
+
block; call it on a timer, in response to a UI action, or whatever fits
|
|
191
|
+
your application.
|
|
192
|
+
|
|
193
|
+
## Threat model
|
|
194
|
+
|
|
195
|
+
**Read this before you rely on `latte-py` for anything where tamper
|
|
196
|
+
resistance, not just cryptographic correctness, matters.**
|
|
197
|
+
|
|
198
|
+
This is a statement of fact about the architecture, not a disclaimer to
|
|
199
|
+
skim past:
|
|
200
|
+
|
|
201
|
+
- Python source and compiled bytecode (`.pyc`) ship human-readable or
|
|
202
|
+
trivially decompilable. Anyone with a text editor and basic familiarity
|
|
203
|
+
with Python can open your application's installed package, find the call
|
|
204
|
+
to `check_license`/`check_license_at`, and delete it, or monkeypatch
|
|
205
|
+
`latte.check_license` to always return a fabricated `PublicLicense`
|
|
206
|
+
before your application code ever runs. This requires no reverse
|
|
207
|
+
engineering tools beyond a text editor: this is fundamentally different
|
|
208
|
+
from a compiled binary (Go, Rust, C, C++, D), where bypassing a license
|
|
209
|
+
check requires actual binary patching or a debugger.
|
|
210
|
+
- This is a known, accepted tradeoff for an interpreted-environment SDK,
|
|
211
|
+
not a bug in this package. **No amount of obfuscation, code-signing the
|
|
212
|
+
`.py` files, or "clever" runtime tricks closes this gap**: Python's
|
|
213
|
+
execution model means the interpreter always has the actual source (or
|
|
214
|
+
bytecode, which trivially decompiles back to source) available to
|
|
215
|
+
inspect and modify at runtime.
|
|
216
|
+
- What this package *does* guarantee: the cryptographic verification
|
|
217
|
+
itself is correct. A forged license (wrong signature, broken chain,
|
|
218
|
+
tampered claims) will fail verification exactly as it would in
|
|
219
|
+
`latte-go`, `latte-rs`, or `latte-c`. What it does *not* guarantee is
|
|
220
|
+
that a determined user can't simply remove the call to this package
|
|
221
|
+
from your application entirely.
|
|
222
|
+
- **If this distinction matters for your deployment** (e.g. you're
|
|
223
|
+
protecting revenue from a motivated, technically capable user base, not
|
|
224
|
+
just casual copying), the mitigation is **server-side re-validation** —
|
|
225
|
+
but only if you draw the trust boundary in the right place. The
|
|
226
|
+
mitigation isn't "run the check again" (a re-run of `check_license` is
|
|
227
|
+
just as patchable as the first run, and a text editor doesn't care how
|
|
228
|
+
many times you call the function you're deleting). It's "run the check
|
|
229
|
+
somewhere the attacker's text editor can't reach": on your server,
|
|
230
|
+
invoked by your server's own code, gating a resource your server
|
|
231
|
+
actually controls (an API response, a file download, a feature flag
|
|
232
|
+
your backend decides). A locally-patched client can lie to itself all
|
|
233
|
+
day; it can't make your server hand over a server-mediated resource
|
|
234
|
+
without the server independently confirming a valid, unexpired license
|
|
235
|
+
first.
|
|
236
|
+
- This only holds if the server does its own verification. If your
|
|
237
|
+
server instead just *trusts* something the client reports (a
|
|
238
|
+
`"licensed": true` field, a header, a cached result), you've moved
|
|
239
|
+
the trust boundary back onto the attacker's machine and you're back
|
|
240
|
+
to square one — that flag is exactly as easy to fabricate as deleting
|
|
241
|
+
the local check was.
|
|
242
|
+
- `GracePeriod`/`in_grace_period` are what your server uses to decide
|
|
243
|
+
*when* to insist on a fresh activation check, not a mechanism that
|
|
244
|
+
makes a client-side check itself tamper-resistant.
|
|
245
|
+
- This tradeoff is specific to Python (and, separately, to Electron/JS;
|
|
246
|
+
see `latte-js`'s equivalent Threat Model section). The compiled SDKs
|
|
247
|
+
(`latte-go`, `latte-rs`, `latte-c`, and C++/D bindings) require actual
|
|
248
|
+
binary reverse engineering to bypass, which is a meaningfully higher bar
|
|
249
|
+
even though none of them are literally unbreakable either.
|
|
250
|
+
|
|
251
|
+
## Testing
|
|
252
|
+
|
|
253
|
+
```sh
|
|
254
|
+
pip install -e ".[dev]"
|
|
255
|
+
pytest
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Runs unit tests for the checksum algorithm and AppID parsing, chain
|
|
259
|
+
verification (valid chains, tampered signatures, broken intermediate links,
|
|
260
|
+
cross-check failures, clock-skew edge cases), grace-period math (including
|
|
261
|
+
exact boundary conditions), plus the full shared cross-language fixture
|
|
262
|
+
suite in `testdata/` (see `../latte-testvectors/README.md`).
|
|
263
|
+
|
|
264
|
+
```sh
|
|
265
|
+
ruff check .
|
|
266
|
+
mypy src
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## License
|
|
270
|
+
|
|
271
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "latte-py"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for LicenseLatte license activation and offline verification"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"cryptography>=42",
|
|
14
|
+
"requests>=2.31",
|
|
15
|
+
"platformdirs>=4",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=8",
|
|
21
|
+
"ruff>=0.6",
|
|
22
|
+
"mypy>=1.10",
|
|
23
|
+
"types-requests>=2.31",
|
|
24
|
+
"responses>=0.25",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["tests"]
|
|
32
|
+
|
|
33
|
+
[tool.ruff]
|
|
34
|
+
line-length = 100
|
|
35
|
+
|
|
36
|
+
[tool.mypy]
|
|
37
|
+
python_version = "3.10"
|
|
38
|
+
strict = true
|
latte_py-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Python SDK for LicenseLatte license activation and offline verification.
|
|
2
|
+
|
|
3
|
+
``Sdk`` activates and renews licenses over the network, with an optional
|
|
4
|
+
on-disk cache so a valid activation survives across process restarts
|
|
5
|
+
without a network call.
|
|
6
|
+
|
|
7
|
+
IMPORTANT: read the "Threat Model" section of README.md before relying on
|
|
8
|
+
this package for anything security-sensitive: Python bytecode is trivially
|
|
9
|
+
readable and patchable, so this package provides the same *cryptographic
|
|
10
|
+
correctness* as the compiled SDKs but not the same tamper resistance.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from . import appid, domain, errors, http, key, validate, verify
|
|
16
|
+
from .domain import CertChain
|
|
17
|
+
from .errors import (
|
|
18
|
+
InvalidKeyError,
|
|
19
|
+
InvalidProjectKeyError,
|
|
20
|
+
LatteError,
|
|
21
|
+
LicenseExpiredError,
|
|
22
|
+
LicenseNotFoundError,
|
|
23
|
+
NetworkError,
|
|
24
|
+
NotActivatedError,
|
|
25
|
+
SeatLimitError,
|
|
26
|
+
ServerError,
|
|
27
|
+
ValidateError,
|
|
28
|
+
VerifyError,
|
|
29
|
+
)
|
|
30
|
+
from .http import Config, Sdk
|
|
31
|
+
from .license import PublicLicense, check_license, check_license_at
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"appid",
|
|
35
|
+
"domain",
|
|
36
|
+
"errors",
|
|
37
|
+
"http",
|
|
38
|
+
"key",
|
|
39
|
+
"validate",
|
|
40
|
+
"verify",
|
|
41
|
+
"CertChain",
|
|
42
|
+
"Config",
|
|
43
|
+
"Sdk",
|
|
44
|
+
"PublicLicense",
|
|
45
|
+
"check_license_at",
|
|
46
|
+
"check_license",
|
|
47
|
+
"ValidateError",
|
|
48
|
+
"VerifyError",
|
|
49
|
+
"LatteError",
|
|
50
|
+
"InvalidKeyError",
|
|
51
|
+
"LicenseExpiredError",
|
|
52
|
+
"NotActivatedError",
|
|
53
|
+
"SeatLimitError",
|
|
54
|
+
"LicenseNotFoundError",
|
|
55
|
+
"InvalidProjectKeyError",
|
|
56
|
+
"NetworkError",
|
|
57
|
+
"ServerError",
|
|
58
|
+
]
|