Metadata-Version: 2.4
Name: authforge-sdk
Version: 2.0.0
Summary: Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses.
Author: AuthForge
License: MIT
Project-URL: Homepage, https://authforge.cc
Project-URL: Documentation, https://docs.authforge.cc
Project-URL: Source, https://github.com/AuthForgeCC/authforge-python
Project-URL: Issues, https://github.com/AuthForgeCC/authforge-python/issues
Keywords: authforge,license,licensing,hwid,authentication
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: cryptography>=41.0.0
Requires-Dist: typing_extensions>=4.2.0
Dynamic: license-file

# AuthForge Python SDK

Official Python SDK for [AuthForge](https://authforge.cc): credit-based license key authentication with Ed25519-verified responses.

Uses `cryptography` for Ed25519 verification. Works on Python 3.9+.

## How licensing works

1. **Activate online.** `login()` calls `POST /auth/validate`. The server checks revocation, expiry, HWID binding, and credits, then returns an Ed25519-signed session with a TTL.
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.
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.

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).

## Features

Everything in this list ships in `authforge.py` today:

- **License validation** via `POST /auth/validate`, returning a signed session payload.
- **Ed25519 signature verification** on every `/auth/validate` and `/auth/heartbeat` response; tampered or unsigned responses are rejected.
- **Key rotation**: `public_key` accepts a single key, a list of keys, or a comma-separated string. The SDK trusts a signature that matches **any** key in the list, so you can roll the server-side signing key without breaking deployed clients.
- **Grace period by default**: after one successful activation, the app runs on the signed session (no network) until the TTL expires.
- **Online check-ins** (opt-in): periodic `/auth/heartbeat` calls for fast revocation and concurrent-use detection.
- **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.
- **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.
- **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.
- **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.
- **`hwid_override`**: bind to any identity instead of the machine (for example `tg:<id>`, `discord:<id>`).
- **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.
- **Self-ban** (`self_ban()`) for anti-tamper response, both pre-session and post-session.
- **Grace period duration** control via `ttl_seconds`, with server-side clamping to `[3600, 604800]`.
- **App variables / license variables** for feature flags and tiered licensing.
- **Automatic retries** for rate-limited and transient network failures, with a fresh nonce per retry.

## Installation

