Skip to content

Commit 330ef1d

Browse files
committed
Add createActivationRequest so air-gapped customers can send a checksummed HWID file.
1 parent 8f0a561 commit 330ef1d

6 files changed

Lines changed: 328 additions & 5 deletions

File tree

‎AGENTS.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
AuthForge is a license key validation service. Your app activates by sending a license key + hardware ID to `POST /auth/validate`; the server checks revocation, expiry, HWID binding, and credits, then returns an Ed25519-signed session with a TTL. By default the app then runs through the **grace period**: it keeps running on the signed session with no network calls, and a background check fails when the session TTL expires. Optionally, enable **online check-ins** (`online_heartbeat=True`): periodic `POST /auth/heartbeat` calls for fast revocation and concurrent-use detection. If the license is revoked or expired, the check-in fails and you handle it (typically exit the app).
99

10-
There is also a **separate** mode for machines that can never reach the internet: **offline license files (`.authforge`)**. The operator mints a signed file in the AuthForge cloud; `login_from_file()` verifies it locally with the app public key and the machine HWID, with zero network calls. Do not ship the App Secret in those builds (`app_secret=None`). Only use it when the user explicitly asks for air-gapped / offline-file licensing. The default integration is always online `login()` + grace period.
10+
There is also a **separate** mode for machines that can never reach the internet: **offline license files (`.authforge`)**. The operator mints a signed file in the AuthForge cloud; `login_from_file()` verifies it locally with the app public key and the machine HWID, with zero network calls. Do not ship the App Secret in those builds (`app_secret=None`). Only use it when the user explicitly asks for air-gapped / offline-file licensing. The default integration is always online `login()` + grace period. To collect the HWID for a bound file, write an **activation request** (`.authforge-request`) with `create_activation_request` / `write_activation_request`. It is not a license, is not signed, and does not mint anything. Prefer it over printing the raw HWID.
1111

1212
## Billing model (so you can pick sensible intervals)
1313

@@ -97,6 +97,8 @@ The attribute `client.heartbeat_mode` still exists for back-compat and reflects
9797
| `get_offline_license()` | `dict \| None` | `jti`, `expires_at`, `hwid_policy`, … of the offline file in use |
9898
| `get_session_kind()` | `"online" \| "offline" \| None` | Kind of session the client holds; `None` when logged out |
9999
| `get_hwid()` | `str` | HWID this client sends; the customer reports it so the operator can mint a bound file |
100+
| `create_activation_request(**kwargs)` | `str` | Unsigned `.authforge-request` for this machine. No network, no secret, callable before `login()`. Hostname omitted unless `include_machine_name=True` |
101+
| `write_activation_request(path, **kwargs)` | `None` | Writes that file as UTF-8 |
100102
| `logout()` | `None` | Stops background checks and clears session state |
101103
| `is_authenticated()` | `bool` | Whether a session token is present and marked authenticated |
102104
| `get_session_data()` | `dict \| None` | Decoded signed payload map |

‎README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,8 @@ client = AuthForgeClient(
151151
on_failure=lambda reason, exc: print(reason, exc),
152152
)
153153

154-
# 1. The customer sends you this value so you can bind the file to their machine:
155-
print("HWID:", client.get_hwid())
154+
# 1. Write an activation request the operator drops into the mint dialog:
155+
client.write_activation_request("machine.authforge-request")
156156

157157
# 2. Later, authorize from the minted file (path or armored text). No network.
158158
if client.login_from_file("license.authforge"):
@@ -204,6 +204,8 @@ A desktop app running 6h/day with online check-ins at a 15-minute interval burns
204204
| `get_offline_license()` | `dict \| None` | Metadata of the offline file in use (`jti`, `expires_at`, `hwid_policy`, …) |
205205
| `get_session_kind()` | `"online" \| "offline" \| None` | Which kind of session the client holds (`None` when logged out) |
206206
| `get_hwid()` | `str` | The HWID this client sends (or `hwid_override`); customers share it to receive a bound file |
207+
| `create_activation_request(**kwargs)` | `str` | Unsigned `.authforge-request` for this machine. No network, no secret. Hostname omitted unless `include_machine_name=True` |
208+
| `write_activation_request(path, **kwargs)` | `None` | Writes that file as UTF-8 |
207209
| `logout()` | `None` | Stops background checks and clears all session/auth state |
208210
| `is_authenticated()` | `bool` | True when an active authenticated session exists |
209211
| `get_session_data()` | `dict \| None` | Full decoded payload map |

