authforge-sdk 1.4.1__tar.gz → 2.0.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: authforge-sdk
3
- Version: 1.4.1
3
+ Version: 2.0.0
4
4
  Summary: Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses.
5
5
  Author: AuthForge
6
6
  License: MIT
@@ -38,7 +38,7 @@ Uses `cryptography` for Ed25519 verification. Works on Python 3.9+.
38
38
  2. **Run through the grace period.** By default the app keeps running on the signed session with no further network calls. A background check re-verifies the signature locally and fails when the session TTL expires. The grace period equals the session TTL: default 24h, and the server clamps requested values to 1h to 7d.
39
39
  3. **Optionally enable online check-ins.** With `online_heartbeat=True`, the SDK also calls `POST /auth/heartbeat` every `heartbeat_interval` seconds for fast revocation and concurrent-use detection.
40
40
 
41
- Separately, for machines that can **never** reach the internet, an operator can mint a signed **offline license file (`.authforge`)** in the AuthForge dashboard or Developer API. The SDK verifies it locally with your app public key: see [Offline license files](#offline-license-files-authforge).
41
+ Separately, for machines that can **never** reach the internet, an operator can mint a signed **offline license file (`.authforge`)** in the AuthForge dashboard or Developer API. The SDK verifies it locally with your app public key: see [Offline license files](#offline-license-files-authforge). If the machine can go online **once**, the end user can get that file themselves with `activate_offline()`: see [Self-serve offline activation](#self-serve-offline-activation).
42
42
 
43
43
  ## Features
44
44
 
@@ -50,8 +50,9 @@ Everything in this list ships in `authforge.py` today:
50
50
  - **Grace period by default**: after one successful activation, the app runs on the signed session (no network) until the TTL expires.
51
51
  - **Online check-ins** (opt-in): periodic `/auth/heartbeat` calls for fast revocation and concurrent-use detection.
52
52
  - **Offline license files (`.authforge`)**: `login_from_file()` / `verify_license_file()` verify a cloud-minted, Ed25519-signed file with zero network access for air-gapped machines.
53
+ - **Self-serve offline activation**: `activate_offline()` turns a license key into a lifetime, HWID-bound `.authforge` file with one online call, then runs offline from that file.
53
54
  - **Nonce anti-replay**: a fresh 128-bit nonce is sent on every request and the echoed nonce in the signed payload is checked before the response is accepted.
54
- - **HWID fingerprinting**: deterministic device hash from MAC + CPU + disk serial, with graceful per-component fallback.
55
+ - **HWID v2**: per-app device id derived from the OS machine id (macOS `IOPlatformUUID`, Linux `/etc/machine-id`, Windows `MachineGuid`), identical across all AuthForge SDKs for the same app and machine.
55
56
  - **`hwid_override`**: bind to any identity instead of the machine (for example `tg:<id>`, `discord:<id>`).
56
57
  - **Seat enforcement**: the server binds each HWID into a license's free slots up to `maxHwidSlots`; `hwid_count` / `max_hwid_slots` are surfaced on the result. A shared (unlimited-seat) key skips per-device binding.
57
58
  - **Self-ban** (`self_ban()`) for anti-tamper response, both pre-session and post-session.
@@ -118,7 +119,7 @@ client = AuthForgeClient(
118
119
  | `on_failure` | callable | `None` | Callback `(reason: str, exc: Exception | None)` on auth failure |
119
120
  | `request_timeout` | int | `15` | HTTP request timeout in seconds |
120
121
  | `ttl_seconds` | `int \| None` | `None` (server default: 86400) | The grace period duration: how long the app keeps running on the signed session without contacting AuthForge. Server clamps to `[3600, 604800]` (1h to 7d); preserved across check-in refreshes. |
121
- | `hwid_override` | `str \| None` | `None` | Optional custom hardware/subject identifier. When set to a non-empty value, the SDK uses it instead of machine fingerprinting. |
122
+ | `hwid_override` | `str \| None` | `None` | Optional custom hardware/subject identifier. When set to a non-empty value, the SDK uses it instead of deriving the HWID from the OS machine id. |
122
123
  | `online_heartbeat` | bool (keyword-only) | `False` | Enable online check-ins: periodic `POST /auth/heartbeat` for fast revocation and concurrent-use detection. |
123
124
 
124
125
  ### Identity-based binding example (Telegram/Discord)
@@ -189,12 +190,81 @@ if client.login_from_file("license.authforge"):
189
190
  print(client.get_license_variables())
190
191
  ```
191
192
 
192
- Collect the HWID from the same SDK build that will load the file: fingerprints are not portable across SDKs or languages. After `login_from_file()`, `get_session_kind()` returns `"offline"` (`"online"` after `login()`, `None` when logged out).
193
+ Since 2.0 every AuthForge SDK computes the same HWID for the same app id and machine, but HWIDs collected before upgrading to 2.0 no longer match: collect the HWID again after upgrading. After `login_from_file()`, `get_session_kind()` returns `"offline"` (`"online"` after `login()`, `None` when logged out).
193
194
 
194
195
  `verify_license_file()` (module function and client method) performs the same checks without touching client state. Failure codes, in check order: `bad_armor`, `bad_signature`, `unsupported_version`, `malformed_payload`, `wrong_app`, `expired`, `hwid_mismatch`. `login_from_file()` reports them through `on_failure("offline_login_failed", exc)` and returns `False`; it never calls `os._exit`.
195
196
 
196
197
  File format (version 1): PEM-style armor with informational headers, a base64 JSON payload (`v`, `appId`, `licenseKey`, `jti`, `kid`, `issuedAt`, `expiresAt`, `hwid` policy, optional label/variable snapshots) and a detached Ed25519 signature over the UTF-8 bytes of the base64 payload string - the same contract as `/auth/validate`. See `offline_license_vectors.json` for conformance vectors.
197
198
 
199
+ ## Self-serve offline activation
200
+
201
+ A separate mode for apps that sell a one-time license and then run with no network: the end user types the license key once, `activate_offline()` makes **one** online call (`POST /auth/offline/activate`) and gets back a lifetime `.authforge` file bound to this machine's HWID. From then on the app starts with `login_from_file()` and never contacts AuthForge again.
202
+
203
+ How it differs from air-gapped offline files:
204
+
205
+ - It **needs the App Secret**, because it is an online call. Air-gapped builds that only use `login_from_file()` still ship with `app_secret=None`.
206
+ - The app owner opts the app in (app settings in the dashboard). Only **perpetual**, **seat-limited** licenses can self-serve; each license can produce files for at most `maxHwidSlots` distinct machines, ever. HWID resets do not free those machine slots.
207
+ - **Billing:** a new machine costs the app owner **2 credits**, once. The same machine activating again (reinstall, deleted file) gets the identical file back for **free**. A refused activation costs nothing.
208
+ - **A refund or chargeback revokes the license online but cannot reach files already issued.** A lifetime file keeps working on its machine.
209
+
210
+ ```python
211
+ import os
212
+ import sys
213
+
214
+ from authforge import AuthForgeClient, AuthForgeError
215
+
216
+ LICENSE_PATH = os.path.join(os.path.expanduser("~"), ".myapp", "license.authforge")
217
+
218
+ client = AuthForgeClient(
219
+ app_id="YOUR_APP_ID",
220
+ app_secret="YOUR_APP_SECRET", # required for activate_offline (online call)
221
+ public_key="YOUR_PUBLIC_KEY",
222
+ on_failure=lambda reason, exc: None, # login_from_file reports why it failed here
223
+ )
224
+
225
+ # 1. No network, no credits: run from the file if it is there and valid.
226
+ if not (os.path.exists(LICENSE_PATH) and client.login_from_file(LICENSE_PATH)):
227
+ # 2. No file yet, or it fails (e.g. hwid_mismatch after a hardware change).
228
+ key = input("License key: ").strip()
229
+ try:
230
+ client.activate_offline(key, path=LICENSE_PATH)
231
+ except AuthForgeError as exc:
232
+ if exc.code == "offline_activation_limit_reached":
233
+ print("This license is active on its maximum number of machines. Please contact the vendor.")
234
+ elif exc.code in {"invalid_key", "revoked", "expired"}:
235
+ print("That license key is not valid.")
236
+ elif exc.code == "hwid_mismatch":
237
+ print("No free seat on this license for this machine.")
238
+ elif exc.transient:
239
+ print(f"Could not activate right now ({exc.code}). Check your connection and try again.")
240
+ else:
241
+ print(f"Activation failed: {exc.code}")
242
+ sys.exit(1)
243
+
244
+ print("Licensed:", client.get_license_variables())
245
+ ```
246
+
247
+ `activate_offline(license_key, path=None)` returns `True` on success and raises `AuthForgeError` otherwise. It:
248
+
249
+ 1. Fails fast with the usual "app_secret is required" `ValueError` (no request) when the client has no App Secret.
250
+ 2. Sends `appId`, `appSecret`, `licenseKey`, the same HWID `login()` sends and a fresh nonce, with the same retries as `login()` (`rate_limited` after 2s then 5s, one retry on a network error).
251
+ 3. Verifies the returned file with your public key(s), app id and this machine's HWID **before** writing anything. A file that fails raises `offline_file_rejected`; the message includes the verifier's reason (`bad_signature`, `wrong_app`, `hwid_mismatch`, ...).
252
+ 4. If `path` is set, writes the file byte-for-byte and atomically (temp file in the same directory, then rename), creating parent directories.
253
+ 5. Leaves the client authenticated exactly as `login_from_file()` does: `get_session_kind()` is `"offline"`, variables and `get_offline_license()` are set, no background checks start. `get_offline_license()["replay"]` is `True` when the server returned a file this machine already had.
254
+
255
+ On any failure nothing is written to `path` (an existing file there is left as it was), the call does not authenticate the client (an existing session is kept), and neither `on_failure` nor `os._exit` is called.
256
+
257
+ | Code | Kind | Meaning |
258
+ | --- | --- | --- |
259
+ | `offline_activation_disabled` | fatal | The app has not opted in to self-serve activation |
260
+ | `offline_activation_requires_perpetual` | fatal | The license has an expiry; only perpetual licenses can self-serve |
261
+ | `offline_activation_requires_seats` | fatal | Unlimited-seat (shared) licenses cannot self-serve |
262
+ | `offline_activation_limit_reached` | fatal | Every machine slot on the license is used; the buyer must contact the vendor |
263
+ | `offline_file_rejected` | fatal | SDK code: the returned file failed local verification; nothing was written |
264
+ | `hwid_mismatch` | fatal | No free seat on the license for this machine (server code, not the verifier's) |
265
+ | `file_write_failed` | transient | The verified file could not be written to `path`; activating again is free |
266
+ | `invalid_key`, `revoked`, `expired`, `no_credits`, `rate_limited`, `unexpected_response`, `network_error`, ... | as everywhere else | Existing codes keep their meaning and classification |
267
+
198
268
  ## Migrating from heartbeat_mode
199
269
 
200
270
  Earlier releases required `heartbeat_mode="LOCAL"` or `"SERVER"`. The argument is now optional and deprecated; it still works but emits a `DeprecationWarning`.
@@ -217,6 +287,7 @@ For code that still reads it, `client.heartbeat_mode` remains available and refl
217
287
 
218
288
  - **1 `login()` or `validate_license()` call = 1 credit** (one `/auth/validate` debit each).
219
289
  - **10 online check-ins on the same license = 1 credit** (billed every 10th successful `/auth/heartbeat`). Grace period checks are local and free.
290
+ - **1 new offline activation (`activate_offline()`) = 2 credits**, charged to the app owner; re-activating the same machine is free. `login_from_file()` is always free.
220
291
 
221
292
  A desktop app running 6h/day with online check-ins at a 15-minute interval burns ~3-4 credits/day. The server enforces `/auth/heartbeat` at 6 requests/minute per license key, so keep intervals at 10 seconds or higher and pick the interval based on how fast you need revocations to propagate (they always land on the **next** check-in).
222
293
 
@@ -226,8 +297,9 @@ A desktop app running 6h/day with online check-ins at a 15-minute interval burns
226
297
  |---|---|---|
227
298
  | `login(license_key)` | `bool` | Activates: validates the key online and stores the signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
228
299
  | `validate_license(license_key)` | `ValidateLicenseResult` | Same `/auth/validate` + signatures as `login`; does not store session or start background checks; returns a dict with `valid` / `code` and **never** calls `on_failure` or `os._exit` |
229
- | `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only) |
300
+ | `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only). Raises `ValueError` on any failure (a network failure as `AuthForgeError` `network_error` / `timeout`); never calls `on_failure` or `os._exit` |
230
301
  | `login_from_file(path_or_text)` | `bool` | Authorizes from an offline `.authforge` file with no network; never starts background checks; failures go to `on_failure("offline_login_failed", …)` |
302
+ | `activate_offline(license_key, path=None)` | `bool` | Self-serve offline activation: one online call returns a lifetime HWID-bound `.authforge` file, verified locally, written atomically to `path` and applied like `login_from_file`. Needs the App Secret; raises `AuthForgeError` on refusal; never calls `on_failure` or `os._exit` |
231
303
  | `verify_license_file(path_or_text, *, now=None)` | `VerifyLicenseFileResult` | Verifies a `.authforge` file with this client's app id / keys / HWID without changing state |
232
304
  | `get_offline_license()` | `dict \| None` | Metadata of the offline file in use (`jti`, `expires_at`, `hwid_policy`, …) |
233
305
  | `get_session_kind()` | `"online" \| "offline" \| None` | Which kind of session the client holds (`None` when logged out) |
@@ -247,7 +319,11 @@ If authentication fails (activation rejected, check-in fails, signature mismatch
247
319
  **`validate_license()`** does not trigger `on_failure` or `os._exit`: check `result["valid"]` and `result["code"]`.
248
320
 
249
321
  Recognized server errors (`KNOWN_SERVER_ERRORS`):
250
- `invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `demo_quota_exceeded`, `system_error`. Codes added to the server later are passed through unchanged.
322
+ `invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `demo_quota_exceeded`, `system_error`, and from `activate_offline()`: `offline_activation_disabled`, `offline_activation_requires_perpetual`, `offline_activation_requires_seats`, `offline_activation_limit_reached`, plus the SDK code `offline_file_rejected`. Codes added to the server later are passed through unchanged.
323
+
324
+ **`activate_offline()`** does not trigger `on_failure` or `os._exit` either: it raises `AuthForgeError` (see [Self-serve offline activation](#self-serve-offline-activation)).
325
+
326
+ **`self_ban()`** does not trigger `on_failure` or `os._exit` either: every failure raises, a server rejection as `ValueError(code)` and a network failure as `AuthForgeError` with `exc.code` `network_error` or `timeout`.
251
327
 
252
328
  Request retries are automatic inside the internal HTTP layer:
253
329
  - `rate_limited`, or HTTP 429 with no error code: retry after 2s, then 5s (max 3 attempts total)
@@ -266,10 +342,10 @@ A failed check-in only counts as an AuthForge verdict when the body is a JSON ob
266
342
 
267
343
  | Kind | Codes | What the SDK does |
268
344
  |---|---|---|
269
- | Fatal | `revoked`, `expired`, `hwid_mismatch` (the HWID is no longer bound to the license, for example after an HWID reset), `blocked` (HWID/IP blacklisted or not whitelisted), `session_expired` (also raised locally when the grace period or session TTL runs out), `malformed_request`, `app_disabled`, `invalid_app`, `signature_mismatch` | Clears the stored session (as `logout()` does) and stops background checks **before** calling `on_failure`, so the grace period cannot keep the app running on it. `on_failure` may call `login()` again. |
345
+ | Fatal | `revoked`, `expired`, `hwid_mismatch` (the HWID is no longer bound to the license, for example after an HWID reset), `blocked` (HWID/IP blacklisted or not whitelisted), `session_expired` (also raised locally when the grace period or session TTL runs out), `malformed_request`, `app_disabled`, `invalid_app`, `signature_mismatch` (plus the `activate_offline()`-only codes `offline_activation_*` and `offline_file_rejected`) | Clears the stored session (as `logout()` does) and stops background checks **before** calling `on_failure`, so the grace period cannot keep the app running on it. `on_failure` may call `login()` again. |
270
346
  | Transient | Everything else: `network_error`, `timeout`, `rate_limited`, `system_error`, `no_credits`, `demo_quota_exceeded`, `app_burn_cap_reached`, `bad_request`, `invalid_key`, every `http_error_<status>`, `invalid_json_response`, `unexpected_response`, `nonce_mismatch`, any unrecognized code | Keeps the session and checks in again on the next `heartbeat_interval`. Once the signed session's TTL has passed, the next transient failure is reported as a fatal `session_expired` instead. |
271
347
 
272
- Transient failures only keep checking in if `on_failure` returns normally. Without a callback, a transient failure prints `AuthForge: background check failed (<code>); retrying next interval` to stderr and check-ins continue; a fatal one, including the `session_expired` a transient failure becomes once the session TTL has passed, still ends the process with `os._exit(1)`. Heartbeat network failures are reported once, as `heartbeat_failed` with code `network_error` or `timeout`, not as a separate `network_error` reason.
348
+ Transient failures only keep checking in if `on_failure` returns normally. Without a callback, a transient failure prints `AuthForge: background check failed (<code>); retrying next interval` to stderr and check-ins continue; a fatal one, including the `session_expired` a transient failure becomes once the session TTL has passed, still ends the process with `os._exit(1)`. Network failures are reported once, with code `network_error` or `timeout`: as `heartbeat_failed` from check-ins and as `login_failed` from `login()`, never as a separate `network_error` reason. `activate_offline()` and `self_ban()` raise them as `AuthForgeError` instead.
273
349
 
274
350
  `on_failure` runs on the background heartbeat thread with no SDK lock held, so calling `logout()`, `is_authenticated()` or `login()` from it is safe. A check-in that is still in flight when you call `logout()` or `login()` is discarded: it cannot restore the old session or report a failure for it.
275
351
 
@@ -339,11 +415,12 @@ client.self_ban(
339
415
  - Uses post-session mode when a session token is available (`session_token` arg or current SDK session).
340
416
  - Falls back to pre-session mode using `license_key` + nonce + app secret.
341
417
  - In pre-session mode, revoke is forced off client-side to avoid unsafe key revocations.
418
+ - Every failure raises: a server rejection as `ValueError(code)`, a network failure (after one retry) as `AuthForgeError` with `exc.code` `network_error` or `timeout`. `self_ban()` never calls `on_failure` or `os._exit`, with or without a callback.
342
419
  - Not available after `login_from_file()`: offline sessions have no server session, so `self_ban()` with no explicit `license_key` / `session_token` raises `ValueError("offline_session")` without contacting the server.
343
420
 
344
421
  ## How It Works
345
422
 
346
- 1. **Activate**: `login()` uses `hwid_override` if provided; otherwise it collects a hardware fingerprint (MAC, CPU, disk serial). It then generates a random nonce and sends everything to the AuthForge API. The server validates the license key, binds the HWID, deducts a credit, and returns a signed payload with a TTL. The SDK verifies the Ed25519 signature and nonce to prevent replay attacks.
423
+ 1. **Activate**: `login()` uses `hwid_override` if provided; otherwise it derives the HWID from the OS machine id (see [Hardware ID](#hardware-id)). It then generates a random nonce and sends everything to the AuthForge API. The server validates the license key, binds the HWID, deducts a credit, and returns a signed payload with a TTL. The SDK verifies the Ed25519 signature and nonce to prevent replay attacks.
347
424
 
348
425
  2. **Background checks**: a daemon thread wakes at the configured interval. By default it enforces the grace period: it re-verifies the stored signature and checks expiry without network calls. With `online_heartbeat=True`, it instead sends `/auth/heartbeat` with a fresh nonce and verifies the response.
349
426
 
@@ -351,18 +428,29 @@ client.self_ban(
351
428
 
352
429
  ## Hardware ID
353
430
 
354
- The SDK generates a deterministic hardware fingerprint by hashing:
355
- - MAC address
356
- - CPU identifier
357
- - Disk serial number
431
+ The HWID (v2, since 2.0) is
432
+
433
+ `SHA256("authforge-hwid-v2|" + app_id + "|" + machine_id)` as lowercase hex,
434
+
435
+ where `machine_id` is the operating system's own machine id, trimmed and lowercased:
436
+
437
+ | OS | Source |
438
+ | -- | ------ |
439
+ | macOS | `IOPlatformUUID` (`/usr/sbin/ioreg -rd1 -c IOPlatformExpertDevice`) |
440
+ | Linux | `/etc/machine-id`, then `/var/lib/dbus/machine-id` |
441
+ | Windows | `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (64-bit registry view, via `winreg`) |
442
+
443
+ These ids survive network changes, renames, disk usage and USB devices. Including the app id means two different apps on the same machine get unrelated HWIDs. Every AuthForge SDK computes the same value for the same app id and machine; `hwid_vectors.json` pins this. The HWID is sent with every auth request so the server can enforce per-device license limits.
444
+
445
+ If no valid OS id can be read (for example a container without `/etc/machine-id`), the SDK falls back to `legacy:<the pre-2.0 fingerprint of MAC, CPU and disk serial>` as the machine id. Containers built from one image can also share a baked-in machine id, so for containers prefer `hwid_override` with an identifier you control.
358
446
 
359
- Each component falls back gracefully if it can't be read (e.g. permissions issues). The HWID is sent with every auth request so the server can enforce per-device license limits.
447
+ The helpers `hwid_from_machine_id`, `normalize_machine_id`, `parse_ioreg_platform_uuid` and `parse_reg_query_machine_guid` are exported for tooling and tests.
360
448
 
361
449
  For non-device identities (for example Telegram users), pass `hwid_override` such as `tg:<user_id>`.
362
450
 
363
451
  ## Test Vectors
364
452
 
365
- The shared `test_vectors.json` file validates cross-language Ed25519 verification behavior. `offline_license_vectors.json` (generated from a fixed test seed in the Node SDK repo) is the cross-SDK conformance suite for `.authforge` offline license files: good files plus the `bad_signature`, wrong key, `wrong_app`, `expired`, `hwid_mismatch`, `unsupported_version` and `bad_armor` rejects.
453
+ The shared `test_vectors.json` file validates cross-language Ed25519 verification behavior. `offline_license_vectors.json` (generated from a fixed test seed in the Node SDK repo) is the cross-SDK conformance suite for `.authforge` offline license files: good files plus the `bad_signature`, wrong key, `wrong_app`, `expired`, `hwid_mismatch`, `unsupported_version` and `bad_armor` rejects. `self_serve_activation_vectors.json` is the cross-SDK suite for `activate_offline()`: mocked `/auth/offline/activate` responses with the expected written file, session and error code. `hwid_vectors.json` (generated by `authforge-node/generate_hwid_vectors.mjs`) is the cross-SDK suite for HWID v2: derivation, machine id normalization, and the `ioreg` / `reg query` parsers.
366
454
 
367
455
  ## Requirements
368
456
 
@@ -10,7 +10,7 @@ Uses `cryptography` for Ed25519 verification. Works on Python 3.9+.
10
10
  2. **Run through the grace period.** By default the app keeps running on the signed session with no further network calls. A background check re-verifies the signature locally and fails when the session TTL expires. The grace period equals the session TTL: default 24h, and the server clamps requested values to 1h to 7d.
11
11
  3. **Optionally enable online check-ins.** With `online_heartbeat=True`, the SDK also calls `POST /auth/heartbeat` every `heartbeat_interval` seconds for fast revocation and concurrent-use detection.
12
12
 
13
- Separately, for machines that can **never** reach the internet, an operator can mint a signed **offline license file (`.authforge`)** in the AuthForge dashboard or Developer API. The SDK verifies it locally with your app public key: see [Offline license files](#offline-license-files-authforge).
13
+ Separately, for machines that can **never** reach the internet, an operator can mint a signed **offline license file (`.authforge`)** in the AuthForge dashboard or Developer API. The SDK verifies it locally with your app public key: see [Offline license files](#offline-license-files-authforge). If the machine can go online **once**, the end user can get that file themselves with `activate_offline()`: see [Self-serve offline activation](#self-serve-offline-activation).
14
14
 
15
15
  ## Features
16
16
 
@@ -22,8 +22,9 @@ Everything in this list ships in `authforge.py` today:
22
22
  - **Grace period by default**: after one successful activation, the app runs on the signed session (no network) until the TTL expires.
23
23
  - **Online check-ins** (opt-in): periodic `/auth/heartbeat` calls for fast revocation and concurrent-use detection.
24
24
  - **Offline license files (`.authforge`)**: `login_from_file()` / `verify_license_file()` verify a cloud-minted, Ed25519-signed file with zero network access for air-gapped machines.
25
+ - **Self-serve offline activation**: `activate_offline()` turns a license key into a lifetime, HWID-bound `.authforge` file with one online call, then runs offline from that file.
25
26
  - **Nonce anti-replay**: a fresh 128-bit nonce is sent on every request and the echoed nonce in the signed payload is checked before the response is accepted.
26
- - **HWID fingerprinting**: deterministic device hash from MAC + CPU + disk serial, with graceful per-component fallback.
27
+ - **HWID v2**: per-app device id derived from the OS machine id (macOS `IOPlatformUUID`, Linux `/etc/machine-id`, Windows `MachineGuid`), identical across all AuthForge SDKs for the same app and machine.
27
28
  - **`hwid_override`**: bind to any identity instead of the machine (for example `tg:<id>`, `discord:<id>`).
28
29
  - **Seat enforcement**: the server binds each HWID into a license's free slots up to `maxHwidSlots`; `hwid_count` / `max_hwid_slots` are surfaced on the result. A shared (unlimited-seat) key skips per-device binding.
29
30
  - **Self-ban** (`self_ban()`) for anti-tamper response, both pre-session and post-session.
@@ -90,7 +91,7 @@ client = AuthForgeClient(
90
91
  | `on_failure` | callable | `None` | Callback `(reason: str, exc: Exception | None)` on auth failure |
91
92
  | `request_timeout` | int | `15` | HTTP request timeout in seconds |
92
93
  | `ttl_seconds` | `int \| None` | `None` (server default: 86400) | The grace period duration: how long the app keeps running on the signed session without contacting AuthForge. Server clamps to `[3600, 604800]` (1h to 7d); preserved across check-in refreshes. |
93
- | `hwid_override` | `str \| None` | `None` | Optional custom hardware/subject identifier. When set to a non-empty value, the SDK uses it instead of machine fingerprinting. |
94
+ | `hwid_override` | `str \| None` | `None` | Optional custom hardware/subject identifier. When set to a non-empty value, the SDK uses it instead of deriving the HWID from the OS machine id. |
94
95
  | `online_heartbeat` | bool (keyword-only) | `False` | Enable online check-ins: periodic `POST /auth/heartbeat` for fast revocation and concurrent-use detection. |
95
96
 
96
97
  ### Identity-based binding example (Telegram/Discord)
@@ -161,12 +162,81 @@ if client.login_from_file("license.authforge"):
161
162
  print(client.get_license_variables())
162
163
  ```
163
164
 
164
- Collect the HWID from the same SDK build that will load the file: fingerprints are not portable across SDKs or languages. After `login_from_file()`, `get_session_kind()` returns `"offline"` (`"online"` after `login()`, `None` when logged out).
165
+ Since 2.0 every AuthForge SDK computes the same HWID for the same app id and machine, but HWIDs collected before upgrading to 2.0 no longer match: collect the HWID again after upgrading. After `login_from_file()`, `get_session_kind()` returns `"offline"` (`"online"` after `login()`, `None` when logged out).
165
166
 
166
167
  `verify_license_file()` (module function and client method) performs the same checks without touching client state. Failure codes, in check order: `bad_armor`, `bad_signature`, `unsupported_version`, `malformed_payload`, `wrong_app`, `expired`, `hwid_mismatch`. `login_from_file()` reports them through `on_failure("offline_login_failed", exc)` and returns `False`; it never calls `os._exit`.
167
168
 
168
169
  File format (version 1): PEM-style armor with informational headers, a base64 JSON payload (`v`, `appId`, `licenseKey`, `jti`, `kid`, `issuedAt`, `expiresAt`, `hwid` policy, optional label/variable snapshots) and a detached Ed25519 signature over the UTF-8 bytes of the base64 payload string - the same contract as `/auth/validate`. See `offline_license_vectors.json` for conformance vectors.
169
170
 
171
+ ## Self-serve offline activation
172
+
173
+ A separate mode for apps that sell a one-time license and then run with no network: the end user types the license key once, `activate_offline()` makes **one** online call (`POST /auth/offline/activate`) and gets back a lifetime `.authforge` file bound to this machine's HWID. From then on the app starts with `login_from_file()` and never contacts AuthForge again.
174
+
175
+ How it differs from air-gapped offline files:
176
+
177
+ - It **needs the App Secret**, because it is an online call. Air-gapped builds that only use `login_from_file()` still ship with `app_secret=None`.
178
+ - The app owner opts the app in (app settings in the dashboard). Only **perpetual**, **seat-limited** licenses can self-serve; each license can produce files for at most `maxHwidSlots` distinct machines, ever. HWID resets do not free those machine slots.
179
+ - **Billing:** a new machine costs the app owner **2 credits**, once. The same machine activating again (reinstall, deleted file) gets the identical file back for **free**. A refused activation costs nothing.
180
+ - **A refund or chargeback revokes the license online but cannot reach files already issued.** A lifetime file keeps working on its machine.
181
+
182
+ ```python
183
+ import os
184
+ import sys
185
+
186
+ from authforge import AuthForgeClient, AuthForgeError
187
+
188
+ LICENSE_PATH = os.path.join(os.path.expanduser("~"), ".myapp", "license.authforge")
189
+
190
+ client = AuthForgeClient(
191
+ app_id="YOUR_APP_ID",
192
+ app_secret="YOUR_APP_SECRET", # required for activate_offline (online call)
193
+ public_key="YOUR_PUBLIC_KEY",
194
+ on_failure=lambda reason, exc: None, # login_from_file reports why it failed here
195
+ )
196
+
197
+ # 1. No network, no credits: run from the file if it is there and valid.
198
+ if not (os.path.exists(LICENSE_PATH) and client.login_from_file(LICENSE_PATH)):
199
+ # 2. No file yet, or it fails (e.g. hwid_mismatch after a hardware change).
200
+ key = input("License key: ").strip()
201
+ try:
202
+ client.activate_offline(key, path=LICENSE_PATH)
203
+ except AuthForgeError as exc:
204
+ if exc.code == "offline_activation_limit_reached":
205
+ print("This license is active on its maximum number of machines. Please contact the vendor.")
206
+ elif exc.code in {"invalid_key", "revoked", "expired"}:
207
+ print("That license key is not valid.")
208
+ elif exc.code == "hwid_mismatch":
209
+ print("No free seat on this license for this machine.")
210
+ elif exc.transient:
211
+ print(f"Could not activate right now ({exc.code}). Check your connection and try again.")
212
+ else:
213
+ print(f"Activation failed: {exc.code}")
214
+ sys.exit(1)
215
+
216
+ print("Licensed:", client.get_license_variables())
217
+ ```
218
+
219
+ `activate_offline(license_key, path=None)` returns `True` on success and raises `AuthForgeError` otherwise. It:
220
+
221
+ 1. Fails fast with the usual "app_secret is required" `ValueError` (no request) when the client has no App Secret.
222
+ 2. Sends `appId`, `appSecret`, `licenseKey`, the same HWID `login()` sends and a fresh nonce, with the same retries as `login()` (`rate_limited` after 2s then 5s, one retry on a network error).
223
+ 3. Verifies the returned file with your public key(s), app id and this machine's HWID **before** writing anything. A file that fails raises `offline_file_rejected`; the message includes the verifier's reason (`bad_signature`, `wrong_app`, `hwid_mismatch`, ...).
224
+ 4. If `path` is set, writes the file byte-for-byte and atomically (temp file in the same directory, then rename), creating parent directories.
225
+ 5. Leaves the client authenticated exactly as `login_from_file()` does: `get_session_kind()` is `"offline"`, variables and `get_offline_license()` are set, no background checks start. `get_offline_license()["replay"]` is `True` when the server returned a file this machine already had.
226
+
227
+ On any failure nothing is written to `path` (an existing file there is left as it was), the call does not authenticate the client (an existing session is kept), and neither `on_failure` nor `os._exit` is called.
228
+
229
+ | Code | Kind | Meaning |
230
+ | --- | --- | --- |
231
+ | `offline_activation_disabled` | fatal | The app has not opted in to self-serve activation |
232
+ | `offline_activation_requires_perpetual` | fatal | The license has an expiry; only perpetual licenses can self-serve |
233
+ | `offline_activation_requires_seats` | fatal | Unlimited-seat (shared) licenses cannot self-serve |
234
+ | `offline_activation_limit_reached` | fatal | Every machine slot on the license is used; the buyer must contact the vendor |
235
+ | `offline_file_rejected` | fatal | SDK code: the returned file failed local verification; nothing was written |
236
+ | `hwid_mismatch` | fatal | No free seat on the license for this machine (server code, not the verifier's) |
237
+ | `file_write_failed` | transient | The verified file could not be written to `path`; activating again is free |
238
+ | `invalid_key`, `revoked`, `expired`, `no_credits`, `rate_limited`, `unexpected_response`, `network_error`, ... | as everywhere else | Existing codes keep their meaning and classification |
239
+
170
240
  ## Migrating from heartbeat_mode
171
241
 
172
242
  Earlier releases required `heartbeat_mode="LOCAL"` or `"SERVER"`. The argument is now optional and deprecated; it still works but emits a `DeprecationWarning`.
@@ -189,6 +259,7 @@ For code that still reads it, `client.heartbeat_mode` remains available and refl
189
259
 
190
260
  - **1 `login()` or `validate_license()` call = 1 credit** (one `/auth/validate` debit each).
191
261
  - **10 online check-ins on the same license = 1 credit** (billed every 10th successful `/auth/heartbeat`). Grace period checks are local and free.
262
+ - **1 new offline activation (`activate_offline()`) = 2 credits**, charged to the app owner; re-activating the same machine is free. `login_from_file()` is always free.
192
263
 
193
264
  A desktop app running 6h/day with online check-ins at a 15-minute interval burns ~3-4 credits/day. The server enforces `/auth/heartbeat` at 6 requests/minute per license key, so keep intervals at 10 seconds or higher and pick the interval based on how fast you need revocations to propagate (they always land on the **next** check-in).
194
265
 
@@ -198,8 +269,9 @@ A desktop app running 6h/day with online check-ins at a 15-minute interval burns
198
269
  |---|---|---|
199
270
  | `login(license_key)` | `bool` | Activates: validates the key online and stores the signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
200
271
  | `validate_license(license_key)` | `ValidateLicenseResult` | Same `/auth/validate` + signatures as `login`; does not store session or start background checks; returns a dict with `valid` / `code` and **never** calls `on_failure` or `os._exit` |
201
- | `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only) |
272
+ | `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only). Raises `ValueError` on any failure (a network failure as `AuthForgeError` `network_error` / `timeout`); never calls `on_failure` or `os._exit` |
202
273
  | `login_from_file(path_or_text)` | `bool` | Authorizes from an offline `.authforge` file with no network; never starts background checks; failures go to `on_failure("offline_login_failed", …)` |
274
+ | `activate_offline(license_key, path=None)` | `bool` | Self-serve offline activation: one online call returns a lifetime HWID-bound `.authforge` file, verified locally, written atomically to `path` and applied like `login_from_file`. Needs the App Secret; raises `AuthForgeError` on refusal; never calls `on_failure` or `os._exit` |
203
275
  | `verify_license_file(path_or_text, *, now=None)` | `VerifyLicenseFileResult` | Verifies a `.authforge` file with this client's app id / keys / HWID without changing state |
204
276
  | `get_offline_license()` | `dict \| None` | Metadata of the offline file in use (`jti`, `expires_at`, `hwid_policy`, …) |
205
277
  | `get_session_kind()` | `"online" \| "offline" \| None` | Which kind of session the client holds (`None` when logged out) |
@@ -219,7 +291,11 @@ If authentication fails (activation rejected, check-in fails, signature mismatch
219
291
  **`validate_license()`** does not trigger `on_failure` or `os._exit`: check `result["valid"]` and `result["code"]`.
220
292
 
221
293
  Recognized server errors (`KNOWN_SERVER_ERRORS`):
222
- `invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `demo_quota_exceeded`, `system_error`. Codes added to the server later are passed through unchanged.
294
+ `invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `demo_quota_exceeded`, `system_error`, and from `activate_offline()`: `offline_activation_disabled`, `offline_activation_requires_perpetual`, `offline_activation_requires_seats`, `offline_activation_limit_reached`, plus the SDK code `offline_file_rejected`. Codes added to the server later are passed through unchanged.
295
+
296
+ **`activate_offline()`** does not trigger `on_failure` or `os._exit` either: it raises `AuthForgeError` (see [Self-serve offline activation](#self-serve-offline-activation)).
297
+
298
+ **`self_ban()`** does not trigger `on_failure` or `os._exit` either: every failure raises, a server rejection as `ValueError(code)` and a network failure as `AuthForgeError` with `exc.code` `network_error` or `timeout`.
223
299
 
224
300
  Request retries are automatic inside the internal HTTP layer:
225
301
  - `rate_limited`, or HTTP 429 with no error code: retry after 2s, then 5s (max 3 attempts total)
@@ -238,10 +314,10 @@ A failed check-in only counts as an AuthForge verdict when the body is a JSON ob
238
314
 
239
315
  | Kind | Codes | What the SDK does |
240
316
  |---|---|---|
241
- | Fatal | `revoked`, `expired`, `hwid_mismatch` (the HWID is no longer bound to the license, for example after an HWID reset), `blocked` (HWID/IP blacklisted or not whitelisted), `session_expired` (also raised locally when the grace period or session TTL runs out), `malformed_request`, `app_disabled`, `invalid_app`, `signature_mismatch` | Clears the stored session (as `logout()` does) and stops background checks **before** calling `on_failure`, so the grace period cannot keep the app running on it. `on_failure` may call `login()` again. |
317
+ | Fatal | `revoked`, `expired`, `hwid_mismatch` (the HWID is no longer bound to the license, for example after an HWID reset), `blocked` (HWID/IP blacklisted or not whitelisted), `session_expired` (also raised locally when the grace period or session TTL runs out), `malformed_request`, `app_disabled`, `invalid_app`, `signature_mismatch` (plus the `activate_offline()`-only codes `offline_activation_*` and `offline_file_rejected`) | Clears the stored session (as `logout()` does) and stops background checks **before** calling `on_failure`, so the grace period cannot keep the app running on it. `on_failure` may call `login()` again. |
242
318
  | Transient | Everything else: `network_error`, `timeout`, `rate_limited`, `system_error`, `no_credits`, `demo_quota_exceeded`, `app_burn_cap_reached`, `bad_request`, `invalid_key`, every `http_error_<status>`, `invalid_json_response`, `unexpected_response`, `nonce_mismatch`, any unrecognized code | Keeps the session and checks in again on the next `heartbeat_interval`. Once the signed session's TTL has passed, the next transient failure is reported as a fatal `session_expired` instead. |
243
319
 
244
- Transient failures only keep checking in if `on_failure` returns normally. Without a callback, a transient failure prints `AuthForge: background check failed (<code>); retrying next interval` to stderr and check-ins continue; a fatal one, including the `session_expired` a transient failure becomes once the session TTL has passed, still ends the process with `os._exit(1)`. Heartbeat network failures are reported once, as `heartbeat_failed` with code `network_error` or `timeout`, not as a separate `network_error` reason.
320
+ Transient failures only keep checking in if `on_failure` returns normally. Without a callback, a transient failure prints `AuthForge: background check failed (<code>); retrying next interval` to stderr and check-ins continue; a fatal one, including the `session_expired` a transient failure becomes once the session TTL has passed, still ends the process with `os._exit(1)`. Network failures are reported once, with code `network_error` or `timeout`: as `heartbeat_failed` from check-ins and as `login_failed` from `login()`, never as a separate `network_error` reason. `activate_offline()` and `self_ban()` raise them as `AuthForgeError` instead.
245
321
 
246
322
  `on_failure` runs on the background heartbeat thread with no SDK lock held, so calling `logout()`, `is_authenticated()` or `login()` from it is safe. A check-in that is still in flight when you call `logout()` or `login()` is discarded: it cannot restore the old session or report a failure for it.
247
323
 
@@ -311,11 +387,12 @@ client.self_ban(
311
387
  - Uses post-session mode when a session token is available (`session_token` arg or current SDK session).
312
388
  - Falls back to pre-session mode using `license_key` + nonce + app secret.
313
389
  - In pre-session mode, revoke is forced off client-side to avoid unsafe key revocations.
390
+ - Every failure raises: a server rejection as `ValueError(code)`, a network failure (after one retry) as `AuthForgeError` with `exc.code` `network_error` or `timeout`. `self_ban()` never calls `on_failure` or `os._exit`, with or without a callback.
314
391
  - Not available after `login_from_file()`: offline sessions have no server session, so `self_ban()` with no explicit `license_key` / `session_token` raises `ValueError("offline_session")` without contacting the server.
315
392
 
316
393
  ## How It Works
317
394
 
318
- 1. **Activate**: `login()` uses `hwid_override` if provided; otherwise it collects a hardware fingerprint (MAC, CPU, disk serial). It then generates a random nonce and sends everything to the AuthForge API. The server validates the license key, binds the HWID, deducts a credit, and returns a signed payload with a TTL. The SDK verifies the Ed25519 signature and nonce to prevent replay attacks.
395
+ 1. **Activate**: `login()` uses `hwid_override` if provided; otherwise it derives the HWID from the OS machine id (see [Hardware ID](#hardware-id)). It then generates a random nonce and sends everything to the AuthForge API. The server validates the license key, binds the HWID, deducts a credit, and returns a signed payload with a TTL. The SDK verifies the Ed25519 signature and nonce to prevent replay attacks.
319
396
 
320
397
  2. **Background checks**: a daemon thread wakes at the configured interval. By default it enforces the grace period: it re-verifies the stored signature and checks expiry without network calls. With `online_heartbeat=True`, it instead sends `/auth/heartbeat` with a fresh nonce and verifies the response.
321
398
 
@@ -323,18 +400,29 @@ client.self_ban(
323
400
 
324
401
  ## Hardware ID
325
402
 
326
- The SDK generates a deterministic hardware fingerprint by hashing:
327
- - MAC address
328
- - CPU identifier
329
- - Disk serial number
403
+ The HWID (v2, since 2.0) is
404
+
405
+ `SHA256("authforge-hwid-v2|" + app_id + "|" + machine_id)` as lowercase hex,
406
+
407
+ where `machine_id` is the operating system's own machine id, trimmed and lowercased:
408
+
409
+ | OS | Source |
410
+ | -- | ------ |
411
+ | macOS | `IOPlatformUUID` (`/usr/sbin/ioreg -rd1 -c IOPlatformExpertDevice`) |
412
+ | Linux | `/etc/machine-id`, then `/var/lib/dbus/machine-id` |
413
+ | Windows | `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (64-bit registry view, via `winreg`) |
414
+
415
+ These ids survive network changes, renames, disk usage and USB devices. Including the app id means two different apps on the same machine get unrelated HWIDs. Every AuthForge SDK computes the same value for the same app id and machine; `hwid_vectors.json` pins this. The HWID is sent with every auth request so the server can enforce per-device license limits.
416
+
417
+ If no valid OS id can be read (for example a container without `/etc/machine-id`), the SDK falls back to `legacy:<the pre-2.0 fingerprint of MAC, CPU and disk serial>` as the machine id. Containers built from one image can also share a baked-in machine id, so for containers prefer `hwid_override` with an identifier you control.
330
418
 
331
- Each component falls back gracefully if it can't be read (e.g. permissions issues). The HWID is sent with every auth request so the server can enforce per-device license limits.
419
+ The helpers `hwid_from_machine_id`, `normalize_machine_id`, `parse_ioreg_platform_uuid` and `parse_reg_query_machine_guid` are exported for tooling and tests.
332
420
 
333
421
  For non-device identities (for example Telegram users), pass `hwid_override` such as `tg:<user_id>`.
334
422
 
335
423
  ## Test Vectors
336
424
 
337
- The shared `test_vectors.json` file validates cross-language Ed25519 verification behavior. `offline_license_vectors.json` (generated from a fixed test seed in the Node SDK repo) is the cross-SDK conformance suite for `.authforge` offline license files: good files plus the `bad_signature`, wrong key, `wrong_app`, `expired`, `hwid_mismatch`, `unsupported_version` and `bad_armor` rejects.
425
+ The shared `test_vectors.json` file validates cross-language Ed25519 verification behavior. `offline_license_vectors.json` (generated from a fixed test seed in the Node SDK repo) is the cross-SDK conformance suite for `.authforge` offline license files: good files plus the `bad_signature`, wrong key, `wrong_app`, `expired`, `hwid_mismatch`, `unsupported_version` and `bad_armor` rejects. `self_serve_activation_vectors.json` is the cross-SDK suite for `activate_offline()`: mocked `/auth/offline/activate` responses with the expected written file, session and error code. `hwid_vectors.json` (generated by `authforge-node/generate_hwid_vectors.mjs`) is the cross-SDK suite for HWID v2: derivation, machine id normalization, and the `ioreg` / `reg query` parsers.
338
426
 
339
427
  ## Requirements
340
428
 
@@ -88,7 +88,7 @@ _ACTIVATION_REQUEST_VERSION = 1
88
88
  _ACTIVATION_REQUEST_TYP = "authforge-activation-request"
89
89
  _BEGIN_ACTIVATION_REQUEST = "-----BEGIN AUTHFORGE ACTIVATION REQUEST-----"
90
90
  _END_ACTIVATION_REQUEST = "-----END AUTHFORGE ACTIVATION REQUEST-----"
91
- _SDK_TAG = "python/1.4.1"
91
+ _SDK_TAG = "python/2.0.0"
92
92
  _ARMOR_LINE_WIDTH = 64
93
93
  _MAX_REQUEST_HWID = 256
94
94
  _MAX_REQUEST_MACHINE_NAME = 128
@@ -477,6 +477,33 @@ def _verify_offline_signature(payload_base64: str, signature_base64: str, keys:
477
477
  return False
478
478
 
479
479
 
480
+ def _write_file_atomic(path: str, data: bytes) -> None:
481
+ """Write ``data`` byte-for-byte to ``path`` via a temp file in the same
482
+ directory and ``os.replace``, creating parent directories. A failure never
483
+ leaves a partial file at ``path`` or changes an existing one."""
484
+ directory = os.path.dirname(os.path.abspath(path))
485
+ os.makedirs(directory, exist_ok=True)
486
+ tmp_path = os.path.join(
487
+ directory, f".{os.path.basename(path)}.{secrets.token_hex(8)}.tmp"
488
+ )
489
+ # O_BINARY (Windows only) keeps the bytes exact; mode 0o666 lets the umask
490
+ # apply as for a normal open().
491
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
492
+ fd = os.open(tmp_path, flags, 0o666)
493
+ try:
494
+ with os.fdopen(fd, "wb") as handle:
495
+ handle.write(data)
496
+ handle.flush()
497
+ os.fsync(handle.fileno())
498
+ os.replace(tmp_path, path)
499
+ except BaseException:
500
+ try:
501
+ os.remove(tmp_path)
502
+ except OSError:
503
+ pass
504
+ raise
505
+
506
+
480
507
  KNOWN_SERVER_ERRORS = {
481
508
  "invalid_app",
482
509
  "invalid_key",
@@ -495,6 +522,14 @@ KNOWN_SERVER_ERRORS = {
495
522
  "malformed_request",
496
523
  "demo_quota_exceeded",
497
524
  "system_error",
525
+ # POST /auth/offline/activate (self-serve offline activation).
526
+ "offline_activation_disabled",
527
+ "offline_activation_requires_perpetual",
528
+ "offline_activation_requires_seats",
529
+ "offline_activation_limit_reached",
530
+ # SDK code (never sent by the server): the returned file failed local
531
+ # verification. Listed with the server codes for cross-SDK parity.
532
+ "offline_file_rejected",
498
533
  }
499
534
 
500
535
  # Well-formed AuthForge verdicts that end the session. This is the only list
@@ -510,6 +545,12 @@ DEFINITIVE_ERROR_CODES = frozenset(
510
545
  "app_disabled",
511
546
  "invalid_app",
512
547
  "signature_mismatch",
548
+ "offline_activation_disabled",
549
+ "offline_activation_requires_perpetual",
550
+ "offline_activation_requires_seats",
551
+ "offline_activation_limit_reached",
552
+ # SDK code: activate_offline() got a file that failed local verification.
553
+ "offline_file_rejected",
513
554
  }
514
555
  )
515
556
 
@@ -536,6 +577,9 @@ TRANSIENT_ERROR_CODES = frozenset(
536
577
  "missing_session_token",
537
578
  "nonce_mismatch",
538
579
  "unknown_error",
580
+ # activate_offline() could not write the verified file; activating the
581
+ # same machine again is free.
582
+ "file_write_failed",
539
583
  }
540
584
  )
541
585
  _SERVER_ERROR_CODE_RE = re.compile(r"[a-z][a-z0-9_]{0,63}")
@@ -577,6 +621,100 @@ def is_transient_error(error: Union[str, BaseException, None]) -> bool:
577
621
  return code not in DEFINITIVE_ERROR_CODES
578
622
 
579
623
 
624
+ # HWID v2: sha256("authforge-hwid-v2|" + app_id + "|" + machine_id), where machine_id is
625
+ # the OS machine id (macOS IOPlatformUUID, Linux /etc/machine-id, Windows MachineGuid).
626
+ # Every SDK derives the same value; conformance is pinned by hwid_vectors.json.
627
+ HWID_V2_PREFIX = "authforge-hwid-v2|"
628
+ _MACHINE_ID_COMMAND_TIMEOUT = 2
629
+ _LINUX_MACHINE_ID_PATHS = ("/etc/machine-id", "/var/lib/dbus/machine-id")
630
+ _MACHINE_ID_RE = re.compile(r"[0-9a-f-]+")
631
+ _ALL_ZERO_MACHINE_ID_RE = re.compile(r"[0-]+")
632
+ _IOREG_PLATFORM_UUID_RE = re.compile(r'"IOPlatformUUID"\s*=\s*"([^"]+)"')
633
+ _ASCII_WS_SPLIT_RE = re.compile(r"[ \t\r\n]+")
634
+
635
+
636
+ def hwid_from_machine_id(app_id: str, machine_id: str) -> str:
637
+ """Derive the HWID from an already-normalized machine id (or a ``legacy:`` fallback id)."""
638
+ material = f"{HWID_V2_PREFIX}{app_id}|{machine_id}"
639
+ return hashlib.sha256(material.encode("utf-8")).hexdigest()
640
+
641
+
642
+ def normalize_machine_id(raw: Optional[str]) -> Optional[str]:
643
+ """Trim and lowercase an OS machine id; ``None`` when it is not a usable id."""
644
+ if not isinstance(raw, str):
645
+ return None
646
+ value = raw.strip(" \t\r\n")
647
+ # ASCII-only lowercasing; the character check below rejects anything non-ASCII.
648
+ value = "".join(chr(ord(c) + 32) if "A" <= c <= "Z" else c for c in value)
649
+ if len(value) < 8 or len(value) > 128:
650
+ return None
651
+ if not _MACHINE_ID_RE.fullmatch(value):
652
+ return None
653
+ if _ALL_ZERO_MACHINE_ID_RE.fullmatch(value):
654
+ return None
655
+ return value
656
+
657
+
658
+ def parse_ioreg_platform_uuid(output: Optional[str]) -> Optional[str]:
659
+ """Extract IOPlatformUUID from ``ioreg -rd1 -c IOPlatformExpertDevice`` output."""
660
+ if not isinstance(output, str):
661
+ return None
662
+ match = _IOREG_PLATFORM_UUID_RE.search(output)
663
+ return match.group(1) if match else None
664
+
665
+
666
+ def parse_reg_query_machine_guid(output: Optional[str]) -> Optional[str]:
667
+ """Extract MachineGuid from ``reg.exe query ... /v MachineGuid`` output."""
668
+ if not isinstance(output, str):
669
+ return None
670
+ for line in re.split(r"\r?\n", output):
671
+ tokens = [t for t in _ASCII_WS_SPLIT_RE.split(line) if t]
672
+ if len(tokens) >= 3 and tokens[0].lower() == "machineguid" and tokens[1] == "REG_SZ":
673
+ return tokens[2]
674
+ return None
675
+
676
+
677
+ def _read_os_machine_id() -> Optional[str]:
678
+ """The normalized OS machine id, or ``None``. Never raises."""
679
+ try:
680
+ if sys.platform == "darwin":
681
+ result = subprocess.run(
682
+ ["/usr/sbin/ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
683
+ capture_output=True,
684
+ text=True,
685
+ timeout=_MACHINE_ID_COMMAND_TIMEOUT,
686
+ )
687
+ if result.returncode != 0:
688
+ return None
689
+ return normalize_machine_id(parse_ioreg_platform_uuid(result.stdout))
690
+ if sys.platform.startswith("linux"):
691
+ for path in _LINUX_MACHINE_ID_PATHS:
692
+ try:
693
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
694
+ machine_id = normalize_machine_id(handle.read())
695
+ except Exception:
696
+ continue # missing or unreadable: try the next source
697
+ if machine_id:
698
+ return machine_id
699
+ return None
700
+ if sys.platform == "win32":
701
+ import winreg
702
+
703
+ with winreg.OpenKey(
704
+ winreg.HKEY_LOCAL_MACHINE,
705
+ r"SOFTWARE\Microsoft\Cryptography",
706
+ 0,
707
+ winreg.KEY_READ | winreg.KEY_WOW64_64KEY,
708
+ ) as key:
709
+ value, value_type = winreg.QueryValueEx(key, "MachineGuid")
710
+ if value_type != winreg.REG_SZ or not isinstance(value, str):
711
+ return None
712
+ return normalize_machine_id(value)
713
+ except Exception:
714
+ pass # fall through to the legacy fingerprint
715
+ return None
716
+
717
+
580
718
  def _warn(message: str) -> None:
581
719
  # sys.stderr is None under pythonw, and a closed stream must not stop check-ins.
582
720
  stream = sys.stderr
@@ -769,6 +907,70 @@ class AuthForgeClient:
769
907
  self._apply_offline_license(result)
770
908
  return True
771
909
 
910
+ def activate_offline(self, license_key: str, path: Optional[str] = None) -> bool:
911
+ """Self-serve offline activation: turn a license key into a lifetime,
912
+ HWID-bound ``.authforge`` file with one online call
913
+ (``POST /auth/offline/activate``).
914
+
915
+ Needs the App Secret (unlike air-gapped builds). The server charges the
916
+ app owner 2 credits for a new machine; the same machine activating
917
+ again gets the identical file back for free. The returned file is
918
+ verified locally (signature, app id, this client's HWID) before
919
+ anything is written. When ``path`` is set the file is written there
920
+ atomically, creating parent directories. On success the client is
921
+ authenticated exactly as after :meth:`login_from_file`, and
922
+ ``get_offline_license()["replay"]`` tells whether the server returned
923
+ a file this machine already had.
924
+
925
+ Raises :class:`AuthForgeError` on any refusal: ``exc.code`` is the
926
+ server code, ``offline_file_rejected`` (the message carries the
927
+ verifier's reason), ``file_write_failed`` or a network code. Never
928
+ calls ``on_failure`` or ``os._exit``. On failure nothing is written to
929
+ ``path`` and the client is not authenticated by this call; an existing
930
+ session is kept. On success the session is replaced, as with
931
+ :meth:`login_from_file`.
932
+ """
933
+ if not license_key or not isinstance(license_key, str):
934
+ raise ValueError("license_key must be a non-empty string")
935
+ self._require_app_secret()
936
+ target = os.fspath(path) if path else None
937
+ body: Dict[str, Any] = {
938
+ "appId": self.app_id,
939
+ "appSecret": self.app_secret,
940
+ "licenseKey": license_key,
941
+ "hwid": self._hwid,
942
+ "nonce": self._generate_nonce(),
943
+ }
944
+ try:
945
+ # A network failure must raise here, never reach on_failure / os._exit.
946
+ response_obj = self._post_json("/auth/offline/activate", body)
947
+ except AuthForgeError:
948
+ raise
949
+ except Exception as exc:
950
+ raise AuthForgeError("network_error", f"network_error: {exc}") from exc
951
+ if not self._is_success_status(response_obj.get("status")):
952
+ raise self._heartbeat_verdict(response_obj)
953
+ file_text = response_obj.get("file")
954
+ if not isinstance(file_text, str) or not file_text:
955
+ raise AuthForgeError("unexpected_response", "unexpected_response: missing file")
956
+
957
+ # The verifier's reason goes in the message only: the server's
958
+ # hwid_mismatch means "no free seat" and must stay distinguishable.
959
+ result = verify_license_file(file_text, self.app_id, self.public_keys, self._hwid)
960
+ if not result["ok"]:
961
+ raise AuthForgeError(
962
+ "offline_file_rejected", f"offline_file_rejected: {result['error']}"
963
+ )
964
+
965
+ if target is not None:
966
+ try:
967
+ _write_file_atomic(target, file_text.encode("utf-8"))
968
+ except OSError as exc:
969
+ raise AuthForgeError("file_write_failed", f"file_write_failed: {exc}") from exc
970
+
971
+ self._apply_offline_license(result, replay=response_obj.get("replay") is True)
972
+ return True
973
+
772
974
  def verify_license_file(
773
975
  self,
774
976
  path_or_text: str,
@@ -798,7 +1000,9 @@ class AuthForgeClient:
798
1000
  with open(path_or_text, "r", encoding="utf-8") as handle:
799
1001
  return handle.read()
800
1002
 
801
- def _apply_offline_license(self, result: VerifyLicenseFileSuccess) -> None:
1003
+ def _apply_offline_license(
1004
+ self, result: VerifyLicenseFileSuccess, *, replay: Optional[bool] = None
1005
+ ) -> None:
802
1006
  # Stop any online session first so the two modes never overlap.
803
1007
  self.logout()
804
1008
  lic = result["license"]
@@ -830,6 +1034,10 @@ class AuthForgeClient:
830
1034
  summary["label"] = lic["label"]
831
1035
  if "license_expires_at" in lic:
832
1036
  summary["license_expires_at"] = lic["license_expires_at"]
1037
+ if replay is not None:
1038
+ # Only set by activate_offline(): True when the server returned
1039
+ # the file this machine already had (free).
1040
+ summary["replay"] = replay
833
1041
  self._offline_license = summary
834
1042
  self._authenticated = True
835
1043
 
@@ -875,7 +1083,7 @@ class AuthForgeClient:
875
1083
  }
876
1084
  if self.ttl_seconds is not None:
877
1085
  body["ttlSeconds"] = self.ttl_seconds
878
- response_obj = self._post_json("/auth/validate", body, skip_failure_hook=True)
1086
+ response_obj = self._post_json("/auth/validate", body)
879
1087
  expected_nonce = str(body.get("nonce", "")).strip()
880
1088
  parsed = self._parse_validate_success(response_obj, expected_nonce)
881
1089
  result: ValidateLicenseSuccess = {
@@ -934,6 +1142,8 @@ class AuthForgeClient:
934
1142
 
935
1143
  resolved_session = resolved_session or current_session
936
1144
 
1145
+ # Every failure raises, a network failure as AuthForgeError
1146
+ # network_error / timeout; self_ban never calls on_failure / os._exit.
937
1147
  if resolved_session:
938
1148
  body: Dict[str, Any] = {
939
1149
  "appId": self.app_id,
@@ -1066,7 +1276,7 @@ class AuthForgeClient:
1066
1276
  "hwid": hwid,
1067
1277
  }
1068
1278
  # Network failures surface once, as heartbeat_failed / network_error.
1069
- response_obj = self._post_json("/auth/heartbeat", body, skip_failure_hook=True)
1279
+ response_obj = self._post_json("/auth/heartbeat", body)
1070
1280
  if not self._is_success_status(response_obj.get("status")):
1071
1281
  raise self._heartbeat_verdict(response_obj)
1072
1282
  expected_nonce = str(body.get("nonce", "")).strip()
@@ -1125,6 +1335,7 @@ class AuthForgeClient:
1125
1335
  }
1126
1336
  if self.ttl_seconds is not None:
1127
1337
  body["ttlSeconds"] = self.ttl_seconds
1338
+ # Network failures surface once, as login_failed / network_error.
1128
1339
  response_obj = self._post_json("/auth/validate", body)
1129
1340
  expected_nonce = str(body.get("nonce", "")).strip()
1130
1341
  self._apply_signed_response(
@@ -1229,9 +1440,7 @@ class AuthForgeClient:
1229
1440
  self._license_variables = parsed["license_variables"]
1230
1441
  self._authenticated = True
1231
1442
 
1232
- def _post_json(
1233
- self, path: str, data: Dict[str, Any], *, skip_failure_hook: bool = False
1234
- ) -> Dict[str, Any]:
1443
+ def _post_json(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
1235
1444
  url = f"{self.api_base_url}{path}"
1236
1445
  body = dict(data)
1237
1446
  rate_attempt = 0
@@ -1271,8 +1480,6 @@ class AuthForgeClient:
1271
1480
  network_attempt += 1
1272
1481
  time.sleep(NETWORK_RETRY_DELAY)
1273
1482
  continue
1274
- if not skip_failure_hook:
1275
- self._fail("network_error", exc)
1276
1483
  reason = getattr(exc, "reason", exc)
1277
1484
  code = "timeout" if isinstance(reason, (socket.timeout, TimeoutError)) else "network_error"
1278
1485
  raise AuthForgeError(code, f"url_error: {exc}") from exc
@@ -1299,6 +1506,11 @@ class AuthForgeClient:
1299
1506
  return obj
1300
1507
 
1301
1508
  def _get_hwid(self) -> str:
1509
+ machine_id = _read_os_machine_id() or "legacy:" + self._legacy_fingerprint()
1510
+ return hwid_from_machine_id(self.app_id, machine_id)
1511
+
1512
+ # Pre-2.0 fingerprint, kept only as the fallback when no OS machine id is readable.
1513
+ def _legacy_fingerprint(self) -> str:
1302
1514
  mac = self._safe_mac_address()
1303
1515
  cpu = self._safe_cpu_info()
1304
1516
  disk = self._safe_disk_serial()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: authforge-sdk
3
- Version: 1.4.1
3
+ Version: 2.0.0
4
4
  Summary: Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses.
5
5
  Author: AuthForge
6
6
  License: MIT
@@ -38,7 +38,7 @@ Uses `cryptography` for Ed25519 verification. Works on Python 3.9+.
38
38
  2. **Run through the grace period.** By default the app keeps running on the signed session with no further network calls. A background check re-verifies the signature locally and fails when the session TTL expires. The grace period equals the session TTL: default 24h, and the server clamps requested values to 1h to 7d.
39
39
  3. **Optionally enable online check-ins.** With `online_heartbeat=True`, the SDK also calls `POST /auth/heartbeat` every `heartbeat_interval` seconds for fast revocation and concurrent-use detection.
40
40
 
41
- Separately, for machines that can **never** reach the internet, an operator can mint a signed **offline license file (`.authforge`)** in the AuthForge dashboard or Developer API. The SDK verifies it locally with your app public key: see [Offline license files](#offline-license-files-authforge).
41
+ Separately, for machines that can **never** reach the internet, an operator can mint a signed **offline license file (`.authforge`)** in the AuthForge dashboard or Developer API. The SDK verifies it locally with your app public key: see [Offline license files](#offline-license-files-authforge). If the machine can go online **once**, the end user can get that file themselves with `activate_offline()`: see [Self-serve offline activation](#self-serve-offline-activation).
42
42
 
43
43
  ## Features
44
44
 
@@ -50,8 +50,9 @@ Everything in this list ships in `authforge.py` today:
50
50
  - **Grace period by default**: after one successful activation, the app runs on the signed session (no network) until the TTL expires.
51
51
  - **Online check-ins** (opt-in): periodic `/auth/heartbeat` calls for fast revocation and concurrent-use detection.
52
52
  - **Offline license files (`.authforge`)**: `login_from_file()` / `verify_license_file()` verify a cloud-minted, Ed25519-signed file with zero network access for air-gapped machines.
53
+ - **Self-serve offline activation**: `activate_offline()` turns a license key into a lifetime, HWID-bound `.authforge` file with one online call, then runs offline from that file.
53
54
  - **Nonce anti-replay**: a fresh 128-bit nonce is sent on every request and the echoed nonce in the signed payload is checked before the response is accepted.
54
- - **HWID fingerprinting**: deterministic device hash from MAC + CPU + disk serial, with graceful per-component fallback.
55
+ - **HWID v2**: per-app device id derived from the OS machine id (macOS `IOPlatformUUID`, Linux `/etc/machine-id`, Windows `MachineGuid`), identical across all AuthForge SDKs for the same app and machine.
55
56
  - **`hwid_override`**: bind to any identity instead of the machine (for example `tg:<id>`, `discord:<id>`).
56
57
  - **Seat enforcement**: the server binds each HWID into a license's free slots up to `maxHwidSlots`; `hwid_count` / `max_hwid_slots` are surfaced on the result. A shared (unlimited-seat) key skips per-device binding.
57
58
  - **Self-ban** (`self_ban()`) for anti-tamper response, both pre-session and post-session.
@@ -118,7 +119,7 @@ client = AuthForgeClient(
118
119
  | `on_failure` | callable | `None` | Callback `(reason: str, exc: Exception | None)` on auth failure |
119
120
  | `request_timeout` | int | `15` | HTTP request timeout in seconds |
120
121
  | `ttl_seconds` | `int \| None` | `None` (server default: 86400) | The grace period duration: how long the app keeps running on the signed session without contacting AuthForge. Server clamps to `[3600, 604800]` (1h to 7d); preserved across check-in refreshes. |
121
- | `hwid_override` | `str \| None` | `None` | Optional custom hardware/subject identifier. When set to a non-empty value, the SDK uses it instead of machine fingerprinting. |
122
+ | `hwid_override` | `str \| None` | `None` | Optional custom hardware/subject identifier. When set to a non-empty value, the SDK uses it instead of deriving the HWID from the OS machine id. |
122
123
  | `online_heartbeat` | bool (keyword-only) | `False` | Enable online check-ins: periodic `POST /auth/heartbeat` for fast revocation and concurrent-use detection. |
123
124
 
124
125
  ### Identity-based binding example (Telegram/Discord)
@@ -189,12 +190,81 @@ if client.login_from_file("license.authforge"):
189
190
  print(client.get_license_variables())
190
191
  ```
191
192
 
192
- Collect the HWID from the same SDK build that will load the file: fingerprints are not portable across SDKs or languages. After `login_from_file()`, `get_session_kind()` returns `"offline"` (`"online"` after `login()`, `None` when logged out).
193
+ Since 2.0 every AuthForge SDK computes the same HWID for the same app id and machine, but HWIDs collected before upgrading to 2.0 no longer match: collect the HWID again after upgrading. After `login_from_file()`, `get_session_kind()` returns `"offline"` (`"online"` after `login()`, `None` when logged out).
193
194
 
194
195
  `verify_license_file()` (module function and client method) performs the same checks without touching client state. Failure codes, in check order: `bad_armor`, `bad_signature`, `unsupported_version`, `malformed_payload`, `wrong_app`, `expired`, `hwid_mismatch`. `login_from_file()` reports them through `on_failure("offline_login_failed", exc)` and returns `False`; it never calls `os._exit`.
195
196
 
196
197
  File format (version 1): PEM-style armor with informational headers, a base64 JSON payload (`v`, `appId`, `licenseKey`, `jti`, `kid`, `issuedAt`, `expiresAt`, `hwid` policy, optional label/variable snapshots) and a detached Ed25519 signature over the UTF-8 bytes of the base64 payload string - the same contract as `/auth/validate`. See `offline_license_vectors.json` for conformance vectors.
197
198
 
199
+ ## Self-serve offline activation
200
+
201
+ A separate mode for apps that sell a one-time license and then run with no network: the end user types the license key once, `activate_offline()` makes **one** online call (`POST /auth/offline/activate`) and gets back a lifetime `.authforge` file bound to this machine's HWID. From then on the app starts with `login_from_file()` and never contacts AuthForge again.
202
+
203
+ How it differs from air-gapped offline files:
204
+
205
+ - It **needs the App Secret**, because it is an online call. Air-gapped builds that only use `login_from_file()` still ship with `app_secret=None`.
206
+ - The app owner opts the app in (app settings in the dashboard). Only **perpetual**, **seat-limited** licenses can self-serve; each license can produce files for at most `maxHwidSlots` distinct machines, ever. HWID resets do not free those machine slots.
207
+ - **Billing:** a new machine costs the app owner **2 credits**, once. The same machine activating again (reinstall, deleted file) gets the identical file back for **free**. A refused activation costs nothing.
208
+ - **A refund or chargeback revokes the license online but cannot reach files already issued.** A lifetime file keeps working on its machine.
209
+
210
+ ```python
211
+ import os
212
+ import sys
213
+
214
+ from authforge import AuthForgeClient, AuthForgeError
215
+
216
+ LICENSE_PATH = os.path.join(os.path.expanduser("~"), ".myapp", "license.authforge")
217
+
218
+ client = AuthForgeClient(
219
+ app_id="YOUR_APP_ID",
220
+ app_secret="YOUR_APP_SECRET", # required for activate_offline (online call)
221
+ public_key="YOUR_PUBLIC_KEY",
222
+ on_failure=lambda reason, exc: None, # login_from_file reports why it failed here
223
+ )
224
+
225
+ # 1. No network, no credits: run from the file if it is there and valid.
226
+ if not (os.path.exists(LICENSE_PATH) and client.login_from_file(LICENSE_PATH)):
227
+ # 2. No file yet, or it fails (e.g. hwid_mismatch after a hardware change).
228
+ key = input("License key: ").strip()
229
+ try:
230
+ client.activate_offline(key, path=LICENSE_PATH)
231
+ except AuthForgeError as exc:
232
+ if exc.code == "offline_activation_limit_reached":
233
+ print("This license is active on its maximum number of machines. Please contact the vendor.")
234
+ elif exc.code in {"invalid_key", "revoked", "expired"}:
235
+ print("That license key is not valid.")
236
+ elif exc.code == "hwid_mismatch":
237
+ print("No free seat on this license for this machine.")
238
+ elif exc.transient:
239
+ print(f"Could not activate right now ({exc.code}). Check your connection and try again.")
240
+ else:
241
+ print(f"Activation failed: {exc.code}")
242
+ sys.exit(1)
243
+
244
+ print("Licensed:", client.get_license_variables())
245
+ ```
246
+
247
+ `activate_offline(license_key, path=None)` returns `True` on success and raises `AuthForgeError` otherwise. It:
248
+
249
+ 1. Fails fast with the usual "app_secret is required" `ValueError` (no request) when the client has no App Secret.
250
+ 2. Sends `appId`, `appSecret`, `licenseKey`, the same HWID `login()` sends and a fresh nonce, with the same retries as `login()` (`rate_limited` after 2s then 5s, one retry on a network error).
251
+ 3. Verifies the returned file with your public key(s), app id and this machine's HWID **before** writing anything. A file that fails raises `offline_file_rejected`; the message includes the verifier's reason (`bad_signature`, `wrong_app`, `hwid_mismatch`, ...).
252
+ 4. If `path` is set, writes the file byte-for-byte and atomically (temp file in the same directory, then rename), creating parent directories.
253
+ 5. Leaves the client authenticated exactly as `login_from_file()` does: `get_session_kind()` is `"offline"`, variables and `get_offline_license()` are set, no background checks start. `get_offline_license()["replay"]` is `True` when the server returned a file this machine already had.
254
+
255
+ On any failure nothing is written to `path` (an existing file there is left as it was), the call does not authenticate the client (an existing session is kept), and neither `on_failure` nor `os._exit` is called.
256
+
257
+ | Code | Kind | Meaning |
258
+ | --- | --- | --- |
259
+ | `offline_activation_disabled` | fatal | The app has not opted in to self-serve activation |
260
+ | `offline_activation_requires_perpetual` | fatal | The license has an expiry; only perpetual licenses can self-serve |
261
+ | `offline_activation_requires_seats` | fatal | Unlimited-seat (shared) licenses cannot self-serve |
262
+ | `offline_activation_limit_reached` | fatal | Every machine slot on the license is used; the buyer must contact the vendor |
263
+ | `offline_file_rejected` | fatal | SDK code: the returned file failed local verification; nothing was written |
264
+ | `hwid_mismatch` | fatal | No free seat on the license for this machine (server code, not the verifier's) |
265
+ | `file_write_failed` | transient | The verified file could not be written to `path`; activating again is free |
266
+ | `invalid_key`, `revoked`, `expired`, `no_credits`, `rate_limited`, `unexpected_response`, `network_error`, ... | as everywhere else | Existing codes keep their meaning and classification |
267
+
198
268
  ## Migrating from heartbeat_mode
199
269
 
200
270
  Earlier releases required `heartbeat_mode="LOCAL"` or `"SERVER"`. The argument is now optional and deprecated; it still works but emits a `DeprecationWarning`.
@@ -217,6 +287,7 @@ For code that still reads it, `client.heartbeat_mode` remains available and refl
217
287
 
218
288
  - **1 `login()` or `validate_license()` call = 1 credit** (one `/auth/validate` debit each).
219
289
  - **10 online check-ins on the same license = 1 credit** (billed every 10th successful `/auth/heartbeat`). Grace period checks are local and free.
290
+ - **1 new offline activation (`activate_offline()`) = 2 credits**, charged to the app owner; re-activating the same machine is free. `login_from_file()` is always free.
220
291
 
221
292
  A desktop app running 6h/day with online check-ins at a 15-minute interval burns ~3-4 credits/day. The server enforces `/auth/heartbeat` at 6 requests/minute per license key, so keep intervals at 10 seconds or higher and pick the interval based on how fast you need revocations to propagate (they always land on the **next** check-in).
222
293
 
@@ -226,8 +297,9 @@ A desktop app running 6h/day with online check-ins at a 15-minute interval burns
226
297
  |---|---|---|
227
298
  | `login(license_key)` | `bool` | Activates: validates the key online and stores the signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
228
299
  | `validate_license(license_key)` | `ValidateLicenseResult` | Same `/auth/validate` + signatures as `login`; does not store session or start background checks; returns a dict with `valid` / `code` and **never** calls `on_failure` or `os._exit` |
229
- | `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only) |
300
+ | `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only). Raises `ValueError` on any failure (a network failure as `AuthForgeError` `network_error` / `timeout`); never calls `on_failure` or `os._exit` |
230
301
  | `login_from_file(path_or_text)` | `bool` | Authorizes from an offline `.authforge` file with no network; never starts background checks; failures go to `on_failure("offline_login_failed", …)` |
302
+ | `activate_offline(license_key, path=None)` | `bool` | Self-serve offline activation: one online call returns a lifetime HWID-bound `.authforge` file, verified locally, written atomically to `path` and applied like `login_from_file`. Needs the App Secret; raises `AuthForgeError` on refusal; never calls `on_failure` or `os._exit` |
231
303
  | `verify_license_file(path_or_text, *, now=None)` | `VerifyLicenseFileResult` | Verifies a `.authforge` file with this client's app id / keys / HWID without changing state |
232
304
  | `get_offline_license()` | `dict \| None` | Metadata of the offline file in use (`jti`, `expires_at`, `hwid_policy`, …) |
233
305
  | `get_session_kind()` | `"online" \| "offline" \| None` | Which kind of session the client holds (`None` when logged out) |
@@ -247,7 +319,11 @@ If authentication fails (activation rejected, check-in fails, signature mismatch
247
319
  **`validate_license()`** does not trigger `on_failure` or `os._exit`: check `result["valid"]` and `result["code"]`.
248
320
 
249
321
  Recognized server errors (`KNOWN_SERVER_ERRORS`):
250
- `invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `demo_quota_exceeded`, `system_error`. Codes added to the server later are passed through unchanged.
322
+ `invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `demo_quota_exceeded`, `system_error`, and from `activate_offline()`: `offline_activation_disabled`, `offline_activation_requires_perpetual`, `offline_activation_requires_seats`, `offline_activation_limit_reached`, plus the SDK code `offline_file_rejected`. Codes added to the server later are passed through unchanged.
323
+
324
+ **`activate_offline()`** does not trigger `on_failure` or `os._exit` either: it raises `AuthForgeError` (see [Self-serve offline activation](#self-serve-offline-activation)).
325
+
326
+ **`self_ban()`** does not trigger `on_failure` or `os._exit` either: every failure raises, a server rejection as `ValueError(code)` and a network failure as `AuthForgeError` with `exc.code` `network_error` or `timeout`.
251
327
 
252
328
  Request retries are automatic inside the internal HTTP layer:
253
329
  - `rate_limited`, or HTTP 429 with no error code: retry after 2s, then 5s (max 3 attempts total)
@@ -266,10 +342,10 @@ A failed check-in only counts as an AuthForge verdict when the body is a JSON ob
266
342
 
267
343
  | Kind | Codes | What the SDK does |
268
344
  |---|---|---|
269
- | Fatal | `revoked`, `expired`, `hwid_mismatch` (the HWID is no longer bound to the license, for example after an HWID reset), `blocked` (HWID/IP blacklisted or not whitelisted), `session_expired` (also raised locally when the grace period or session TTL runs out), `malformed_request`, `app_disabled`, `invalid_app`, `signature_mismatch` | Clears the stored session (as `logout()` does) and stops background checks **before** calling `on_failure`, so the grace period cannot keep the app running on it. `on_failure` may call `login()` again. |
345
+ | Fatal | `revoked`, `expired`, `hwid_mismatch` (the HWID is no longer bound to the license, for example after an HWID reset), `blocked` (HWID/IP blacklisted or not whitelisted), `session_expired` (also raised locally when the grace period or session TTL runs out), `malformed_request`, `app_disabled`, `invalid_app`, `signature_mismatch` (plus the `activate_offline()`-only codes `offline_activation_*` and `offline_file_rejected`) | Clears the stored session (as `logout()` does) and stops background checks **before** calling `on_failure`, so the grace period cannot keep the app running on it. `on_failure` may call `login()` again. |
270
346
  | Transient | Everything else: `network_error`, `timeout`, `rate_limited`, `system_error`, `no_credits`, `demo_quota_exceeded`, `app_burn_cap_reached`, `bad_request`, `invalid_key`, every `http_error_<status>`, `invalid_json_response`, `unexpected_response`, `nonce_mismatch`, any unrecognized code | Keeps the session and checks in again on the next `heartbeat_interval`. Once the signed session's TTL has passed, the next transient failure is reported as a fatal `session_expired` instead. |
271
347
 
272
- Transient failures only keep checking in if `on_failure` returns normally. Without a callback, a transient failure prints `AuthForge: background check failed (<code>); retrying next interval` to stderr and check-ins continue; a fatal one, including the `session_expired` a transient failure becomes once the session TTL has passed, still ends the process with `os._exit(1)`. Heartbeat network failures are reported once, as `heartbeat_failed` with code `network_error` or `timeout`, not as a separate `network_error` reason.
348
+ Transient failures only keep checking in if `on_failure` returns normally. Without a callback, a transient failure prints `AuthForge: background check failed (<code>); retrying next interval` to stderr and check-ins continue; a fatal one, including the `session_expired` a transient failure becomes once the session TTL has passed, still ends the process with `os._exit(1)`. Network failures are reported once, with code `network_error` or `timeout`: as `heartbeat_failed` from check-ins and as `login_failed` from `login()`, never as a separate `network_error` reason. `activate_offline()` and `self_ban()` raise them as `AuthForgeError` instead.
273
349
 
274
350
  `on_failure` runs on the background heartbeat thread with no SDK lock held, so calling `logout()`, `is_authenticated()` or `login()` from it is safe. A check-in that is still in flight when you call `logout()` or `login()` is discarded: it cannot restore the old session or report a failure for it.
275
351
 
@@ -339,11 +415,12 @@ client.self_ban(
339
415
  - Uses post-session mode when a session token is available (`session_token` arg or current SDK session).
340
416
  - Falls back to pre-session mode using `license_key` + nonce + app secret.
341
417
  - In pre-session mode, revoke is forced off client-side to avoid unsafe key revocations.
418
+ - Every failure raises: a server rejection as `ValueError(code)`, a network failure (after one retry) as `AuthForgeError` with `exc.code` `network_error` or `timeout`. `self_ban()` never calls `on_failure` or `os._exit`, with or without a callback.
342
419
  - Not available after `login_from_file()`: offline sessions have no server session, so `self_ban()` with no explicit `license_key` / `session_token` raises `ValueError("offline_session")` without contacting the server.
343
420
 
344
421
  ## How It Works
345
422
 
346
- 1. **Activate**: `login()` uses `hwid_override` if provided; otherwise it collects a hardware fingerprint (MAC, CPU, disk serial). It then generates a random nonce and sends everything to the AuthForge API. The server validates the license key, binds the HWID, deducts a credit, and returns a signed payload with a TTL. The SDK verifies the Ed25519 signature and nonce to prevent replay attacks.
423
+ 1. **Activate**: `login()` uses `hwid_override` if provided; otherwise it derives the HWID from the OS machine id (see [Hardware ID](#hardware-id)). It then generates a random nonce and sends everything to the AuthForge API. The server validates the license key, binds the HWID, deducts a credit, and returns a signed payload with a TTL. The SDK verifies the Ed25519 signature and nonce to prevent replay attacks.
347
424
 
348
425
  2. **Background checks**: a daemon thread wakes at the configured interval. By default it enforces the grace period: it re-verifies the stored signature and checks expiry without network calls. With `online_heartbeat=True`, it instead sends `/auth/heartbeat` with a fresh nonce and verifies the response.
349
426
 
@@ -351,18 +428,29 @@ client.self_ban(
351
428
 
352
429
  ## Hardware ID
353
430
 
354
- The SDK generates a deterministic hardware fingerprint by hashing:
355
- - MAC address
356
- - CPU identifier
357
- - Disk serial number
431
+ The HWID (v2, since 2.0) is
432
+
433
+ `SHA256("authforge-hwid-v2|" + app_id + "|" + machine_id)` as lowercase hex,
434
+
435
+ where `machine_id` is the operating system's own machine id, trimmed and lowercased:
436
+
437
+ | OS | Source |
438
+ | -- | ------ |
439
+ | macOS | `IOPlatformUUID` (`/usr/sbin/ioreg -rd1 -c IOPlatformExpertDevice`) |
440
+ | Linux | `/etc/machine-id`, then `/var/lib/dbus/machine-id` |
441
+ | Windows | `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (64-bit registry view, via `winreg`) |
442
+
443
+ These ids survive network changes, renames, disk usage and USB devices. Including the app id means two different apps on the same machine get unrelated HWIDs. Every AuthForge SDK computes the same value for the same app id and machine; `hwid_vectors.json` pins this. The HWID is sent with every auth request so the server can enforce per-device license limits.
444
+
445
+ If no valid OS id can be read (for example a container without `/etc/machine-id`), the SDK falls back to `legacy:<the pre-2.0 fingerprint of MAC, CPU and disk serial>` as the machine id. Containers built from one image can also share a baked-in machine id, so for containers prefer `hwid_override` with an identifier you control.
358
446
 
359
- Each component falls back gracefully if it can't be read (e.g. permissions issues). The HWID is sent with every auth request so the server can enforce per-device license limits.
447
+ The helpers `hwid_from_machine_id`, `normalize_machine_id`, `parse_ioreg_platform_uuid` and `parse_reg_query_machine_guid` are exported for tooling and tests.
360
448
 
361
449
  For non-device identities (for example Telegram users), pass `hwid_override` such as `tg:<user_id>`.
362
450
 
363
451
  ## Test Vectors
364
452
 
365
- The shared `test_vectors.json` file validates cross-language Ed25519 verification behavior. `offline_license_vectors.json` (generated from a fixed test seed in the Node SDK repo) is the cross-SDK conformance suite for `.authforge` offline license files: good files plus the `bad_signature`, wrong key, `wrong_app`, `expired`, `hwid_mismatch`, `unsupported_version` and `bad_armor` rejects.
453
+ The shared `test_vectors.json` file validates cross-language Ed25519 verification behavior. `offline_license_vectors.json` (generated from a fixed test seed in the Node SDK repo) is the cross-SDK conformance suite for `.authforge` offline license files: good files plus the `bad_signature`, wrong key, `wrong_app`, `expired`, `hwid_mismatch`, `unsupported_version` and `bad_armor` rejects. `self_serve_activation_vectors.json` is the cross-SDK suite for `activate_offline()`: mocked `/auth/offline/activate` responses with the expected written file, session and error code. `hwid_vectors.json` (generated by `authforge-node/generate_hwid_vectors.mjs`) is the cross-SDK suite for HWID v2: derivation, machine id normalization, and the `ioreg` / `reg query` parsers.
366
454
 
367
455
  ## Requirements
368
456
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "authforge-sdk"
7
- version = "1.4.1"
7
+ version = "2.0.0"
8
8
  description = "Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes
File without changes