Install from [PyPI](https://pypi.org/project/authforge-sdk/) as **`authforge-sdk`**. In code, import the **`authforge`** module:

```bash
pip install authforge-sdk
```

**Alternative:** copy `authforge.py` into your project if you need a single-file vendored layout (you must still satisfy the `cryptography` dependency yourself).

## Quick Start

After **`pip install authforge-sdk`** (or vendoring `authforge.py`), use:

```python
from authforge import AuthForgeClient

client = AuthForgeClient(
    app_id="YOUR_APP_ID",           # from your AuthForge dashboard
    app_secret="YOUR_APP_SECRET",   # from your AuthForge dashboard
    public_key="YOUR_PUBLIC_KEY",   # from your AuthForge dashboard
)

license_key = input("Enter license key: ")

if client.login(license_key):
    print("Activated!")
    # Your app logic here. The app runs through the grace period by default:
    # no further network calls until the session TTL expires.
else:
    print("Invalid license key.")
    exit(1)
```

To detect revocations quickly (or concurrent use), enable online check-ins:

```python
client = AuthForgeClient(
    app_id="YOUR_APP_ID",
    app_secret="YOUR_APP_SECRET",
    public_key="YOUR_PUBLIC_KEY",
    heartbeat_interval=900,   # check in every 15 minutes
    online_heartbeat=True,    # periodic POST /auth/heartbeat
)
```

## Configuration

| Parameter | Type | Default | Description |
|---|---|---|---|
| `app_id` | str | required | Your application ID from the AuthForge dashboard |
| `app_secret` | str | required for online APIs; `None` / `""` for `login_from_file` only | Your application secret from the AuthForge dashboard. Do not ship it in air-gapped binaries. |
| `public_key` | `str \| Sequence[str]` | required | App Ed25519 public key(s) (base64) from dashboard. Pass one key, a list of keys, or a comma-separated string to trust multiple keys during rotation (see [Key rotation](#key-rotation)). |
| `heartbeat_mode` | `str \| None` | `None` | Deprecated shim (see [Migrating from heartbeat_mode](#migrating-from-heartbeat_mode)). Use `online_heartbeat` instead. |
| `heartbeat_interval` | int | `900` | Seconds between background checks (minimum `10`; default 15 min). Applies to both grace period checks and online check-ins. |
| `api_base_url` | str | `https://auth.authforge.cc` | API endpoint |
| `on_failure` | callable | `None` | Callback `(reason: str, exc: Exception | None)` on auth failure |
| `request_timeout` | int | `15` | HTTP request timeout in seconds |
| `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. |
| `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. |
| `online_heartbeat` | bool (keyword-only) | `False` | Enable online check-ins: periodic `POST /auth/heartbeat` for fast revocation and concurrent-use detection. |

### Identity-based binding example (Telegram/Discord)

```python
client = AuthForgeClient(
    app_id="YOUR_APP_ID",
    app_secret="YOUR_APP_SECRET",
    public_key="YOUR_PUBLIC_KEY",
    hwid_override=f"tg:{telegram_user_id}",  # or f"discord:{discord_user_id}"
)
```

### Key rotation

`public_key` is a trust list. To rotate the server-side signing key without a
flag-day, ship the **new** key alongside the **previous** one; the SDK accepts a
signature that matches any entry:

```python
client = AuthForgeClient(
    app_id="YOUR_APP_ID",
    app_secret="YOUR_APP_SECRET",
    public_key=["NEW_PUBLIC_KEY", "PREVIOUS_PUBLIC_KEY"],  # or "NEW,PREVIOUS"
)
```

`client.public_keys` exposes the full trust list; `client.public_key` is the
first (primary) entry.

## Grace period and online check-ins

**Grace period (default).** After a successful activation, the SDK re-verifies the cached signed session payload and checks the expiry timestamp locally at each `heartbeat_interval`: no network calls. When the session TTL expires, it triggers failure with `session_expired`. The grace period is **session continuation within the session TTL** (default 24h, server clamps 1h to 7d) after one successful online activation. It is not persistent offline licensing, and a mid-session revocation is not picked up until the next online validate or check-in.

**Online check-ins (opt-in, `online_heartbeat=True`).** The SDK calls `/auth/heartbeat` every `heartbeat_interval` seconds with a fresh nonce, verifies signature + nonce, and triggers failure on invalid session state. Use this when you need fast revocation propagation or concurrent-use detection. A definitive rejection (`revoked`, `hwid_mismatch`, `blocked`, ...) clears the stored session immediately; every other failure (network, `rate_limited`, `system_error`, `no_credits`, unrecognized responses, ...) is transient and keeps it until the session TTL runs out. See [Background check failures](#background-check-failures).

Tune `ttl_seconds` to set the grace period duration (server default 24h, clamped to 1h to 7d).

## Offline license files (`.authforge`)

For machines that never connect to the internet, the operator mints a **signed offline license file** in the AuthForge dashboard (License page -> *Mint .authforge file*) or via `POST /v1/licenses/{licenseKey}/offline-files`. The file is a standalone Ed25519-signed document; the SDK verifies it with **only** your app public key and the machine HWID. It never contacts AuthForge and never starts online check-ins. Pass `app_secret=None` (or `""`) so the air-gapped binary does not contain the App Secret.

| | Grace period (default) | Offline license file |
| --- | --- | --- |
| Needs network | Once, at `login()` | Never on the end machine |
| What is verified | Signed *session* from `/auth/validate` | Signed *document* minted in the cloud |
| Lifetime | Session TTL: 1h to 7d | Operator-chosen expiry or lifetime (perpetual licenses only) |
| Revocation | Picked up at the next online validate / check-in | **Not** reachable: the file stays valid until its own expiry |
| Cost | 1 credit per `login()` | 1 credit per mint; verifying is free |

```python
from authforge import AuthForgeClient

client = AuthForgeClient(
    app_id="YOUR_APP_ID",
    app_secret=None,  # login_from_file does not use the App Secret; do not ship it in air-gapped builds
    public_key="YOUR_PUBLIC_KEY",
    on_failure=lambda reason, exc: print(reason, exc),
)

# 1. Write an activation request the operator drops into the mint dialog:
client.write_activation_request("machine.authforge-request")

# 2. Later, authorize from the minted file (path or armored text). No network.
if client.login_from_file("license.authforge"):
    info = client.get_offline_license()
    print("Offline license OK until", info["expires_at"] or "forever")
    print(client.get_license_variables())
```

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).

`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`.

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.

## Self-serve offline activation

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.

How it differs from air-gapped offline files:

- 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`.
- 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.
- **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.
- **A refund or chargeback revokes the license online but cannot reach files already issued.** A lifetime file keeps working on its machine.

```python
import os
import sys

from authforge import AuthForgeClient, AuthForgeError

LICENSE_PATH = os.path.join(os.path.expanduser("~"), ".myapp", "license.authforge")

client = AuthForgeClient(
    app_id="YOUR_APP_ID",
    app_secret="YOUR_APP_SECRET",  # required for activate_offline (online call)
    public_key="YOUR_PUBLIC_KEY",
    on_failure=lambda reason, exc: None,  # login_from_file reports why it failed here
)

# 1. No network, no credits: run from the file if it is there and valid.
if not (os.path.exists(LICENSE_PATH) and client.login_from_file(LICENSE_PATH)):
    # 2. No file yet, or it fails (e.g. hwid_mismatch after a hardware change).
    key = input("License key: ").strip()
    try:
        client.activate_offline(key, path=LICENSE_PATH)
    except AuthForgeError as exc:
        if exc.code == "offline_activation_limit_reached":
            print("This license is active on its maximum number of machines. Please contact the vendor.")
        elif exc.code in {"invalid_key", "revoked", "expired"}:
            print("That license key is not valid.")
        elif exc.code == "hwid_mismatch":
            print("No free seat on this license for this machine.")
        elif exc.transient:
            print(f"Could not activate right now ({exc.code}). Check your connection and try again.")
        else:
            print(f"Activation failed: {exc.code}")
        sys.exit(1)

print("Licensed:", client.get_license_variables())
```

`activate_offline(license_key, path=None)` returns `True` on success and raises `AuthForgeError` otherwise. It:

1. Fails fast with the usual "app_secret is required" `ValueError` (no request) when the client has no App Secret.
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).
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`, ...).
4. If `path` is set, writes the file byte-for-byte and atomically (temp file in the same directory, then rename), creating parent directories.
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.

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.

| Code | Kind | Meaning |
| --- | --- | --- |
| `offline_activation_disabled` | fatal | The app has not opted in to self-serve activation |
| `offline_activation_requires_perpetual` | fatal | The license has an expiry; only perpetual licenses can self-serve |
| `offline_activation_requires_seats` | fatal | Unlimited-seat (shared) licenses cannot self-serve |
| `offline_activation_limit_reached` | fatal | Every machine slot on the license is used; the buyer must contact the vendor |
| `offline_file_rejected` | fatal | SDK code: the returned file failed local verification; nothing was written |
| `hwid_mismatch` | fatal | No free seat on the license for this machine (server code, not the verifier's) |
| `file_write_failed` | transient | The verified file could not be written to `path`; activating again is free |
| `invalid_key`, `revoked`, `expired`, `no_credits`, `rate_limited`, `unexpected_response`, `network_error`, ... | as everywhere else | Existing codes keep their meaning and classification |

## Migrating from heartbeat_mode

Earlier releases required `heartbeat_mode="LOCAL"` or `"SERVER"`. The argument is now optional and deprecated; it still works but emits a `DeprecationWarning`.

- `heartbeat_mode="LOCAL"` maps to the default behavior (the grace period): just remove the argument.
- `heartbeat_mode="SERVER"` maps to `online_heartbeat=True`.
- If both arguments are set, either one enables online check-ins: `heartbeat_mode="SERVER"` is not overridden by `online_heartbeat=False`.

```python
# Before
client = AuthForgeClient(app_id, app_secret, public_key, heartbeat_mode="SERVER")

# After
client = AuthForgeClient(app_id, app_secret, public_key, online_heartbeat=True)
```

For code that still reads it, `client.heartbeat_mode` remains available and reflects the effective policy (`"SERVER"` when online check-ins are enabled, `"LOCAL"` otherwise).

## Billing

- **1 `login()` or `validate_license()` call = 1 credit** (one `/auth/validate` debit each).
- **10 online check-ins on the same license = 1 credit** (billed every 10th successful `/auth/heartbeat`). Grace period checks are local and free.
- **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.

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).

## Methods

| Method | Returns | Description |
|---|---|---|
| `login(license_key)` | `bool` | Activates: validates the key online and stores the signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
| `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` |
| `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` |
| `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", …)` |
| `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` |
| `verify_license_file(path_or_text, *, now=None)` | `VerifyLicenseFileResult` | Verifies a `.authforge` file with this client's app id / keys / HWID without changing state |
| `get_offline_license()` | `dict \| None` | Metadata of the offline file in use (`jti`, `expires_at`, `hwid_policy`, …) |
| `get_session_kind()` | `"online" \| "offline" \| None` | Which kind of session the client holds (`None` when logged out) |
| `get_hwid()` | `str` | The HWID this client sends (or `hwid_override`); customers share it to receive a bound file |
| `create_activation_request(**kwargs)` | `str` | Unsigned `.authforge-request` for this machine. No network, no secret. Hostname omitted unless `include_machine_name=True` |
| `write_activation_request(path, **kwargs)` | `None` | Writes that file as UTF-8 |
| `logout()` | `None` | Stops background checks and clears all session/auth state |
| `is_authenticated()` | `bool` | True when an active authenticated session exists |
| `get_session_data()` | `dict \| None` | Full decoded payload map |
| `get_app_variables()` | `dict \| None` | App-scoped variables map |
| `get_license_variables()` | `dict \| None` | License-scoped variables map |

## Failure Handling

If authentication fails (activation rejected, check-in fails, signature mismatch, grace period expired, etc.), the SDK calls your `on_failure` callback if one is provided. Without a callback, a transient background check failure (network outage, `rate_limited`, `system_error`, ...) writes a one-line warning to stderr and check-ins continue; every other failure (a rejected `login()`, a definitive check-in answer, the grace period running out) **calls `os._exit(1)` to terminate the process**, so your app cannot keep running without a valid license. `os._exit` skips all cleanup, so set `on_failure` if your app has anything to save.

**`validate_license()`** does not trigger `on_failure` or `os._exit`: check `result["valid"]` and `result["code"]`.

Recognized server errors (`KNOWN_SERVER_ERRORS`):
`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.

**`activate_offline()`** does not trigger `on_failure` or `os._exit` either: it raises `AuthForgeError` (see [Self-serve offline activation](#self-serve-offline-activation)).

**`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`.

Request retries are automatic inside the internal HTTP layer:
- `rate_limited`, or HTTP 429 with no error code: retry after 2s, then 5s (max 3 attempts total)
- `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
- network failure: retry once after 2s
- every retry regenerates a fresh nonce

### Background check failures

Background check failures reach `on_failure("heartbeat_failed", exc)`, where `exc` is an `AuthForgeError` (a `ValueError` subclass):

- `exc.code`: the server's error code from the response body, whatever the HTTP status (`revoked`, `expired`, `hwid_mismatch`, `blocked`, `session_expired`, `rate_limited`, `no_credits`, `system_error`, `malformed_request`, ...), or an SDK code: `network_error`, `timeout`, `http_error_<status>` (non-JSON error body), `invalid_json_response`, `unexpected_response`, `signature_mismatch`, `nonce_mismatch`.
- `exc.transient` / `exc.fatal`: the classification. `is_transient_error(code_or_exc)` is the same check as a function; `DEFINITIVE_ERROR_CODES` is the full list of fatal codes.

A failed check-in only counts as an AuthForge verdict when the body is a JSON object with `"status": "failed"` and a non-empty `error` string. Any other failure body (for example a proxy's JSON, or `{"status": "revoked"}`) is reported as `unexpected_response`, whose message includes the raw `status` and `error`.

| Kind | Codes | What the SDK does |
|---|---|---|
| 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. |
| 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. |

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.

`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.

To tolerate short outages but shut down on a definitive answer, have the callback signal your main thread and let the main thread save and exit:

```python
import sys
import threading
from authforge import AuthForgeClient, AuthForgeError

license_lost = threading.Event()

def handle_auth_failure(reason, exc):
    if reason == "heartbeat_failed" and isinstance(exc, AuthForgeError) and exc.transient:
        # Connectivity problem or AuthForge overloaded: keep running. The SDK
        # retries every heartbeat_interval and reports session_expired (fatal)
        # once the ttl_seconds grace period is used up.
        print(f"AuthForge check-in failed ({exc.code}), retrying", file=sys.stderr)
        return
    code = exc.code if isinstance(exc, AuthForgeError) else exc
    print(f"License check failed: {reason} ({code})", file=sys.stderr)
    # This can run on the heartbeat thread: signal the main thread instead of exiting here.
    license_lost.set()

client = AuthForgeClient(
    app_id="YOUR_APP_ID",
    app_secret="YOUR_APP_SECRET",
    public_key="YOUR_PUBLIC_KEY",
    online_heartbeat=True,
    ttl_seconds=3600,  # retry window for transient check-in failures
    on_failure=handle_auth_failure,
)

if not client.login(input("License key: ").strip()):
    sys.exit(1)

while not license_lost.is_set():
    do_one_unit_of_work()  # keep units short so the loop notices the signal quickly

save_user_work()
client.logout()
sys.exit(1)
```

If your main thread is blocked (for example in `input()` or a long `time.sleep()`) rather than looping, call `_thread.interrupt_main()` from the callback instead: it raises `KeyboardInterrupt` on the main thread, so your existing `try` / `finally` cleanup runs. `sys.exit()` inside the callback only ends the heartbeat thread. Calling `os._exit(1)` from the callback is a last resort for when the main thread cannot react at all: it skips `finally` blocks, `atexit` handlers and unflushed file writes, so save the user's work first.

## Self-ban (tamper response)

Use `self_ban()` when anti-tamper checks trigger:

```python
# Post-session (authenticated): defaults to revoke + HWID/IP blacklist.
client.self_ban()

# Pre-session: pass license_key, SDK automatically disables revoke_license.
client.self_ban(license_key="AF-XXXX-XXXX-XXXX")

# Custom flags:
client.self_ban(
    blacklist_hwid=True,
    blacklist_ip=True,
    revoke_license=False,
)
```

`self_ban()` automatically chooses mode:
- Uses post-session mode when a session token is available (`session_token` arg or current SDK session).
- Falls back to pre-session mode using `license_key` + nonce + app secret.
- In pre-session mode, revoke is forced off client-side to avoid unsafe key revocations.
- 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.
- 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.

## How It Works

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.

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.

3. **Crypto**: both `/validate` and `/heartbeat` responses are signed by AuthForge with your app's Ed25519 private key. The SDK verifies every signed `payload` using your configured `public_key` and rejects tampered responses.

## Hardware ID

The HWID (v2, since 2.0) is

`SHA256("authforge-hwid-v2|" + app_id + "|" + machine_id)` as lowercase hex,

where `machine_id` is the operating system's own machine id, trimmed and lowercased:

| OS | Source |
| -- | ------ |
| macOS | `IOPlatformUUID` (`/usr/sbin/ioreg -rd1 -c IOPlatformExpertDevice`) |
| Linux | `/etc/machine-id`, then `/var/lib/dbus/machine-id` |
| Windows | `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (64-bit registry view, via `winreg`) |

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.

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.

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.

For non-device identities (for example Telegram users), pass `hwid_override` such as `tg:<user_id>`.

## Test Vectors

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.

## Requirements

- Python 3.9+
- Dependency: `cryptography`

## License

MIT