‎activation_request_vectors.json‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"format": "authforge-activation-request",
3+
"version": 1,
4+
"description": "Cross-SDK conformance vectors for activation requests (.authforge-request). Generators with the same inputs must match good_minimal and good_full byte-for-byte. Parser tolerance: CRLF, UTF-8 BOM, re-wrapping, email preamble. Checksum is decision-relevant.",
5+
"cases": [
6+
{
7+
"name": "good_minimal",
8+
"expect": "ok",
9+
"inputs": {
10+
"appId": "test-app",
11+
"hwid": "testhwid",
12+
"createdAt": "2026-09-11T12:00:00.000Z"
13+
},
14+
"file": "-----BEGIN AUTHFORGE ACTIVATION REQUEST-----\nVersion: 1\nApp-Id: test-app\nChecksum: c64d10a2fe4d54f7\n\neyJ2IjoxLCJ0eXAiOiJhdXRoZm9yZ2UtYWN0aXZhdGlvbi1yZXF1ZXN0IiwiYXBw\nSWQiOiJ0ZXN0LWFwcCIsImh3aWQiOiJ0ZXN0aHdpZCIsImNyZWF0ZWRBdCI6IjIw\nMjYtMDktMTFUMTI6MDA6MDAuMDAwWiJ9\n-----END AUTHFORGE ACTIVATION REQUEST-----\n"
15+
},
16+
{
17+
"name": "good_full",
18+
"expect": "ok",
19+
"inputs": {
20+
"appId": "test-app",
21+
"hwid": "testhwid",
22+
"createdAt": "2026-09-11T12:00:00.000Z",
23+
"machineName": "dev-box",
24+
"os": "Windows 11",
25+
"sdk": "python/1.2.1",
26+
"licenseKey": "TEST-KEY0-0000-0000"
27+
},
28+
"file": "-----BEGIN AUTHFORGE ACTIVATION REQUEST-----\nVersion: 1\nApp-Id: test-app\nChecksum: e496a94a849114c3\n\neyJ2IjoxLCJ0eXAiOiJhdXRoZm9yZ2UtYWN0aXZhdGlvbi1yZXF1ZXN0IiwiYXBw\nSWQiOiJ0ZXN0LWFwcCIsImh3aWQiOiJ0ZXN0aHdpZCIsImNyZWF0ZWRBdCI6IjIw\nMjYtMDktMTFUMTI6MDA6MDAuMDAwWiIsIm1hY2hpbmVOYW1lIjoiZGV2LWJveCIs\nIm9zIjoiV2luZG93cyAxMSIsInNkayI6InB5dGhvbi8xLjIuMSIsImxpY2Vuc2VL\nZXkiOiJURVNULUtFWTAtMDAwMC0wMDAwIn0=\n-----END AUTHFORGE ACTIVATION REQUEST-----\n"
29+
},
30+
{
31+
"name": "tolerate_crlf_bom_preamble",
32+
"expect": "ok",
33+
"file": "Please see attached.\r\n-----BEGIN AUTHFORGE ACTIVATION REQUEST-----\r\nVersion: 1\r\nApp-Id: test-app\r\nChecksum: c64d10a2fe4d54f7\r\n\r\neyJ2IjoxLCJ0eXAiOiJh\r\ndXRoZm9yZ2UtYWN0aXZh\r\ndGlvbi1yZXF1ZXN0Iiwi\r\nYXBwSWQiOiJ0ZXN0LWFw\r\ncCIsImh3aWQiOiJ0ZXN0\r\naHdpZCIsImNyZWF0ZWRB\r\ndCI6IjIwMjYtMDktMTFU\r\nMTI6MDA6MDAuMDAwWiJ9\r\n-----END AUTHFORGE ACTIVATION REQUEST-----\r\nThanks,\r\nPat\r\n"
34+
},
35+
{
36+
"name": "bad_checksum",
37+
"expect": "checksum_mismatch",
38+
"file": "-----BEGIN AUTHFORGE ACTIVATION REQUEST-----\nVersion: 1\nApp-Id: test-app\nChecksum: deadbeefdeadbeef\n\neyJ2IjoxLCJ0eXAiOiJhdXRoZm9yZ2UtYWN0aXZhdGlvbi1yZXF1ZXN0IiwiYXBw\nSWQiOiJ0ZXN0LWFwcCIsImh3aWQiOiJ0ZXN0aHdpZCIsImNyZWF0ZWRBdCI6IjIw\nMjYtMDktMTFUMTI6MDA6MDAuMDAwWiJ9\n-----END AUTHFORGE ACTIVATION REQUEST-----\n"
39+
},
40+
{
41+
"name": "truncated_body",
42+
"expect": "reject",
43+
"file": "-----BEGIN AUTHFORGE ACTIVATION REQUEST-----\nVersion: 1\nApp-Id: test-app\nChecksum: c64d10a2fe4d54f7\n\neyJ2IjoxLCJ0eXAiOiJhdXRoZm9yZ2UtYWN0aXZhdGlvbi1yZXF1ZXN0IiwiYXBw\nSWQiOiJ0ZXN0LWFwcCIsImh3aWQiOiJ0ZXN0aHdpZCIsImNyZWF0ZWRBdCI6IjIw\nMjYtMDktMTFUMTI6MDA6\n-----END AUTHFORGE ACTIVATION REQUEST-----\n"
44+
},
45+
{
46+
"name": "license_file",
47+
"expect": "is_license_file",
48+
"file": "-----BEGIN AUTHFORGE LICENSE-----\nVersion: 1\nApp-Id: test-app\n\nQUJD\n-----END AUTHFORGE LICENSE-----\n"
49+
}
50+
]
51+
}

