Skip to content

Commit a7e3306

Browse files
committed
feat: self-ban support, HWID override support, release 1.0.2
1 parent a541f97 commit a7e3306

3 files changed

Lines changed: 114 additions & 3 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ if __name__ == "__main__":
6666
| `on_failure` | `Callable[[str, Optional[Exception]], None] \| None` | no | `None` | Called on login/heartbeat/network failure; if omitted, process exits via `os._exit(1)` |
6767
| `request_timeout` | `int` | no | `15` | HTTP timeout (seconds) |
6868
| `ttl_seconds` | `int \| None` | no | `None` (server default: 86400) | Requested session token lifetime. Server clamps to `[3600, 604800]`; preserved across heartbeat refreshes. |
69+
| `hwid_override` | `str \| None` | no | `None` | Optional custom HWID/subject string. When set to a non-empty value (for example `tg:123456789`), the SDK sends it instead of generating a machine fingerprint. |
70+
71+
For Telegram/Discord bot flows, prefer immutable IDs (`tg:<user_id>`, `discord:<user_id>`) instead of usernames.
6972

7073
## Methods
7174

README.md

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,19 @@ else:
5151
| `on_failure` | callable | `None` | Callback `(reason: str, exc: Exception | None)` on auth failure |
5252
| `request_timeout` | int | `15` | HTTP request timeout in seconds |
5353
| `ttl_seconds` | `int \| None` | `None` (server default: 86400) | Requested session token lifetime. Server clamps to `[3600, 604800]`; preserved across heartbeat refreshes. |
54+
| `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. |
55+
56+
### Identity-based binding example (Telegram/Discord)
57+
58+
```python
59+
client = AuthForgeClient(
60+
app_id="YOUR_APP_ID",
61+
app_secret="YOUR_APP_SECRET",
62+
public_key="YOUR_PUBLIC_KEY",
63+
heartbeat_mode="SERVER",
64+
hwid_override=f"tg:{telegram_user_id}", # or f"discord:{discord_user_id}"
65+
)
66+
```
5467

5568
## Billing
5669

@@ -64,6 +77,7 @@ A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A
6477
| Method | Returns | Description |
6578
|---|---|---|
6679
| `login(license_key)` | `bool` | Validates key and stores signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
80+
| `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only) |
6781
| `logout()` | `None` | Stops heartbeat and clears all session/auth state |
6882
| `is_authenticated()` | `bool` | True when an active authenticated session exists |
6983
| `get_session_data()` | `dict \| None` | Full decoded payload map |
@@ -81,7 +95,7 @@ A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A
8195
If authentication fails (login rejected, heartbeat fails, signature mismatch, etc.), the SDK calls your `on_failure` callback if one is provided. If no callback is set, **the SDK calls `os._exit(1)` to terminate the process.** This is intentional — it prevents your app from running without a valid license.
8296

8397
Recognized server errors:
84-
`invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `bad_request`
98+
`invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`
8599

86100
Request retries are automatic inside the internal HTTP layer:
87101
- `rate_limited`: retry after 2s, then 5s (max 3 attempts total)
@@ -105,9 +119,33 @@ client = AuthForgeClient(
105119
)
106120
```
107121

122+
## Self-ban (tamper response)
123+
124+
Use `self_ban()` when anti-tamper checks trigger:
125+
126+
```python
127+
# Post-session (authenticated): defaults to revoke + HWID/IP blacklist.
128+
client.self_ban()
129+
130+
# Pre-session: pass license_key, SDK automatically disables revoke_license.
131+
client.self_ban(license_key="AF-XXXX-XXXX-XXXX")
132+
133+
# Custom flags:
134+
client.self_ban(
135+
blacklist_hwid=True,
136+
blacklist_ip=True,
137+
revoke_license=False,
138+
)
139+
```
140+
141+
`self_ban()` automatically chooses mode:
142+
- Uses post-session mode when a session token is available (`session_token` arg or current SDK session).
143+
- Falls back to pre-session mode using `license_key` + nonce + app secret.
144+
- In pre-session mode, revoke is forced off client-side to avoid unsafe key revocations.
145+
108146
## How It Works
109147

110-
1. **Login**Collects a hardware fingerprint (MAC, CPU, disk serial), 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. The SDK verifies the Ed25519 signature and nonce to prevent replay attacks.
148+
1. **Login**Uses `hwid_override` if provided; otherwise 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. The SDK verifies the Ed25519 signature and nonce to prevent replay attacks.
111149

112150
2. **Heartbeat** — A background daemon thread checks in at the configured interval. In SERVER mode, it sends a fresh nonce and verifies the response. In LOCAL mode, it re-verifies the stored signature and checks expiry without network calls.
113151

