pwfauth 1.0.0__py3-none-any.whl

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.
pwfauth/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """Official Python client for PWF Auth (https://pwfauth.com).
2
+
3
+ License-key activation, hardware-ID binding, encrypted sessions with a
4
+ server-driven kill switch, free trials, remote texts/slides, and OTA update checks.
5
+
6
+ from pwfauth import PwfClient
7
+ import os, sys
8
+
9
+ client = PwfClient(os.environ["PWFAUTH_SECRET"])
10
+
11
+ def on_session_ended(error_code, message):
12
+ print(f"Session ended ({error_code}): {message}", file=sys.stderr)
13
+ sys.exit(1)
14
+
15
+ client.on_session_ended = on_session_ended
16
+
17
+ login = client.login("XXXXX-XXXXX-XXXXX-XXXXX")
18
+ if not login.success:
19
+ print(login.message, file=sys.stderr)
20
+ sys.exit(1)
21
+
22
+ client.start_heartbeat() # keeps the session alive AND enforces the kill switch
23
+
24
+ The hardware ID is derived identically to the .NET and Node clients, so the same
25
+ machine counts as one device no matter which SDK an app uses.
26
+ """
27
+
28
+ from .client import PwfClient
29
+ from .envelope import CryptoEnvelope
30
+ from .errors import PwfCryptoError, PwfError, PwfErrorCodes, PwfHttpError, ends_session
31
+ from .hardware_id import get_hardware_id, reset_hardware_id_cache
32
+ from .response import License, PwfResponse
33
+
34
+ __version__ = "1.0.0"
35
+
36
+ __all__ = [
37
+ "PwfClient",
38
+ "PwfResponse",
39
+ "License",
40
+ "CryptoEnvelope",
41
+ "PwfError",
42
+ "PwfHttpError",
43
+ "PwfCryptoError",
44
+ "PwfErrorCodes",
45
+ "ends_session",
46
+ "get_hardware_id",
47
+ "reset_hardware_id_cache",
48
+ "__version__",
49
+ ]
pwfauth/client.py ADDED
@@ -0,0 +1,292 @@
1
+ """The PWF Auth client.
2
+
3
+ Mirrors the .NET and Node clients: same envelope, same endpoints, same error codes,
4
+ same hardware-ID derivation. Zero dependencies beyond ``cryptography`` -- HTTP goes
5
+ through :mod:`urllib.request` rather than pulling ``requests`` and its four
6
+ transitive dependencies into every app that installs this.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import threading
13
+ import urllib.error
14
+ import urllib.request
15
+ from typing import Any, Callable
16
+
17
+ from .envelope import CryptoEnvelope
18
+ from .errors import PwfError, PwfHttpError, ends_session
19
+ from .hardware_id import get_hardware_id
20
+ from .response import PwfResponse
21
+
22
+ __all__ = ["PwfClient"]
23
+
24
+ _DEFAULT_BASE_URL = "https://pwfauth.com"
25
+
26
+
27
+ class PwfClient:
28
+ """A licence client for one application.
29
+
30
+ ``on_session_ended`` is the callback that matters: assign a function taking
31
+ ``(error_code, message)`` and it fires the moment the licence stops being valid
32
+ on this machine -- a ban, pause, expiry, HWID reset, revoke, maintenance window,
33
+ or a server that has gone unreachable past the failure budget. Logging alone
34
+ leaves the licence unenforceable; stop the app there.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ app_secret: str,
40
+ *,
41
+ base_url: str = _DEFAULT_BASE_URL,
42
+ heartbeat_seconds: int = 60,
43
+ max_heartbeat_failures: int = 3,
44
+ timeout: float = 15.0,
45
+ hardware_id: str | None = None,
46
+ ) -> None:
47
+ if not app_secret or not isinstance(app_secret, str):
48
+ raise TypeError("app_secret is required.")
49
+
50
+ self.app_secret = app_secret
51
+ self.base_url = base_url.rstrip("/")
52
+ self.heartbeat_seconds = heartbeat_seconds
53
+ self.max_heartbeat_failures = max_heartbeat_failures
54
+ self.timeout = timeout
55
+ self.hardware_id = hardware_id or get_hardware_id()
56
+
57
+ self.on_session_ended: Callable[[str | None, str | None], None] | None = None
58
+
59
+ self._crypto = CryptoEnvelope(app_secret)
60
+ self._session_id: str | None = None
61
+ self._license_key: str | None = None
62
+ self._timer: threading.Timer | None = None
63
+ self._lock = threading.Lock()
64
+ self._failures = 0
65
+
66
+ # ── state ────────────────────────────────────────────────────────────────
67
+
68
+ @property
69
+ def is_signed_in(self) -> bool:
70
+ return self._session_id is not None
71
+
72
+ @property
73
+ def session_id(self) -> str | None:
74
+ return self._session_id
75
+
76
+ # ── licence lifecycle ────────────────────────────────────────────────────
77
+
78
+ def login(self, license_key: str) -> PwfResponse:
79
+ """Activate a licence key on this machine and open a session."""
80
+ if not license_key or not isinstance(license_key, str):
81
+ raise TypeError("license_key is required.")
82
+ res = self.post_envelope("/api/auth/login.php", {
83
+ "license_key": license_key,
84
+ "hwid": self.hardware_id,
85
+ })
86
+ if res.success:
87
+ self._session_id = res.data.get("session_id")
88
+ self._license_key = license_key
89
+ self._failures = 0
90
+ return res
91
+
92
+ def check_key(self, license_key: str) -> PwfResponse:
93
+ """Read a key's state WITHOUT opening a session, so no device seat is used."""
94
+ if not license_key or not isinstance(license_key, str):
95
+ raise TypeError("license_key is required.")
96
+ return self.post_envelope("/api/auth/check-key.php", {
97
+ "license_key": license_key,
98
+ "hwid": self.hardware_id,
99
+ })
100
+
101
+ def heartbeat(self) -> PwfResponse:
102
+ """Send one heartbeat. Raises if called before :meth:`login`."""
103
+ if not self._session_id:
104
+ raise PwfError("Not signed in — call login() before heartbeat().")
105
+ return self.post_envelope("/api/auth/heartbeat.php", {
106
+ "session_id": self._session_id,
107
+ "license_key": self._license_key,
108
+ "hwid": self.hardware_id,
109
+ })
110
+
111
+ def logout(self) -> PwfResponse:
112
+ """Close the session and free the device seat."""
113
+ if not self._session_id:
114
+ raise PwfError("Not signed in.")
115
+ try:
116
+ return self.post_envelope("/api/auth/logout.php", {
117
+ "session_id": self._session_id,
118
+ "license_key": self._license_key,
119
+ })
120
+ finally:
121
+ self.stop_heartbeat()
122
+ self._session_id = None
123
+ self._license_key = None
124
+
125
+ # ── heartbeat loop ───────────────────────────────────────────────────────
126
+
127
+ def start_heartbeat(self) -> None:
128
+ """Begin the background heartbeat.
129
+
130
+ This is what turns a one-time check into a live session and enforces the
131
+ kill switch. A daemon timer, so it never keeps the interpreter alive.
132
+ """
133
+ if not self._session_id:
134
+ raise PwfError("Not signed in — call login() before start_heartbeat().")
135
+ self.stop_heartbeat()
136
+ self._schedule()
137
+
138
+ def stop_heartbeat(self) -> None:
139
+ with self._lock:
140
+ if self._timer is not None:
141
+ self._timer.cancel()
142
+ self._timer = None
143
+
144
+ def _schedule(self) -> None:
145
+ with self._lock:
146
+ self._timer = threading.Timer(self.heartbeat_seconds, self._tick)
147
+ self._timer.daemon = True
148
+ self._timer.start()
149
+
150
+ def _tick(self) -> None:
151
+ if not self._session_id:
152
+ return
153
+ try:
154
+ res = self.heartbeat()
155
+ except Exception:
156
+ # Unreachable server. Tolerate a few in a row — a laptop lid, a train
157
+ # tunnel, a Wi-Fi handover — then treat it as the session ending, so a
158
+ # pulled network cable cannot be used to outrun a revoke.
159
+ self._failures += 1
160
+ if self._failures >= self.max_heartbeat_failures:
161
+ self._end_session("NETWORK_LOST", "The license server is unreachable.")
162
+ return
163
+ self._schedule()
164
+ return
165
+
166
+ self._failures = 0
167
+ if not res.success and ends_session(res.error_code):
168
+ self._end_session(res.error_code, res.message)
169
+ return
170
+ # Any other failure is transient (rate limit, a 500) — keep the loop alive.
171
+ self._schedule()
172
+
173
+ def _end_session(self, error_code: str | None, message: str | None) -> None:
174
+ self.stop_heartbeat()
175
+ self._session_id = None
176
+ self._license_key = None
177
+ if self.on_session_ended:
178
+ self.on_session_ended(error_code, message)
179
+
180
+ # ── app content and updates ──────────────────────────────────────────────
181
+
182
+ def get_app_info(self) -> PwfResponse:
183
+ return self.get_envelope("/api/app/info.php")
184
+
185
+ def get_texts(self, license_key: str | None = None) -> PwfResponse:
186
+ return self.get_envelope("/api/app/text.php", bearer_license_key=license_key)
187
+
188
+ def get_slides(self) -> PwfResponse:
189
+ return self.post_envelope("/api/app/slides.php", {})
190
+
191
+ def check_update(self, current_version: str) -> PwfResponse:
192
+ if not current_version or not isinstance(current_version, str):
193
+ raise TypeError("current_version is required.")
194
+ return self.post_envelope("/api/update/check.php", {"version": current_version})
195
+
196
+ def track_social_click(self, platform: str) -> PwfResponse:
197
+ if not platform or not isinstance(platform, str):
198
+ raise TypeError("platform is required.")
199
+ return self.post_envelope("/api/app/social-click.php", {"platform": platform})
200
+
201
+ # ── plain-JSON endpoints (no envelope) ───────────────────────────────────
202
+
203
+ def create_trial(self) -> PwfResponse:
204
+ return self.post_plain("/api/auth/trial.php", {"hwid": self.hardware_id})
205
+
206
+ def request_hardware_reset(self, license_key: str, reason: str = "") -> PwfResponse:
207
+ if not license_key or not isinstance(license_key, str):
208
+ raise TypeError("license_key is required.")
209
+ return self.post_plain("/api/auth/request-hwid-reset.php", {
210
+ "license_key": license_key,
211
+ "hwid": self.hardware_id,
212
+ "reason": reason,
213
+ })
214
+
215
+ def register_account(self, username: str, password: str, license_key: str) -> PwfResponse:
216
+ return self.post_plain("/api/auth/account-register.php", {
217
+ "username": username,
218
+ "password": password,
219
+ "license_key": license_key,
220
+ "hwid": self.hardware_id,
221
+ })
222
+
223
+ def account_login(self, username: str, password: str) -> PwfResponse:
224
+ res = self.post_plain("/api/auth/account-login.php", {
225
+ "username": username,
226
+ "password": password,
227
+ "hwid": self.hardware_id,
228
+ })
229
+ if res.success:
230
+ self._session_id = res.data.get("session_id")
231
+ self._license_key = (res.license.license_key if res.license else None)
232
+ self._failures = 0
233
+ return res
234
+
235
+ def change_account_password(self, username: str, old_password: str,
236
+ new_password: str) -> PwfResponse:
237
+ return self.post_plain("/api/auth/change-password.php", {
238
+ "username": username,
239
+ "old_password": old_password,
240
+ "new_password": new_password,
241
+ })
242
+
243
+ # ── transports ───────────────────────────────────────────────────────────
244
+
245
+ def post_envelope(self, path: str, body: Any = None) -> PwfResponse:
246
+ """POST an encrypted envelope and decrypt the reply."""
247
+ return self._send(path, method="POST",
248
+ headers={"Content-Type": "application/json",
249
+ "X-App-Secret": self.app_secret},
250
+ data=self._crypto.encrypt(json.dumps(body or {})).encode("utf-8"))
251
+
252
+ def get_envelope(self, path: str, bearer_license_key: str | None = None) -> PwfResponse:
253
+ headers = {"X-App-Secret": self.app_secret}
254
+ if bearer_license_key:
255
+ headers["Authorization"] = "Bearer " + bearer_license_key
256
+ return self._send(path, method="GET", headers=headers, data=None)
257
+
258
+ def post_plain(self, path: str, body: Any = None) -> PwfResponse:
259
+ """POST unencrypted JSON, for the endpoints that do not use the envelope."""
260
+ return self._send(path, method="POST",
261
+ headers={"Content-Type": "application/json",
262
+ "X-App-Secret": self.app_secret},
263
+ data=json.dumps(body or {}).encode("utf-8"))
264
+
265
+ def _send(self, path: str, *, method: str, headers: dict, data: bytes | None) -> PwfResponse:
266
+ req = urllib.request.Request(self.base_url + path, data=data, method=method)
267
+ for k, v in headers.items():
268
+ req.add_header(k, v)
269
+
270
+ try:
271
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
272
+ status = resp.status
273
+ raw = resp.read().decode("utf-8", errors="replace")
274
+ except urllib.error.HTTPError as e:
275
+ # A 4xx still carries a JSON error envelope worth reading — Cloudflare
276
+ # eats 5xx bodies, which is why the server uses 4xx for API errors.
277
+ status = e.code
278
+ raw = e.read().decode("utf-8", errors="replace")
279
+ except Exception as cause:
280
+ raise PwfHttpError(f"Could not reach the license server: {cause}") from cause
281
+
282
+ if CryptoEnvelope.looks_like_envelope(raw):
283
+ raw = self._crypto.decrypt(raw)
284
+
285
+ try:
286
+ return PwfResponse.parse(raw)
287
+ except PwfError:
288
+ # Not JSON: an HTML error page from a proxy is the usual cause, so hand
289
+ # back a snippet instead of "unexpected token <".
290
+ raise PwfHttpError(
291
+ f"The license server returned a non-JSON body (HTTP {status}).",
292
+ status=status, snippet=raw[:200])
pwfauth/envelope.py ADDED
@@ -0,0 +1,111 @@
1
+ """The AES-256-CBC + HMAC-SHA256 envelope the SDK-grade endpoints speak.
2
+
3
+ Wire format: ``{"p": base64(IV || ciphertext), "t": unix, "s": hmac_hex(p + t)}``.
4
+ Both keys derive from the app secret, so there is no separate key exchange.
5
+
6
+ This mirrors the server's PayloadCrypto, the .NET client's CryptoEnvelope and the
7
+ Node client's envelope.js **byte for byte** -- all four must stay in lockstep, so
8
+ treat any change here as a protocol change, not an implementation detail.
9
+
10
+ Python has no AES in its standard library, which is why ``cryptography`` is the one
11
+ dependency this package takes. Everything else is stdlib.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import hashlib
18
+ import hmac
19
+ import json
20
+ import os
21
+ import time
22
+
23
+ from cryptography.hazmat.primitives import padding
24
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
25
+
26
+ from .errors import PwfCryptoError
27
+
28
+
29
+ class CryptoEnvelope:
30
+ """Builds and verifies the ``{p,t,s}`` envelope."""
31
+
32
+ def __init__(self, app_secret: str, max_drift_seconds: int = 300) -> None:
33
+ """
34
+ :param app_secret: The 64-character hex secret from your dashboard.
35
+ :param max_drift_seconds: How far a reply's timestamp may drift before it is
36
+ rejected as a replay. Must match the server (300).
37
+ """
38
+ if not app_secret:
39
+ raise TypeError("App secret is required.")
40
+ self._enc_key = hashlib.sha256(("enc:" + app_secret).encode("utf-8")).digest()
41
+ self._mac_key = hashlib.sha256(("mac:" + app_secret).encode("utf-8")).digest()
42
+ self._max_drift = max_drift_seconds
43
+
44
+ def encrypt(self, plain_json: str) -> str:
45
+ """Encrypt a request body into the wire envelope."""
46
+ if not isinstance(plain_json, str):
47
+ raise TypeError("plain_json must be a string.")
48
+
49
+ iv = os.urandom(16)
50
+ padder = padding.PKCS7(algorithms.AES.block_size).padder()
51
+ padded = padder.update(plain_json.encode("utf-8")) + padder.finalize()
52
+
53
+ encryptor = Cipher(algorithms.AES(self._enc_key), modes.CBC(iv)).encryptor()
54
+ ciphertext = encryptor.update(padded) + encryptor.finalize()
55
+
56
+ p = base64.b64encode(iv + ciphertext).decode("ascii")
57
+ t = int(time.time())
58
+ return json.dumps({"p": p, "t": t, "s": self._hmac_hex(p + str(t))},
59
+ separators=(",", ":"))
60
+
61
+ def decrypt(self, envelope_json: str) -> str:
62
+ """Verify and decrypt a wire envelope back into plain JSON.
63
+
64
+ :raises PwfCryptoError: signature failed, timestamp drifted, or malformed.
65
+ """
66
+ try:
67
+ obj = json.loads(envelope_json)
68
+ p, t, s = obj["p"], obj["t"], obj["s"]
69
+ if not isinstance(p, str) or not isinstance(s, str) or not isinstance(t, int):
70
+ raise ValueError("missing fields")
71
+ except Exception as cause:
72
+ raise PwfCryptoError("Invalid envelope format.") from cause
73
+
74
+ if not hmac.compare_digest(self._hmac_hex(p + str(t)), s):
75
+ raise PwfCryptoError(
76
+ "HMAC verification failed — wrong app secret, or the payload was tampered with.")
77
+
78
+ if abs(int(time.time()) - t) > self._max_drift:
79
+ raise PwfCryptoError(
80
+ "Envelope timestamp is outside the accepted window — "
81
+ "check this machine's system clock.")
82
+
83
+ try:
84
+ combined = base64.b64decode(p, validate=True)
85
+ except Exception as cause:
86
+ raise PwfCryptoError("Malformed ciphertext.") from cause
87
+ if len(combined) <= 16:
88
+ raise PwfCryptoError("Malformed ciphertext.")
89
+
90
+ try:
91
+ decryptor = Cipher(algorithms.AES(self._enc_key), modes.CBC(combined[:16])).decryptor()
92
+ padded = decryptor.update(combined[16:]) + decryptor.finalize()
93
+ unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
94
+ return (unpadder.update(padded) + unpadder.finalize()).decode("utf-8")
95
+ except Exception as cause:
96
+ raise PwfCryptoError(
97
+ "Decryption failed — wrong app secret or corrupted payload.") from cause
98
+
99
+ @staticmethod
100
+ def looks_like_envelope(body: str) -> bool:
101
+ """True when the document looks like a ``{p,t,s}`` envelope."""
102
+ if not body:
103
+ return False
104
+ try:
105
+ o = json.loads(body)
106
+ except Exception:
107
+ return False
108
+ return isinstance(o, dict) and "p" in o and "t" in o and "s" in o
109
+
110
+ def _hmac_hex(self, message: str) -> str:
111
+ return hmac.new(self._mac_key, message.encode("utf-8"), hashlib.sha256).hexdigest()
pwfauth/errors.py ADDED
@@ -0,0 +1,75 @@
1
+ """Exception types and the server's error-code vocabulary."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class PwfError(Exception):
7
+ """Base class for every error this library raises."""
8
+
9
+
10
+ class PwfCryptoError(PwfError):
11
+ """The encrypted envelope could not be built, verified, or decrypted.
12
+
13
+ Usually one of: the wrong app secret, a tampered payload, or a machine clock
14
+ far enough out of step that the timestamp fell outside the replay window.
15
+ """
16
+
17
+
18
+ class PwfHttpError(PwfError):
19
+ """The server answered with a non-success status, or a body that was not JSON.
20
+
21
+ ``snippet`` carries the first 200 characters of what actually came back, which
22
+ is what turns "unexpected token <" into "your proxy returned an HTML error page".
23
+ """
24
+
25
+ def __init__(self, message: str, status: int = 0, snippet: str = "") -> None:
26
+ super().__init__(message)
27
+ self.status = status
28
+ self.snippet = snippet
29
+
30
+
31
+ class PwfErrorCodes:
32
+ """Error codes the API returns in ``error_code``.
33
+
34
+ Grouped by what the client should DO, not by what went wrong. Anything in
35
+ :data:`SESSION_ENDING` means the licence is no longer valid on this machine and
36
+ the app must stop; everything else is worth retrying or reporting.
37
+ """
38
+
39
+ # Licence state — the session is over.
40
+ KEY_BANNED = "KEY_BANNED"
41
+ KEY_PAUSED = "KEY_PAUSED"
42
+ KEY_EXPIRED = "KEY_EXPIRED"
43
+ KEY_REVOKED = "KEY_REVOKED"
44
+ KEY_NOT_FOUND = "KEY_NOT_FOUND"
45
+ HWID_MISMATCH = "HWID_MISMATCH"
46
+ HWID_RESET = "HWID_RESET"
47
+ SESSION_EXPIRED = "SESSION_EXPIRED"
48
+ SESSION_NOT_FOUND = "SESSION_NOT_FOUND"
49
+ MAINTENANCE = "MAINTENANCE"
50
+ APP_DISABLED = "APP_DISABLED"
51
+
52
+ # Client-side, raised by this library rather than the server.
53
+ NETWORK_LOST = "NETWORK_LOST"
54
+
55
+ # Transient / informational — keep going.
56
+ RATE_LIMITED = "RATE_LIMITED"
57
+ SERVER_ERROR = "SERVER_ERROR"
58
+ INVALID_REQUEST = "INVALID_REQUEST"
59
+
60
+ SESSION_ENDING = frozenset({
61
+ KEY_BANNED, KEY_PAUSED, KEY_EXPIRED, KEY_REVOKED, KEY_NOT_FOUND,
62
+ HWID_MISMATCH, HWID_RESET, SESSION_EXPIRED, SESSION_NOT_FOUND,
63
+ MAINTENANCE, APP_DISABLED, NETWORK_LOST,
64
+ })
65
+
66
+
67
+ def ends_session(error_code: str | None) -> bool:
68
+ """True when this code means the licence is finished on this machine.
69
+
70
+ Use it to decide whether to sign the user out. An unknown code returns False on
71
+ purpose: a code this library has not heard of is far more likely to be a new
72
+ transient condition than a new way of being banned, and locking users out of a
73
+ paid app on a guess is the worse failure.
74
+ """
75
+ return bool(error_code) and error_code in PwfErrorCodes.SESSION_ENDING
pwfauth/hardware_id.py ADDED
@@ -0,0 +1,94 @@
1
+ """A stable per-machine identifier.
2
+
3
+ Mirrors the .NET and Node clients exactly, so the same machine produces the same
4
+ HWID under any of the three SDKs. That parity is not cosmetic: a licence counts
5
+ distinct hardware IDs against its device limit, so a mismatch would burn a second
6
+ seat the moment a customer switched SDKs.
7
+
8
+ Windows reads the cryptography MachineGuid through ``reg.exe`` -- deliberately not
9
+ ``wmic``, which Windows 11 24H2 removed. Linux reads ``/etc/machine-id``, macOS the
10
+ IOPlatformUUID. Anything that fails falls back to the host name.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import platform
17
+ import re
18
+ import socket
19
+ import subprocess
20
+
21
+ _cached: str | None = None
22
+
23
+
24
+ def get_hardware_id() -> str:
25
+ """Return this machine's hardware ID, computing it once per process."""
26
+ global _cached
27
+ if _cached is not None:
28
+ return _cached
29
+
30
+ value = ""
31
+ try:
32
+ value = _resolve()
33
+ except Exception:
34
+ # Any probe failure falls through to the host-name fallback.
35
+ pass
36
+
37
+ _cached = (value or _safe_hostname()).strip()
38
+ return _cached
39
+
40
+
41
+ def reset_hardware_id_cache() -> None:
42
+ """Clear the cache. Only useful in tests."""
43
+ global _cached
44
+ _cached = None
45
+
46
+
47
+ def _resolve() -> str:
48
+ system = platform.system()
49
+ if system == "Windows":
50
+ return _windows_machine_guid()
51
+ if system == "Darwin":
52
+ return _mac_platform_uuid()
53
+ for path in ("/etc/machine-id", "/var/lib/dbus/machine-id"):
54
+ if os.path.exists(path):
55
+ with open(path, "r", encoding="utf-8") as fh:
56
+ return fh.read().strip()
57
+ return ""
58
+
59
+
60
+ def _windows_machine_guid() -> str:
61
+ out = _run(["reg", "query", r"HKLM\SOFTWARE\Microsoft\Cryptography", "/v", "MachineGuid"])
62
+ # " MachineGuid REG_SZ 2f5a1c8e-..."
63
+ marker = out.upper().find("REG_SZ")
64
+ return "" if marker < 0 else out[marker + len("REG_SZ"):].strip()
65
+
66
+
67
+ def _mac_platform_uuid() -> str:
68
+ out = _run(["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"])
69
+ m = re.search(r'"IOPlatformUUID"\s*=\s*"([^"]+)"', out)
70
+ return m.group(1) if m else ""
71
+
72
+
73
+ def _run(args: list[str]) -> str:
74
+ try:
75
+ # CREATE_NO_WINDOW keeps a console from flashing over a GUI app, the same
76
+ # concern as windowsHide in the Node client.
77
+ flags = 0x08000000 if platform.system() == "Windows" else 0
78
+ out = subprocess.run(
79
+ args,
80
+ capture_output=True,
81
+ timeout=5,
82
+ creationflags=flags,
83
+ check=False,
84
+ )
85
+ return out.stdout.decode("utf-8", errors="replace")
86
+ except Exception:
87
+ return ""
88
+
89
+
90
+ def _safe_hostname() -> str:
91
+ try:
92
+ return socket.gethostname() or "unknown-host"
93
+ except Exception:
94
+ return "unknown-host"
pwfauth/response.py ADDED
@@ -0,0 +1,123 @@
1
+ """The parsed reply from the licence server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timezone
8
+
9
+ from .errors import PwfError
10
+
11
+
12
+ def _to_datetime(value) -> datetime | None:
13
+ """Parse the server's timestamps, returning None for anything unusable.
14
+
15
+ Lifetime keys send ``expires_at: null``. Returning None rather than raising is
16
+ deliberate -- treating that null as a date is the single most common integration
17
+ bug against this API.
18
+ """
19
+ if not value or not isinstance(value, str):
20
+ return None
21
+ text = value.strip().replace(" ", "T")
22
+ if text.endswith("Z"):
23
+ text = text[:-1] + "+00:00"
24
+ try:
25
+ dt = datetime.fromisoformat(text)
26
+ except ValueError:
27
+ return None
28
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class License:
33
+ """The licence block from a login reply."""
34
+
35
+ license_key: str | None = None
36
+ key_type: str | None = None
37
+ duration: int | None = None
38
+ hwid: str | None = None
39
+ activated_at: datetime | None = None
40
+ expires_at: datetime | None = None
41
+ days_remaining: int | None = None
42
+ status: str | None = None
43
+
44
+ @property
45
+ def is_lifetime(self) -> bool:
46
+ """True when the key never expires.
47
+
48
+ The library answers this so callers never have to special-case a null date.
49
+ """
50
+ return self.expires_at is None
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class PwfResponse:
55
+ """A licence-server reply, with the raw body kept reachable as fields grow."""
56
+
57
+ data: dict = field(default_factory=dict)
58
+ raw_json: str = ""
59
+
60
+ @staticmethod
61
+ def parse(body: str) -> "PwfResponse":
62
+ try:
63
+ data = json.loads(body)
64
+ except Exception as cause:
65
+ raise PwfError(
66
+ "The license server returned a body that is not valid JSON.") from cause
67
+ if not isinstance(data, dict):
68
+ raise PwfError("The license server returned a body that is not a JSON object.")
69
+ return PwfResponse(data=data, raw_json=body)
70
+
71
+ @property
72
+ def success(self) -> bool:
73
+ """True when the API reported success."""
74
+ return self.data.get("success") is True
75
+
76
+ @property
77
+ def error_code(self) -> str | None:
78
+ """Machine-readable failure reason. Compare against :class:`PwfErrorCodes`."""
79
+ code = self.data.get("error_code")
80
+ return code if isinstance(code, str) else None
81
+
82
+ @property
83
+ def message(self) -> str | None:
84
+ """Human-readable message, safe to show the end user."""
85
+ msg = self.data.get("message")
86
+ return msg if isinstance(msg, str) else None
87
+
88
+ @property
89
+ def license(self) -> License | None:
90
+ """The licence block, or None when the reply carries no ``user`` object."""
91
+ u = self.data.get("user")
92
+ if not isinstance(u, dict):
93
+ return None
94
+ days = u.get("days_remaining")
95
+ return License(
96
+ license_key=u.get("license_key"),
97
+ key_type=u.get("key_type"),
98
+ duration=u.get("duration"),
99
+ hwid=u.get("hwid"),
100
+ activated_at=_to_datetime(u.get("activated_at")),
101
+ expires_at=_to_datetime(u.get("expires_at")),
102
+ days_remaining=days if isinstance(days, int) else None,
103
+ status=u.get("status"),
104
+ )
105
+
106
+ @property
107
+ def features(self) -> dict:
108
+ """Per-key feature flags. Empty dict when absent."""
109
+ f = self.data.get("features")
110
+ return f if isinstance(f, dict) else {}
111
+
112
+ def get_boolean(self, key: str, default: bool = False) -> bool:
113
+ """Read a feature flag, tolerating the 1/0/"true" forms the panel can store."""
114
+ if key not in self.features:
115
+ return default
116
+ v = self.features[key]
117
+ if isinstance(v, bool):
118
+ return v
119
+ if isinstance(v, (int, float)):
120
+ return v != 0
121
+ if isinstance(v, str):
122
+ return v.strip().lower() in ("1", "true", "yes", "on")
123
+ return default
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: pwfauth
3
+ Version: 1.0.0
4
+ Summary: Official Python client for PWF Auth (pwfauth.com): license-key activation, hardware-ID binding, encrypted sessions with a server-driven kill switch, free trials, remote texts/slides, and OTA update checks.
5
+ Project-URL: Homepage, https://pwfauth.com
6
+ Project-URL: Documentation, https://pwfauth.com/api.php
7
+ Project-URL: Repository, https://github.com/pwfauth/pwfauth-python
8
+ Project-URL: Issues, https://github.com/pwfauth/pwfauth-python/issues
9
+ Author: PWF Auth
10
+ License: MIT License
11
+
12
+ Copyright (c) 2026 PWF Auth
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: activation,auth,authentication,drm,hwid,license-key,licensing,pwfauth,sdk
33
+ Classifier: Development Status :: 5 - Production/Stable
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Operating System :: OS Independent
37
+ Classifier: Programming Language :: Python :: 3
38
+ Classifier: Programming Language :: Python :: 3.9
39
+ Classifier: Programming Language :: Python :: 3.10
40
+ Classifier: Programming Language :: Python :: 3.11
41
+ Classifier: Programming Language :: Python :: 3.12
42
+ Classifier: Programming Language :: Python :: 3.13
43
+ Classifier: Topic :: Security :: Cryptography
44
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
45
+ Requires-Python: >=3.9
46
+ Requires-Dist: cryptography>=3.4
47
+ Description-Content-Type: text/markdown
48
+
49
+ # pwfauth
50
+
51
+ Official Python client for [PWF Auth](https://pwfauth.com) — license keys, hardware-ID
52
+ binding, encrypted sessions with a server-driven kill switch, free trials, remote
53
+ texts/slides, and OTA update checks.
54
+
55
+ PWF Auth is **100% free** — every feature, unlimited, forever.
56
+
57
+ ```bash
58
+ pip install pwfauth
59
+ ```
60
+
61
+ Python 3.9+. One dependency (`cryptography`), because Python has no AES in its
62
+ standard library; HTTP uses `urllib` from the stdlib rather than pulling in `requests`.
63
+
64
+ ## Quick start
65
+
66
+ ```python
67
+ import os
68
+ import sys
69
+ from pwfauth import PwfClient
70
+
71
+ client = PwfClient(os.environ["PWFAUTH_SECRET"])
72
+
73
+ def on_session_ended(error_code, message):
74
+ """Ban, pause, expiry, HWID reset, revoke, maintenance, or an unreachable
75
+ server. Actually stop here — logging alone leaves the licence unenforceable."""
76
+ print(f"Session ended ({error_code}): {message}", file=sys.stderr)
77
+ sys.exit(1)
78
+
79
+ client.on_session_ended = on_session_ended
80
+
81
+ login = client.login("XXXXX-XXXXX-XXXXX-XXXXX")
82
+ if not login.success:
83
+ print(login.message, file=sys.stderr) # safe to show the user
84
+ sys.exit(1)
85
+
86
+ client.start_heartbeat() # keeps the session alive AND enforces the kill switch
87
+ ```
88
+
89
+ `start_heartbeat()` is what turns a one-time check into a live session. Revoke the key
90
+ in your dashboard and `on_session_ended` fires on the user's machine within seconds —
91
+ it does not wait for the next launch.
92
+
93
+ ## Reading the licence
94
+
95
+ ```python
96
+ lic = login.license
97
+ if lic.is_lifetime:
98
+ print("Lifetime licence")
99
+ else:
100
+ print(f"Expires {lic.expires_at:%Y-%m-%d} — {lic.days_remaining} day(s) left")
101
+ ```
102
+
103
+ `expires_at` is `None` for lifetime keys — the server sends `expires_at: null`, and
104
+ treating that as a date is the most common integration bug against this API. The
105
+ `is_lifetime` flag exists so you never have to.
106
+
107
+ ## Feature flags
108
+
109
+ ```python
110
+ if login.get_boolean("pro_export"):
111
+ enable_export()
112
+ ```
113
+
114
+ Handles the `1` / `0` / `"true"` forms the dashboard can store.
115
+
116
+ ## Checking a key without using a device seat
117
+
118
+ ```python
119
+ status = client.check_key(key) # no session opened, no seat consumed
120
+ ```
121
+
122
+ ## Hardware ID
123
+
124
+ ```python
125
+ from pwfauth import get_hardware_id
126
+ print(get_hardware_id())
127
+ ```
128
+
129
+ Derived identically to the [.NET](https://www.nuget.org/packages/PWFAuth) and
130
+ [Node](https://www.npmjs.com/package/pwfauth) clients, so **the same machine counts as
131
+ one device under any SDK** — switching languages does not burn a second seat against
132
+ the licence's device limit.
133
+
134
+ ## Everything else
135
+
136
+ | Method | What it does |
137
+ | --- | --- |
138
+ | `login(key)` / `logout()` | Open and close a session |
139
+ | `check_key(key)` | Read a key's state without a session |
140
+ | `heartbeat()` | One manual beat |
141
+ | `start_heartbeat()` / `stop_heartbeat()` | The background loop |
142
+ | `create_trial()` | Issue a free trial for this machine |
143
+ | `request_hardware_reset(key, reason)` | Ask an admin to unbind a device |
144
+ | `get_app_info()` / `get_texts()` / `get_slides()` | Remote app content |
145
+ | `check_update(version)` | OTA update check |
146
+ | `register_account()` / `account_login()` / `change_account_password()` | User accounts |
147
+ | `post_envelope()` / `get_envelope()` / `post_plain()` | Raw transports |
148
+
149
+ ## Error codes
150
+
151
+ ```python
152
+ from pwfauth import PwfErrorCodes, ends_session
153
+
154
+ if ends_session(res.error_code):
155
+ sign_out()
156
+ ```
157
+
158
+ `ends_session()` returns `False` for a code it does not recognise, on purpose: an
159
+ unknown code is far more likely to be a new transient condition than a new way of
160
+ being banned, and locking a paying user out on a guess is the worse failure.
161
+
162
+ ## Links
163
+
164
+ - [Dashboard](https://pwfauth.com) · [API reference](https://pwfauth.com/api.php)
165
+ - [.NET client](https://www.nuget.org/packages/PWFAuth) · [Node client](https://www.npmjs.com/package/pwfauth) · [VS Code extension](https://marketplace.visualstudio.com/items?itemName=PWFAuth.pwfauth)
166
+
167
+ MIT
@@ -0,0 +1,10 @@
1
+ pwfauth/__init__.py,sha256=W61RzVUvNF5sKGpePmrTp5dmYZetfQ9tBTK5qomCT8k,1445
2
+ pwfauth/client.py,sha256=CZY-s-gs3FUN4TaGUHHWX8dWogHQMgtbPHXhuGx25ME,12491
3
+ pwfauth/envelope.py,sha256=S4LZyPyBA4uAWofwkniMtsgQZbY2HFovlq-hwGrm4QI,4654
4
+ pwfauth/errors.py,sha256=y4feYoNOisZIQN_7f_Bqr6XcZYVdu32tnwbB1zbEgo0,2668
5
+ pwfauth/hardware_id.py,sha256=zMcjwf-Q1Rf7iZrrnlSA9MQuFUMr2iDT4qRoEm8vVSk,2776
6
+ pwfauth/response.py,sha256=mQ7tvFIEH64_fyWmK3xwcrEkUXiKX5ot1Yhd6w62fdw,4072
7
+ pwfauth-1.0.0.dist-info/METADATA,sha256=iXs9PTCq_6lz0t4qtVCti2GYT11FL7cGX4bt5oYT3LQ,6437
8
+ pwfauth-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
9
+ pwfauth-1.0.0.dist-info/licenses/LICENSE,sha256=14ZkQljXYT8S_M1UPsaMP2wJbbk__tbhlBE1CamDSi4,1065
10
+ pwfauth-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PWF Auth
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.