‎authforge.py‎

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,21 @@ class ValidateLicenseFailure(TypedDict):
8080
"hwid_mismatch",
8181
)
8282

83+
# Activation requests (`.authforge-request`): unsigned transport for a HWID.
84+
# Distinct markers from BEGIN AUTHFORGE LICENSE. Not signed; the Checksum
85+
# header is the only integrity check. Keep _SDK_TAG in sync with pyproject.toml.
86+
_ACTIVATION_REQUEST_VERSION = 1
87+
_ACTIVATION_REQUEST_TYP = "authforge-activation-request"
88+
_BEGIN_ACTIVATION_REQUEST = "-----BEGIN AUTHFORGE ACTIVATION REQUEST-----"
89+
_END_ACTIVATION_REQUEST = "-----END AUTHFORGE ACTIVATION REQUEST-----"
90+
_SDK_TAG = "python/1.2.1"
91+
_ARMOR_LINE_WIDTH = 64
92+
_MAX_REQUEST_HWID = 256
93+
_MAX_REQUEST_MACHINE_NAME = 128
94+
_MAX_REQUEST_OS = 64
95+
_MAX_REQUEST_SDK = 64
96+
_MAX_REQUEST_LICENSE_KEY = 64
97+
8398

8499
class OfflineLicense(TypedDict):
85100
app_id: str
@@ -117,6 +132,131 @@ class ParsedLicenseFile(TypedDict):
117132
signature_base64: str
118133

119134

135+
def _clip_request_field(value: str, max_len: int) -> str:
136+
return value if len(value) <= max_len else value[:max_len]
137+
138+
139+
def _json_escape_request(value: str) -> str:
140+
out: List[str] = []
141+
for ch in value:
142+
code = ord(ch)
143+
if ch == "\\":
144+
out.append("\\\\")
145+
elif ch == '"':
146+
out.append('\\"')
147+
elif ch == "\b":
148+
out.append("\\b")
149+
elif ch == "\f":
150+
out.append("\\f")
151+
elif ch == "\n":
152+
out.append("\\n")
153+
elif ch == "\r":
154+
out.append("\\r")
155+
elif ch == "\t":
156+
out.append("\\t")
157+
elif code < 0x20:
158+
out.append(f"\\u00{code:02x}")
159+
else:
160+
out.append(ch)
161+
return '"' + "".join(out) + '"'
162+
163+
164+
def _wrap_armor_64(value: str) -> str:
165+
return "\n".join(value[i : i + _ARMOR_LINE_WIDTH] for i in range(0, len(value), _ARMOR_LINE_WIDTH))
166+
167+
168+
def _canonical_activation_request_json(
169+
*,
170+
app_id: str,
171+
hwid: str,
172+
created_at: str,
173+
machine_name: Optional[str] = None,
174+
os_name: Optional[str] = None,
175+
sdk: Optional[str] = None,
176+
license_key: Optional[str] = None,
177+
) -> str:
178+
parts = [
179+
f'"v":{_ACTIVATION_REQUEST_VERSION}',
180+
f'"typ":{_json_escape_request(_ACTIVATION_REQUEST_TYP)}',
181+
f'"appId":{_json_escape_request(app_id)}',
182+
f'"hwid":{_json_escape_request(_clip_request_field(hwid, _MAX_REQUEST_HWID))}',
183+
f'"createdAt":{_json_escape_request(created_at)}',
184+
]
185+
if machine_name:
186+
parts.append(
187+
f'"machineName":{_json_escape_request(_clip_request_field(machine_name, _MAX_REQUEST_MACHINE_NAME))}'
188+
)
189+
if os_name:
190+
parts.append(f'"os":{_json_escape_request(_clip_request_field(os_name, _MAX_REQUEST_OS))}')
191+
if sdk:
192+
parts.append(f'"sdk":{_json_escape_request(_clip_request_field(sdk, _MAX_REQUEST_SDK))}')
193+
if license_key:
194+
parts.append(
195+
f'"licenseKey":{_json_escape_request(_clip_request_field(license_key, _MAX_REQUEST_LICENSE_KEY))}'
196+
)
197+
return "{" + ",".join(parts) + "}"
198+
199+
200+
def _utc_iso_ms(value: Optional[datetime] = None) -> str:
201+
now = value or datetime.now(timezone.utc)
202+
if now.tzinfo is None:
203+
now = now.replace(tzinfo=timezone.utc)
204+
now = now.astimezone(timezone.utc)
205+
return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(now.microsecond / 1000):03d}Z"
206+
207+
208+
def _detect_os_label() -> str:
209+
system = platform.system()
210+
release = platform.release()
211+
if system == "Darwin":
212+
mac = platform.mac_ver()[0]
213+
label = f"macOS {mac or release}"
214+
elif system == "Windows":
215+
label = f"Windows {release}"
216+
elif system == "Linux":
217+
label = f"Linux {release}"
218+
else:
219+
label = f"{system} {release}".strip()
220+
return _clip_request_field(label, _MAX_REQUEST_OS)
221+
222+
223+
def format_activation_request(
224+
*,
225+
app_id: str,
226+
hwid: str,
227+
created_at: str,
228+
machine_name: Optional[str] = None,
229+
os: Optional[str] = None,
230+
sdk: Optional[str] = None,
231+
license_key: Optional[str] = None,
232+
) -> str:
233+
"""Armored ``.authforge-request`` text from explicit fields."""
234+
json_body = _canonical_activation_request_json(
235+
app_id=app_id,
236+
hwid=hwid,
237+
created_at=created_at,
238+
machine_name=machine_name,
239+
os_name=os,
240+
sdk=sdk,
241+
license_key=license_key,
242+
)
243+
payload_b64 = base64.b64encode(json_body.encode("utf-8")).decode("ascii")
244+
checksum = hashlib.sha256(payload_b64.encode("utf-8")).hexdigest()[:16]
245+
clean_app = app_id.replace("\r", " ").replace("\n", " ").strip()
246+
return "\n".join(
247+
[
248+
_BEGIN_ACTIVATION_REQUEST,
249+
f"Version: {_ACTIVATION_REQUEST_VERSION}",
250+
f"App-Id: {clean_app}",
251+
f"Checksum: {checksum}",
252+
"",
253+
_wrap_armor_64(payload_b64),
254+
_END_ACTIVATION_REQUEST,
255+
"",
256+
]
257+
)
258+
259+
120260
def parse_license_file(text: str) -> Optional[ParsedLicenseFile]:
121261
"""Parse armored ``.authforge`` text.
122262
@@ -463,6 +603,49 @@ def get_hwid(self) -> str:
463603
"""
464604
return self._hwid
465605