@@ -122,6 +160,8 @@ The SDK generates a deterministic hardware fingerprint by hashing:
122160

123161
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.
124162

163+
For non-device identities (for example Telegram users), pass `hwid_override` such as `tg:<user_id>`.
164+
125165
## Test Vectors
126166

127167
The shared `test_vectors.json` file validates cross-language Ed25519 verification behavior.

authforge.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"replay_detected",
3333
"app_disabled",
3434
"session_expired",
35+
"revoke_requires_session",
3536
"bad_request",
3637
"system_error",
3738
}
@@ -49,6 +50,7 @@ def __init__(
4950
on_failure: Optional[Callable[[str, Optional[Exception]], None]] = None,
5051
request_timeout: int = 15,
5152
ttl_seconds: Optional[int] = None,
53+
hwid_override: Optional[str] = None,
5254
) -> None:
5355
if not app_id or not isinstance(app_id, str):
5456
raise ValueError("app_id must be a non-empty string")
@@ -92,7 +94,7 @@ def __init__(
9294
self._app_variables: Optional[Dict[str, Any]] = None
9395
self._license_variables: Optional[Dict[str, Any]] = None
9496
self._authenticated = False
95-
self._hwid = self._get_hwid()
97+
self._hwid = self._resolve_hwid(hwid_override)
9698
self._ed25519_public_key = self._load_public_key(public_key)
9799

98100
def login(self, license_key: str) -> bool:
@@ -107,6 +109,65 @@ def login(self, license_key: str) -> bool:
107109
self._fail("login_failed", exc)
108110
return False
109111

112+
def self_ban(
113+
self,
114+
*,
115+
license_key: Optional[str] = None,
116+
session_token: Optional[str] = None,
117+
revoke_license: bool = True,
118+
blacklist_hwid: bool = True,
119+
blacklist_ip: bool = True,
120+
) -> Dict[str, Any]:
121+
resolved_session = (
122+
session_token.strip()
123+
if isinstance(session_token, str) and session_token.strip()
124+
else None
125+
)
126+
with self._lock:
127+
current_session = self._session_token
128+
current_license = self._license_key
129+
hwid = self._hwid
130+
resolved_session = resolved_session or current_session
131+
132+
if resolved_session:
133+
body: Dict[str, Any] = {
134+
"appId": self.app_id,
135+
"sessionToken": resolved_session,
136+
"hwid": hwid,
137+
"revokeLicense": bool(revoke_license),
138+
"blacklistHwid": bool(blacklist_hwid),
139+
"blacklistIp": bool(blacklist_ip),
140+
}
141+
response_obj = self._post_json("/auth/selfban", body)
142+
if not self._is_success_status(response_obj.get("status")):
143+
raise ValueError(self._extract_server_error(response_obj))
144+
return response_obj
145+
146+
resolved_license = (
147+
license_key.strip()
148+
if isinstance(license_key, str) and license_key.strip()
149+
else None
150+
)
151+
resolved_license = resolved_license or current_license
152+
if not resolved_license:
153+
raise ValueError("missing_license_key")
154+
155+
body = {
156+
"appId": self.app_id,
157+
"appSecret": self.app_secret,
158+
"licenseKey": resolved_license,
159+
"hwid": hwid,
160+
"nonce": self._generate_nonce(),
161+
# Pre-session self-ban cannot revoke licenses.
162+
"revokeLicense": False,
163+
"blacklistHwid": bool(blacklist_hwid),
164+
"blacklistIp": bool(blacklist_ip),
165+
}
166+
response_obj = self._post_json("/auth/selfban", body)
167+
if not self._is_success_status(response_obj.get("status")):
168+
raise ValueError(self._extract_server_error(response_obj))
169+
return response_obj
170+
110171
def _start_heartbeat_once(self) -> None:
111172
with self._lock:
112173
if self._heartbeat_started:
@@ -310,6 +371,13 @@ def _get_hwid(self) -> str:
310371
material = f"mac:{mac}|cpu:{cpu}|disk:{disk}"
311372
return hashlib.sha256(material.encode("utf-8")).hexdigest()
312373

374+
def _resolve_hwid(self, hwid_override: Optional[str]) -> str:
375+
if isinstance(hwid_override, str):
376+
trimmed = hwid_override.strip()
377+
if trimmed:
378+
return trimmed
379+
return self._get_hwid()
380+
313381
def _safe_mac_address(self) -> str:
314382
try:
315383
return f"{uuid.getnode():012x}"

0 commit comments

Comments
 (0)