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 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.
@@ -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).
@@ -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
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]