Skip to content

Commit 334013f

Browse files
committed
V2
1 parent fb99090 commit 334013f

5 files changed

Lines changed: 251 additions & 93 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ client = AuthForgeClient(
9090

9191
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.
9292

93-
3. **Crypto**Every response is signed with a key derived from `SHA256(appSecret + nonce)`. The signing key changes on every call, making replay and MITM attacks impractical.
93+
3. **Crypto**The `/validate` response is signed with a key derived from `SHA256(appSecret + nonce)`. That response carries a per-session `sigKey` (32-byte random hex) embedded in the signed session token. Every `/heartbeat` response is then signed with a key derived from `SHA256(sigKey + nonce)`. This keeps `appSecret` out of the heartbeat path while still rotating the signing key on every nonce, making replay and MITM attacks impractical.
9494

9595
## Hardware ID
9696

authforge.py

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"app_disabled",
3232
"session_expired",
3333
"bad_request",
34+
"server_error",
3435
"checksum_required",
3536
"checksum_mismatch",
3637
}
@@ -77,6 +78,7 @@ def __init__(
7778
self._raw_payload_b64: Optional[str] = None
7879
self._signature: Optional[str] = None
7980
self._derived_key: Optional[bytes] = None
81+
self._sig_key: Optional[str] = None
8082
self._session_data: Optional[Dict[str, Any]] = None
8183
self._app_variables: Optional[Dict[str, Any]] = None
8284
self._license_variables: Optional[Dict[str, Any]] = None
@@ -134,7 +136,12 @@ def _server_heartbeat(self) -> None:
134136
}
135137
response_obj = self._post_json("/auth/heartbeat", body)
136138
expected_nonce = str(body.get("nonce", "")).strip()
137-
self._apply_signed_response(response_obj, expected_nonce=expected_nonce, license_key=None)
139+
self._apply_signed_response(
140+
response_obj,
141+
expected_nonce=expected_nonce,
142+
license_key=None,
143+
context="heartbeat",
144+
)
138145

139146
def _local_heartbeat(self) -> None:
140147
with self._lock:
@@ -164,13 +171,19 @@ def _validate_and_store(self, license_key: str) -> None:
164171
}
165172
response_obj = self._post_json("/auth/validate", body)
166173
expected_nonce = str(body.get("nonce", "")).strip()
167-
self._apply_signed_response(response_obj, expected_nonce=expected_nonce, license_key=license_key)
174+
self._apply_signed_response(
175+
response_obj,
176+
expected_nonce=expected_nonce,
177+
license_key=license_key,
178+
context="validate",
179+
)
168180

