authforge-sdk 1.5.0__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.5.0
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
@@ -52,7 +52,7 @@ Everything in this list ships in `authforge.py` today:
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
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.
54
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.
55
- - **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.
56
56
  - **`hwid_override`**: bind to any identity instead of the machine (for example `tg:<id>`, `discord:<id>`).
57
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.
58
58
  - **Self-ban** (`self_ban()`) for anti-tamper response, both pre-session and post-session.
@@ -119,7 +119,7 @@ client = AuthForgeClient(
119
119
  | `on_failure` | callable | `None` | Callback `(reason: str, exc: Exception | None)` on auth failure |
120
120
  | `request_timeout` | int | `15` | HTTP request timeout in seconds |
121
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. |
122
- | `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. |
123
123
  | `online_heartbeat` | bool (keyword-only) | `False` | Enable online check-ins: periodic `POST /auth/heartbeat` for fast revocation and concurrent-use detection. |
124
124
 
125
125
  ### Identity-based binding example (Telegram/Discord)
@@ -190,7 +190,7 @@ if client.login_from_file("license.authforge"):
190
190
  print(client.get_license_variables())
191
191
  ```
192
192
 
193
- 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).
194
194
 
195
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`.
196
196
 
@@ -297,7 +297,7 @@ A desktop app running 6h/day with online check-ins at a 15-minute interval burns
297
297
  |---|---|---|
298
298
  | `login(license_key)` | `bool` | Activates: validates the key online and stores the signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
299
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` |
300
- | `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` |
301
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
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` |
303
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 |
@@ -323,6 +323,8 @@ Recognized server errors (`KNOWN_SERVER_ERRORS`):
323
323
 
324
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
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`.
327
+
326
328
  Request retries are automatic inside the internal HTTP layer:
327
329
  - `rate_limited`, or HTTP 429 with no error code: retry after 2s, then 5s (max 3 attempts total)
328
330
  - `no_credits`, `demo_quota_exceeded` and `app_burn_cap_reached` also arrive as HTTP 429 but are never retried immediately; online check-ins try again at the next interval
@@ -343,7 +345,7 @@ A failed check-in only counts as an AuthForge verdict when the body is a JSON ob
343
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. |
344
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. |
345
347
 
346
- 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.
347
349
 
348
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.
349
351
 