606+
def create_activation_request(
607+
self,
608+
*,
609+
include_machine_name: bool = False,
610+
machine_name: Optional[str] = None,
611+
os: Optional[str] = None,
612+
omit_os: bool = False,
613+
sdk: Optional[str] = None,
614+
omit_sdk: bool = False,
615+
license_key: Optional[str] = None,
616+
created_at: Optional[str] = None,
617+
) -> str:
618+
"""Build an activation request (``.authforge-request``) for this machine.
619+
620+
No network, no session, no app secret. The HWID is the same value
621+
:meth:`login` / :meth:`login_from_file` use. ``machine_name`` is omitted
622+
unless ``include_machine_name`` is true (hostnames are often a person's
623+
name).
624+
"""
625+
created = created_at if created_at else _utc_iso_ms()
626+
name: Optional[str] = None
627+
if include_machine_name:
628+
name = machine_name or platform.node() or socket.gethostname()
629+
os_name: Optional[str] = None if omit_os else (os if os is not None else _detect_os_label())
630+
sdk_tag: Optional[str] = None if omit_sdk else (sdk if sdk is not None else _SDK_TAG)
631+
key = license_key if license_key is not None else self._license_key
632+
return format_activation_request(
633+
app_id=self.app_id,
634+
hwid=self._hwid,
635+
created_at=created,
636+
machine_name=name,
637+
os=os_name,
638+
sdk=sdk_tag,
639+
license_key=key,
640+
)
641+
642+
def write_activation_request(self, path: str, **kwargs: Any) -> None:
643+
"""Write an activation request to ``path`` (UTF-8). Same kwargs as
644+
:meth:`create_activation_request`."""
645+
text = self.create_activation_request(**kwargs)
646+
with open(path, "w", encoding="utf-8", newline="\n") as handle:
647+
handle.write(text)
648+
466649
def login_from_file(self, path_or_text: str) -> bool:
467650
"""Authorize from a cloud-minted offline license file (``.authforge``)
468651
with NO network access. Accepts a filesystem path or the armored text.

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "authforge-sdk"
7-
version = "1.2.1"
7+
version = "1.3.0"
88
description = "Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses."
99
readme = "README.md"
1010
requires-python = ">=3.9"

0 commit comments

Comments
 (0)