169181
def _apply_signed_response(
170182
self,
171183
response_obj: Dict[str, Any],
172184
expected_nonce: str,
173185
license_key: Optional[str],
186+
context: str,
174187
) -> None:
175188
status = response_obj.get("status")
176189
if not self._is_success_status(status):
@@ -185,13 +198,22 @@ def _apply_signed_response(
185198
if received_nonce != expected_nonce:
186199
raise ValueError("nonce_mismatch")
187200

188-
derived_key = self._derive_key(expected_nonce)
201+
if context == "validate":
202+
derived_key = self._derive_validate_key(expected_nonce)
203+
elif context == "heartbeat":
204+
derived_key = self._derive_heartbeat_key(expected_nonce)
205+
else:
206+
raise ValueError(f"unknown_signing_context:{context}")
189207
self._verify_signature(raw_payload_b64, derived_key, signature)
190208

191209
session_token = str(payload_json.get("sessionToken", "")).strip()
192210
if not session_token:
193211
raise ValueError("missing_sessionToken")
194212

213+
new_sig_key = self._extract_sig_key_from_session_token(session_token)
214+
if not new_sig_key:
215+
raise ValueError("missing_sigKey")
216+
195217
expires_from_token = self._extract_expires_in_from_session_token(session_token)
196218
expires_from_payload = payload_json.get("expiresIn")
197219

@@ -210,6 +232,7 @@ def _apply_signed_response(
210232
self._raw_payload_b64 = raw_payload_b64
211233
self._signature = signature
212234
self._derived_key = derived_key
235+
self._sig_key = new_sig_key
213236
self._session_data = dict(payload_json)
214237
self._app_variables = self._extract_optional_map(payload_json.get("appVariables"))
215238
self._license_variables = self._extract_optional_map(payload_json.get("licenseVariables"))
@@ -235,24 +258,21 @@ def _post_json(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
235258
try:
236259
with urllib.request.urlopen(request, timeout=self.request_timeout) as response:
237260
raw_response = response.read().decode("utf-8")
261+
status_code = int(getattr(response, "status", 200))
238262
obj = self._parse_response_object(raw_response)
239263
data.clear()
240264
data.update(body)
241265
break
242266
except urllib.error.HTTPError as exc:
243-
detail = ""
244-
parsed: Optional[Dict[str, Any]] = None
267+
status_code = int(exc.code)
245268
try:
246269
detail = exc.read().decode("utf-8")
247-
parsed = self._parse_response_object(detail)
270+
obj = self._parse_response_object(detail)
248271
except Exception:
249-
detail = str(exc)
250-
if parsed is not None:
251-
obj = parsed
252-
data.clear()
253-
data.update(body)
254-
break
255-
raise RuntimeError(f"http_error_{exc.code}: {detail}") from exc
272+
raise RuntimeError(f"http_error_{status_code}") from exc
273+
data.clear()
274+
data.update(body)
275+
break
256276
except (urllib.error.URLError, socket.timeout, TimeoutError) as exc:
257277
if network_attempt == 0:
258278
network_attempt += 1
@@ -261,7 +281,11 @@ def _post_json(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
261281
self._fail("network_error", exc)
262282
raise RuntimeError(f"url_error: {exc}") from exc
263283

264-
if self._extract_server_error(obj) == "rate_limited" and rate_attempt < len(RATE_LIMIT_RETRY_DELAYS):
284+
is_rate_limited = (
285+
status_code == 429
286+
or self._extract_server_error(obj) == "rate_limited"
287+
)
288+
if is_rate_limited and rate_attempt < len(RATE_LIMIT_RETRY_DELAYS):
265289
time.sleep(RATE_LIMIT_RETRY_DELAYS[rate_attempt])
266290
rate_attempt += 1
267291
continue
@@ -342,31 +366,54 @@ def _decode_base64_any(self, value: str) -> bytes:
342366
return base64.urlsafe_b64decode(padded)
343367

344368
def _extract_expires_in_from_session_token(self, session_token: str) -> Optional[int]:
369+
payload = self._decode_session_token_body(session_token)
370+
if payload is None:
371+
return None
372+
value = payload.get("expiresIn")
373+
if value is None:
374+
return None
375+
return int(value)
376+
377+
def _extract_sig_key_from_session_token(self, session_token: str) -> Optional[str]:
378+
payload = self._decode_session_token_body(session_token)
379+
if payload is None:
380+
return None
381+
value = payload.get("sigKey")
382+
if not isinstance(value, str) or not value:
383+
return None
384+
return value
385+
386+
def _decode_session_token_body(self, session_token: str) -> Optional[Dict[str, Any]]:
345387
parts = session_token.split(".")
346388
if len(parts) < 2:
347389
return None
348-
payload_part = parts[0]
349-
padded = self._add_base64_padding(payload_part)
390+
padded = self._add_base64_padding(parts[0])
350391
try:
351392
decoded = base64.urlsafe_b64decode(padded)
352393
payload = json.loads(decoded.decode("utf-8"))
353394
except Exception:
354395
return None
355-
value = payload.get("expiresIn")
356-
if value is None:
396+
if not isinstance(payload, dict):
357397
return None
358-
return int(value)
398+
return payload
359399

360400
def _add_base64_padding(self, text: str) -> str:
361401
remainder = len(text) % 4
362402
if remainder == 0:
363403
return text
364404
return text + ("=" * (4 - remainder))
365405

366-
def _derive_key(self, nonce: str) -> bytes:
406+
def _derive_validate_key(self, nonce: str) -> bytes:
367407
seed = f"{self.app_secret}{nonce}".encode("utf-8")
368408
return hashlib.sha256(seed).digest()
369409

410+
def _derive_heartbeat_key(self, nonce: str) -> bytes:
411+
sig_key = self._sig_key
412+
if not sig_key:
413+
raise RuntimeError("missing_sig_key")
414+
seed = f"{sig_key}{nonce}".encode("utf-8")
415+
return hashlib.sha256(seed).digest()
416+
370417
def _verify_signature(self, raw_payload_b64: str, derived_key: bytes, signature: str) -> None:
371418
expected = hmac.new(
372419
derived_key,
@@ -430,6 +477,7 @@ def logout(self) -> None:
430477
self._raw_payload_b64 = None
431478
self._signature = None
432479
self._derived_key = None
480+
self._sig_key = None
433481
self._session_data = None
434482
self._app_variables = None
435483
self._license_variables = None

generate_vectors.py

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,30 @@
66

77

88
APP_SECRET = "af_test_secret_2026_reference"
9+
SIG_KEY = "af_test_sig_key_2026_reference_0123456789abcdef"
910
NONCE = "0123456789abcdeffedcba9876543210"
1011
SESSION_SIGNING_SECRET = "authforge-dev-session-signing-secret-rotate-before-production"
12+
EXPIRES_IN = 1740433200
13+
TIMESTAMP = 1740429600
14+
APP_ID = "test-app"
15+
LICENSE_KEY = "test-key"
16+
HWID = "testhwid"
1117

1218

1319
def _b64url_no_pad(data: bytes) -> str:
1420
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
1521

1622

17-
def _build_realistic_session_token() -> str:
23+
def _build_session_token() -> str:
1824
body = {
19-
"appId": "test-app",
20-
"licenseKey": "test-key",
21-
"hwid": "testhwid",
22-
"appSecret": APP_SECRET,
23-
"expiresIn": 1740433200,
25+
"appId": APP_ID,
26+
"licenseKey": LICENSE_KEY,
27+
"hwid": HWID,
28+
"sigKey": SIG_KEY,
29+
"expiresIn": EXPIRES_IN,
2430
}
2531
body_json = json.dumps(body, separators=(",", ":")).encode("utf-8")
2632
body_b64 = _b64url_no_pad(body_json)
27-
2833
digest = hmac.new(
2934
SESSION_SIGNING_SECRET.encode("utf-8"),
3035
body_b64.encode("utf-8"),
@@ -36,39 +41,62 @@ def _build_realistic_session_token() -> str:
3641

3742
def _build_payload_b64() -> str:
3843
payload_obj = {
39-
"sessionToken": _build_realistic_session_token(),
40-
"timestamp": 1740429600,
41-
"expiresIn": 1740433200,
44+
"sessionToken": _build_session_token(),
45+
"timestamp": TIMESTAMP,
46+
"expiresIn": EXPIRES_IN,
4247
"nonce": NONCE,
4348
}
4449
payload_json = json.dumps(payload_obj, separators=(",", ":")).encode("utf-8")
4550
return base64.b64encode(payload_json).decode("ascii")
4651

4752

48-
PAYLOAD = _build_payload_b64()
53+
def _hmac_hex(key: bytes, message: str) -> str:
54+
return hmac.new(key, message.encode("utf-8"), hashlib.sha256).hexdigest()
55+
56+
57+
def _sha256_hex(value: str) -> str:
58+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
4959

5060

5161
def main() -> None:
52-
derived_key_bytes = hashlib.sha256(f"{APP_SECRET}{NONCE}".encode("utf-8")).digest()
53-
signature_hex = hmac.new(
54-
derived_key_bytes,
55-
PAYLOAD.encode("utf-8"),
56-
hashlib.sha256,
57-
).hexdigest()
62+
payload = _build_payload_b64()
63+
64+
validate_key = hashlib.sha256(f"{APP_SECRET}{NONCE}".encode("utf-8")).digest()
65+
validate_sig = _hmac_hex(validate_key, payload)
66+
67+
heartbeat_key = hashlib.sha256(f"{SIG_KEY}{NONCE}".encode("utf-8")).digest()
68+
heartbeat_sig = _hmac_hex(heartbeat_key, payload)
5869

5970
vectors = {
60-
"algorithm": {
61-
"keyDerivation": "SHA256(appSecret + nonce)",
62-
"signature": "HMAC-SHA256(raw_base64_payload_string, derivedKey)",
63-
},
64-
"inputs": {
65-
"appSecret": APP_SECRET,
66-
"nonce": NONCE,
67-
"payload": PAYLOAD,
71+
"validate": {
72+
"algorithm": {
73+
"keyDerivation": "SHA256(appSecret + nonce)",
74+
"signature": "HMAC-SHA256(raw_base64_payload_string, derivedKey)",
75+
},
76+
"inputs": {
77+
"appSecret": APP_SECRET,
78+
"nonce": NONCE,
79+
"payload": payload,
80+
},
81+
"outputs": {
82+
"derivedKeyHex": validate_key.hex(),
83+
"signatureHex": validate_sig,
84+
},
6885
},
69-
"outputs": {
70-
"derivedKeyHex": derived_key_bytes.hex(),
71-
"signatureHex": signature_hex,
86+
"heartbeat": {
87+
"algorithm": {
88+
"keyDerivation": "SHA256(sigKey + nonce)",
89+
"signature": "HMAC-SHA256(raw_base64_payload_string, derivedKey)",
90+
},
91+
"inputs": {
92+
"sigKey": SIG_KEY,
93+
"nonce": NONCE,
94+
"payload": payload,
95+
},
96+
"outputs": {
97+
"derivedKeyHex": heartbeat_key.hex(),
98+
"signatureHex": heartbeat_sig,
99+
},
72100
},
73101
}
74102

0 commit comments

Comments
 (0)