@@ -413,11 +415,12 @@ client.self_ban(
413
415
  - Uses post-session mode when a session token is available (`session_token` arg or current SDK session).
414
416
  - Falls back to pre-session mode using `license_key` + nonce + app secret.
415
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.
416
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.
417
420
 
418
421
  ## How It Works
419
422
 
420
- 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.
421
424
 
422
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.
423
426
 
@@ -425,18 +428,29 @@ client.self_ban(
425
428
 
426
429
  ## Hardware ID
427
430
 
428
- The SDK generates a deterministic hardware fingerprint by hashing:
429
- - MAC address
430
- - CPU identifier
431
- - 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.
432
446
 
433
- 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.
434
448
 
435
449
  For non-device identities (for example Telegram users), pass `hwid_override` such as `tg:<user_id>`.
436
450
 
437
451
  ## Test Vectors
438
452
 
439
- 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.
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.
440
454
 
441
455
  ## Requirements
442
456
 
@@ -24,7 +24,7 @@ Everything in this list ships in `authforge.py` today:
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
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.
26
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.
27
- - **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.
28
28
  - **`hwid_override`**: bind to any identity instead of the machine (for example `tg:<id>`, `discord:<id>`).
29
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.
30
30
  - **Self-ban** (`self_ban()`) for anti-tamper response, both pre-session and post-session.
@@ -91,7 +91,7 @@ client = AuthForgeClient(
91
91
  | `on_failure` | callable | `None` | Callback `(reason: str, exc: Exception | None)` on auth failure |
92
92
  | `request_timeout` | int | `15` | HTTP request timeout in seconds |
93
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. |
94
- | `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. |
95
95
  | `online_heartbeat` | bool (keyword-only) | `False` | Enable online check-ins: periodic `POST /auth/heartbeat` for fast revocation and concurrent-use detection. |
96
96
 
97
97
  ### Identity-based binding example (Telegram/Discord)
@@ -162,7 +162,7 @@ if client.login_from_file("license.authforge"):
162
162
  print(client.get_license_variables())
163
163
  ```
164
164
 
165
- 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).
166
166
 
167
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`.
168
168
 
@@ -269,7 +269,7 @@ A desktop app running 6h/day with online check-ins at a 15-minute interval burns
269
269
  |---|---|---|
270
270
  | `login(license_key)` | `bool` | Activates: validates the key online and stores the signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
271
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` |
272
- | `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` |
273
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
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` |
275
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 |
@@ -295,6 +295,8 @@ Recognized server errors (`KNOWN_SERVER_ERRORS`):
295
295
 
296
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
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`.
299
+
298
300
  Request retries are automatic inside the internal HTTP layer:
299
301
  - `rate_limited`, or HTTP 429 with no error code: retry after 2s, then 5s (max 3 attempts total)
300
302
  - `no_credits`, `demo_quota_exceeded` and `app_burn_cap_reached` also arrive as HTTP 429 but are never retried immediately; online check-ins try again at the next interval
@@ -315,7 +317,7 @@ A failed check-in only counts as an AuthForge verdict when the body is a JSON ob
315
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. |
316
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. |
317
319
 
318
- 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.
319
321
 
320
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.
321
323
 
@@ -385,11 +387,12 @@ client.self_ban(
385
387
  - Uses post-session mode when a session token is available (`session_token` arg or current SDK session).
386
388
  - Falls back to pre-session mode using `license_key` + nonce + app secret.
387
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.
388
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.
389
392
 
390
393
  ## How It Works
391
394
 
392
- 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.
393
396
 
394
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.
395
398
 
@@ -397,18 +400,29 @@ client.self_ban(
397
400
 
398
401
  ## Hardware ID
399
402
 
400
- The SDK generates a deterministic hardware fingerprint by hashing:
401
- - MAC address
402
- - CPU identifier
403
- - 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.
404
418
 
405
- 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.
406
420
 
407
421
  For non-device identities (for example Telegram users), pass `hwid_override` such as `tg:<user_id>`.
408
422
 
409
423
  ## Test Vectors
410
424
 
411
- 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.
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.
412
426
 
413
427
  ## Requirements
414
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.5.0"
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
@@ -621,6 +621,100 @@ def is_transient_error(error: Union[str, BaseException, None]) -> bool:
621
621
  return code not in DEFINITIVE_ERROR_CODES
622
622
 
623
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
+
624
718
  def _warn(message: str) -> None:
625
719
  # sys.stderr is None under pythonw, and a closed stream must not stop check-ins.
626
720
  stream = sys.stderr
@@ -848,9 +942,8 @@ class AuthForgeClient:
848
942
  "nonce": self._generate_nonce(),
849
943
  }
850
944
  try:
851
- # skip_failure_hook: a network failure must raise here, never
852
- # reach on_failure / os._exit.
853
- response_obj = self._post_json("/auth/offline/activate", body, skip_failure_hook=True)
945
+ # A network failure must raise here, never reach on_failure / os._exit.
946
+ response_obj = self._post_json("/auth/offline/activate", body)
854
947
  except AuthForgeError:
855
948
  raise
856
949
  except Exception as exc:
@@ -990,7 +1083,7 @@ class AuthForgeClient:
990
1083
  }
991
1084
  if self.ttl_seconds is not None:
992
1085
  body["ttlSeconds"] = self.ttl_seconds
993
- response_obj = self._post_json("/auth/validate", body, skip_failure_hook=True)
1086
+ response_obj = self._post_json("/auth/validate", body)
994
1087
  expected_nonce = str(body.get("nonce", "")).strip()
995
1088
  parsed = self._parse_validate_success(response_obj, expected_nonce)
996
1089
  result: ValidateLicenseSuccess = {
@@ -1049,6 +1142,8 @@ class AuthForgeClient:
1049
1142
 
1050
1143
  resolved_session = resolved_session or current_session
1051
1144
 
1145
+ # Every failure raises, a network failure as AuthForgeError
1146
+ # network_error / timeout; self_ban never calls on_failure / os._exit.
1052
1147
  if resolved_session:
1053
1148
  body: Dict[str, Any] = {
1054
1149
  "appId": self.app_id,
@@ -1181,7 +1276,7 @@ class AuthForgeClient:
1181
1276
  "hwid": hwid,
1182
1277
  }
1183
1278
  # Network failures surface once, as heartbeat_failed / network_error.
1184
- response_obj = self._post_json("/auth/heartbeat", body, skip_failure_hook=True)
1279
+ response_obj = self._post_json("/auth/heartbeat", body)
1185
1280
  if not self._is_success_status(response_obj.get("status")):
1186
1281
  raise self._heartbeat_verdict(response_obj)
1187
1282
  expected_nonce = str(body.get("nonce", "")).strip()
@@ -1240,6 +1335,7 @@ class AuthForgeClient:
1240
1335
  }
1241
1336
  if self.ttl_seconds is not None:
1242
1337
  body["ttlSeconds"] = self.ttl_seconds
1338
+ # Network failures surface once, as login_failed / network_error.
1243
1339
  response_obj = self._post_json("/auth/validate", body)
1244
1340
  expected_nonce = str(body.get("nonce", "")).strip()
1245
1341
  self._apply_signed_response(
@@ -1344,9 +1440,7 @@ class AuthForgeClient:
1344
1440
  self._license_variables = parsed["license_variables"]
1345
1441
  self._authenticated = True
1346
1442
 
1347
- def _post_json(
1348
- self, path: str, data: Dict[str, Any], *, skip_failure_hook: bool = False
1349
- ) -> Dict[str, Any]:
1443
+ def _post_json(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
1350
1444
  url = f"{self.api_base_url}{path}"
1351
1445
  body = dict(data)
1352
1446
  rate_attempt = 0
@@ -1386,8 +1480,6 @@ class AuthForgeClient:
1386
1480
  network_attempt += 1
1387
1481
  time.sleep(NETWORK_RETRY_DELAY)
1388
1482
  continue
1389
- if not skip_failure_hook:
1390
- self._fail("network_error", exc)
1391
1483
  reason = getattr(exc, "reason", exc)
1392
1484
  code = "timeout" if isinstance(reason, (socket.timeout, TimeoutError)) else "network_error"
1393
1485
  raise AuthForgeError(code, f"url_error: {exc}") from exc
@@ -1414,6 +1506,11 @@ class AuthForgeClient:
1414
1506
  return obj
1415
1507
 
1416
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:
1417
1514
  mac = self._safe_mac_address()
1418
1515
  cpu = self._safe_cpu_info()
1419
1516
  disk = self._safe_disk_serial()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: authforge-sdk
3
- Version: 1.5.0
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
@@ -52,7 +52,7 @@ Everything in this list ships in `authforge.py` today:
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
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.
54
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.
55
- - **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.
56
56
  - **`hwid_override`**: bind to any identity instead of the machine (for example `tg:<id>`, `discord:<id>`).
57
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.
58
58
  - **Self-ban** (`self_ban()`) for anti-tamper response, both pre-session and post-session.
@@ -119,7 +119,7 @@ client = AuthForgeClient(
119
119
  | `on_failure` | callable | `None` | Callback `(reason: str, exc: Exception | None)` on auth failure |
120
120
  | `request_timeout` | int | `15` | HTTP request timeout in seconds |
121
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. |
122
- | `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. |
123
123
  | `online_heartbeat` | bool (keyword-only) | `False` | Enable online check-ins: periodic `POST /auth/heartbeat` for fast revocation and concurrent-use detection. |
124
124
 
125
125
  ### Identity-based binding example (Telegram/Discord)
@@ -190,7 +190,7 @@ if client.login_from_file("license.authforge"):
190
190
  print(client.get_license_variables())
191
191
  ```
192
192
 
193
- 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).
194
194
 
195
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`.
196
196
 
@@ -297,7 +297,7 @@ A desktop app running 6h/day with online check-ins at a 15-minute interval burns
297
297
  |---|---|---|
298
298
  | `login(license_key)` | `bool` | Activates: validates the key online and stores the signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
299
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` |
300
- | `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` |
301
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
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` |
303
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 |
@@ -323,6 +323,8 @@ Recognized server errors (`KNOWN_SERVER_ERRORS`):
323
323
 
324
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
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`.
327
+
326
328
  Request retries are automatic inside the internal HTTP layer:
327
329
  - `rate_limited`, or HTTP 429 with no error code: retry after 2s, then 5s (max 3 attempts total)
328
330
  - `no_credits`, `demo_quota_exceeded` and `app_burn_cap_reached` also arrive as HTTP 429 but are never retried immediately; online check-ins try again at the next interval
@@ -343,7 +345,7 @@ A failed check-in only counts as an AuthForge verdict when the body is a JSON ob
343
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. |
344
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. |
345
347
 
346
- 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.
347
349
 
348
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.
349
351
 
@@ -413,11 +415,12 @@ client.self_ban(
413
415
  - Uses post-session mode when a session token is available (`session_token` arg or current SDK session).
414
416
  - Falls back to pre-session mode using `license_key` + nonce + app secret.
415
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.
416
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.
417
420
 
418
421
  ## How It Works
419
422
 
420
- 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.
421
424
 
422
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.
423
426
 
@@ -425,18 +428,29 @@ client.self_ban(
425
428
 
426
429
  ## Hardware ID
427
430
 
428
- The SDK generates a deterministic hardware fingerprint by hashing:
429
- - MAC address
430
- - CPU identifier
431
- - 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.
432
446
 
433
- 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.
434
448
 
435
449
  For non-device identities (for example Telegram users), pass `hwid_override` such as `tg:<user_id>`.
436
450
 
437
451
  ## Test Vectors
438
452
 
439
- 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.
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.
440
454
 
441
455
  ## Requirements
442
456
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "authforge-sdk"
7
- version = "1.5.0"
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