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 +49 -0
- pwfauth/client.py +292 -0
- pwfauth/envelope.py +111 -0
- pwfauth/errors.py +75 -0
- pwfauth/hardware_id.py +94 -0
- pwfauth/response.py +123 -0
- pwfauth-1.0.0.dist-info/METADATA +167 -0
- pwfauth-1.0.0.dist-info/RECORD +10 -0
- pwfauth-1.0.0.dist-info/WHEEL +4 -0
- pwfauth-1.0.0.dist-info/licenses/LICENSE +21 -0
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,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.
|