Skip to content

Commit d904337

Browse files
committed
Add createActivationRequest so air-gapped customers can send a checksummed HWID file.
1 parent 36fcc56 commit d904337

7 files changed

Lines changed: 359 additions & 4 deletions

File tree

‎AGENTS.md‎

Lines changed: 2 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 a license key online: it sends the key plus a hardware ID to `POST /auth/validate`, and 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 that signed session without contacting AuthForge (the SDK re-verifies the signed session locally in the background) until the TTL expires. Optionally, you can enable online check-ins: periodic calls to `POST /auth/heartbeat` for fast revocation and concurrent-use detection. If the license is revoked or the session becomes invalid, the background check 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; `LoginFromFile()` 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 (pass `""`). 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; `LoginFromFile()` 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 (pass `""`). 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 `CreateActivationRequest`. It is not a license, is not signed, and does not mint anything. Prefer it over printing the raw HWID.
1111

1212
## Installation
1313

@@ -102,6 +102,7 @@ The old string-mode constructors still work and behave exactly as before, but th
102102
| `GetOfflineLicense()` | `std::optional<OfflineLicense>` | `jti`, `expiresAt`, `hwidPolicy`, … of the offline file in use |
103103
| `GetSessionKind()` | `SessionKind` | `SessionKind::Online`, `SessionKind::Offline`, or `SessionKind::None` when logged out |
104104
| `GetHwid()` | `const std::string&` | HWID this client sends; the customer reports it so the operator can mint a bound file |
105+
| `CreateActivationRequest(const ActivationRequestOptions& = {})` | `std::string` | Unsigned `.authforge-request` for this machine. No network, no secret, callable before `Login()`. Hostname omitted unless `includeMachineName` |
105106
| `Logout()` | `void` | Stops background checks and clears state |
106107
| `IsAuthenticated()` | `bool` | Whether authenticated |
107108
| `GetSessionDataJson()` | `std::optional<std::string>` | Payload JSON string |

‎CMakeLists.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
cmake_minimum_required(VERSION 3.16)
2-
project(AuthForgeCPPSDK VERSION 1.2.1 LANGUAGES CXX)
2+
project(AuthForgeCPPSDK VERSION 1.3.0 LANGUAGES CXX)
33

44
include(GNUInstallDirs)
55
include(CMakePackageConfigHelpers)

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ A desktop app with online check-ins running 6h/day at a 15-minute interval burns
160160
| `GetOfflineLicense()` | `std::optional<OfflineLicense>` | Metadata of the offline file in use (`jti`, `expiresAt`, `hwidPolicy`, …) |
161161
| `GetSessionKind()` | `SessionKind` | `SessionKind::Online`, `SessionKind::Offline`, or `SessionKind::None` when logged out |
162162
| `GetHwid()` | `const std::string&` | The HWID this client sends (or `hwidOverride`); customers share it to receive a bound file |
163+
| `CreateActivationRequest(const ActivationRequestOptions& = {})` | `std::string` | Unsigned `.authforge-request` for this machine. No network, no secret. Hostname omitted unless `includeMachineName` |
163164
| `Logout()` | `void` | Stops background checks and clears all session/auth state |
164165
| `IsAuthenticated()` | `bool` | True when an active authenticated session exists |
165166
| `GetSessionDataJson()` | `std::optional<std::string>` | Full decoded payload JSON |
@@ -194,8 +195,8 @@ authforge::AuthForgeClient client(
194195
std::cerr << reason << (exc ? std::string(": ") + exc->what() : "") << "\n";
195196
});
196197

197-
// 1. The customer sends you this value so you can bind the file to their machine:
198-
std::cout << "HWID: " << client.GetHwid() << "\n";
198+
// 1. Write an activation request the operator drops into the mint dialog:
199+
std::string request = client.CreateActivationRequest();
199200

200201
// 2. Later, authorize from the minted file (path or armored text). No network.
201202
if (client.LoginFromFile("license.authforge")) {

‎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_offline.cpp‎

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,24 @@
1111
#include <algorithm>
1212
#include <cctype>
1313
#include <chrono>
14+
#include <cstdio>
1415
#include <cstdlib>
16+
#include <ctime>
1517
#include <fstream>
18+
#include <iomanip>
1619
#include <map>
1720
#include <memory>
1821
#include <sstream>
1922
#include <stdexcept>
2023

2124
#include <sodium.h>
2225

26+
#ifdef _WIN32
27+
#include <windows.h>
28+
#else
29+
#include <unistd.h>
30+
#endif
31+
2332
namespace authforge {
2433

2534
namespace {
@@ -794,4 +803,230 @@ void AuthForgeClient::ApplyOfflineLicense(const OfflineLicense &license) {
794803
authenticated_ = true;
795804
}
796805

806+
namespace {
807+
808+
constexpr int kArmorLineWidth = 64;
809+
constexpr int kMaxRequestHwid = 256;
810+
constexpr int kMaxRequestMachineName = 128;
811+
constexpr int kMaxRequestOs = 64;
812+
constexpr int kMaxRequestSdk = 64;
813+
constexpr int kMaxRequestLicenseKey = 64;
814+
constexpr const char *kActivationRequestTyp = "authforge-activation-request";
815+
constexpr const char *kBeginActivationRequest = "-----BEGIN AUTHFORGE ACTIVATION REQUEST-----";
816+
constexpr const char *kEndActivationRequest = "-----END AUTHFORGE ACTIVATION REQUEST-----";
817+
constexpr const char *kActivationRequestSdkTag = "cpp/1.2.1";
818+
819+
std::string ClipRequestField(const std::string &value, int max) {
820+
if (static_cast<int>(value.size()) <= max) {
821+
return value;
822+
}
823+
return value.substr(0, static_cast<std::size_t>(max));
824+
}
825+
826+
std::string JsonEscapeRequest(const std::string &value) {
827+
std::ostringstream oss;
828+
oss << '"';
829+
for (unsigned char ch : value) {
830+
switch (ch) {
831+
case '\\':
832+
oss << "\\\\";
833+
break;
834+
case '"':
835+
oss << "\\\"";
836+
break;
837+
case '\b':
838+
oss << "\\b";
839+
break;
840+
case '\f':
841+
oss << "\\f";
842+
break;
843+
case '\n':
844+
oss << "\\n";
845+
break;
846+
case '\r':
847+
oss << "\\r";
848+
break;
849+
case '\t':
850+
oss << "\\t";
851+
break;
852+
default:
853+
if (ch < 0x20U) {
854+
oss << "\\u00";
855+
oss << "0123456789abcdef"[(ch >> 4) & 0x0F];
856+
oss << "0123456789abcdef"[ch & 0x0F];
857+
} else {
858+
oss << static_cast<char>(ch);
859+
}
860+
break;
861+
}
862+
}
863+
oss << '"';
864+
return oss.str();
865+
}
866+
867+
std::string WrapArmor64(const std::string &value) {
868+
std::string out;
869+
for (std::size_t i = 0; i < value.size(); i += static_cast<std::size_t>(kArmorLineWidth)) {
870+
if (!out.empty()) {
871+
out.push_back('\n');
872+
}
873+
out.append(value, i, static_cast<std::size_t>(kArmorLineWidth));
874+
}
875+
return out;
876+
}
877+
878+
std::string EncodeBase64(const std::string &input) {
879+
static const char kTable[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
880+
std::string out;
881+
const auto *data = reinterpret_cast<const unsigned char *>(input.data());
882+
const std::size_t len = input.size();
883+
out.reserve(((len + 2) / 3) * 4);
884+
std::size_t i = 0;
885+
while (i + 2 < len) {
886+
const unsigned int n = (static_cast<unsigned int>(data[i]) << 16) | (static_cast<unsigned int>(data[i + 1]) << 8) |
887+
static_cast<unsigned int>(data[i + 2]);
888+
out.push_back(kTable[(n >> 18) & 63]);
889+
out.push_back(kTable[(n >> 12) & 63]);
890+
out.push_back(kTable[(n >> 6) & 63]);
891+
out.push_back(kTable[n & 63]);
892+
i += 3;
893+
}
894+
if (i < len) {
895+
unsigned int n = static_cast<unsigned int>(data[i]) << 16;
896+
if (i + 1 < len) {
897+
n |= static_cast<unsigned int>(data[i + 1]) << 8;
898+
}
899+
out.push_back(kTable[(n >> 18) & 63]);
900+
out.push_back(kTable[(n >> 12) & 63]);
901+
out.push_back(i + 1 < len ? kTable[(n >> 6) & 63] : '=');
902+
out.push_back('=');
903+
}
904+
return out;
905+
}
906+
907+
std::string Sha256Hex16(const std::string &payloadB64) {
908+
if (sodium_init() < 0) {
909+
throw std::runtime_error("sodium_init failed");
910+
}
911+
unsigned char digest[crypto_hash_sha256_BYTES];
912+
crypto_hash_sha256(digest, reinterpret_cast<const unsigned char *>(payloadB64.data()), payloadB64.size());
913+
static constexpr char kHex[] = "0123456789abcdef";
914+
std::string out(16, '0');
915+
for (int i = 0; i < 8; ++i) {
916+
out[static_cast<std::size_t>(i * 2)] = kHex[(digest[i] >> 4) & 0x0F];
917+
out[static_cast<std::size_t>(i * 2 + 1)] = kHex[digest[i] & 0x0F];
918+
}
919+
return out;
920+
}
921+
922+
std::string CanonicalActivationRequestJson(const std::string &appId, const std::string &hwid, const std::string &createdAt,
923+
const std::string &machineName, const std::string &os, const std::string &sdk,
924+
const std::string &licenseKey) {
925+
std::string json = "{";
926+
json += "\"v\":1";
927+
json += ",\"typ\":" + JsonEscapeRequest(kActivationRequestTyp);
928+
json += ",\"appId\":" + JsonEscapeRequest(appId);
929+
json += ",\"hwid\":" + JsonEscapeRequest(ClipRequestField(hwid, kMaxRequestHwid));
930+
json += ",\"createdAt\":" + JsonEscapeRequest(createdAt);
931+
if (!machineName.empty()) {
932+
json += ",\"machineName\":" + JsonEscapeRequest(ClipRequestField(machineName, kMaxRequestMachineName));
933+
}
934+
if (!os.empty()) {
935+
json += ",\"os\":" + JsonEscapeRequest(ClipRequestField(os, kMaxRequestOs));
936+
}
937+
if (!sdk.empty()) {
938+
json += ",\"sdk\":" + JsonEscapeRequest(ClipRequestField(sdk, kMaxRequestSdk));
939+
}
940+
if (!licenseKey.empty()) {
941+
json += ",\"licenseKey\":" + JsonEscapeRequest(ClipRequestField(licenseKey, kMaxRequestLicenseKey));
942+
}
943+
json += "}";
944+
return json;
945+
}
946+
947+
std::string DetectOsLabel() {
948+
#if defined(_WIN32)
949+
return "Windows";
950+
#elif defined(__APPLE__)
951+
return "macOS";
952+
#else
953+
return "Linux";
954+
#endif
955+
}
956+
957+
std::string UtcIsoMsNow() {
958+
const auto now = std::chrono::system_clock::now();
959+
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()) % 1000;
960+
const std::time_t t = std::chrono::system_clock::to_time_t(now);
961+
std::tm tm{};
962+
#ifdef _WIN32
963+
gmtime_s(&tm, &t);
964+
#else
965+
gmtime_r(&t, &tm);
966+
#endif
967+
char buf[32];
968+
std::snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ", tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
969+
tm.tm_hour, tm.tm_min, tm.tm_sec, static_cast<int>(ms.count()));
970+
return buf;
971+
}
972+
973+
std::string DetectHostname() {
974+
#ifdef _WIN32
975+
char buf[256];
976+
DWORD n = static_cast<DWORD>(sizeof(buf));
977+
if (GetComputerNameA(buf, &n) != 0) {
978+
return buf;
979+
}
980+
#else
981+
char buf[256];
982+
if (gethostname(buf, sizeof(buf)) == 0) {
983+
return buf;
984+
}
985+
#endif
986+
return "";
987+
}
988+
989+
} // namespace
990+
991+
std::string FormatActivationRequest(const std::string &appId, const std::string &hwid, const std::string &createdAt,
992+
const std::string &machineName, const std::string &os, const std::string &sdk,
993+
const std::string &licenseKey) {
994+
const std::string json = CanonicalActivationRequestJson(appId, hwid, createdAt, machineName, os, sdk, licenseKey);
995+
const std::string payloadB64 = EncodeBase64(json);
996+
const std::string checksum = Sha256Hex16(payloadB64);
997+
std::string clean = appId;
998+
for (char &ch : clean) {
999+
if (ch == '\r' || ch == '\n') {
1000+
ch = ' ';
1001+
}
1002+
}
1003+
std::ostringstream oss;
1004+
oss << kBeginActivationRequest << "\n";
1005+
oss << "Version: 1\n";
1006+
oss << "App-Id: " << TrimCopy(clean) << "\n";
1007+
oss << "Checksum: " << checksum << "\n";
1008+
oss << "\n";
1009+
oss << WrapArmor64(payloadB64) << "\n";
1010+
oss << kEndActivationRequest << "\n";
1011+
return oss.str();
1012+
}
1013+
1014+
std::string AuthForgeClient::CreateActivationRequest(const ActivationRequestOptions &options) const {
1015+
const std::string createdAt = options.createdAt.empty() ? UtcIsoMsNow() : options.createdAt;
1016+
std::string machineName;
1017+
if (options.includeMachineName) {
1018+
machineName = options.machineName.empty() ? DetectHostname() : options.machineName;
1019+
}
1020+
std::string os;
1021+
if (!options.omitOs) {
1022+
os = options.os.empty() ? DetectOsLabel() : options.os;
1023+
}
1024+
std::string sdk;
1025+
if (!options.omitSdk) {
1026+
sdk = options.sdk.empty() ? kActivationRequestSdkTag : options.sdk;
1027+
}
1028+
const std::string licenseKey = options.licenseKey.empty() ? licenseKey_ : options.licenseKey;
1029+
return FormatActivationRequest(appId_, hwid_, createdAt, machineName, os, sdk, licenseKey);
1030+
}
1031+
7971032
} // namespace authforge

‎authforge_sdk.h‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,26 @@ VerifyLicenseFileResult VerifyLicenseFile(
144144
/// Parse `YYYY-MM-DDTHH:MM:SS[.fff][Z|+HH:MM]` into epoch milliseconds.
145145
std::optional<long long> ParseIso8601Ms(const std::string &value);
146146

147+
/// Optional fields for AuthForgeClient::CreateActivationRequest.
148+
/// machineName is omitted unless includeMachineName is true.
149+
struct ActivationRequestOptions {
150+
bool includeMachineName = false;
151+
bool omitOs = false;
152+
bool omitSdk = false;
153+
std::string machineName;
154+
std::string os;
155+
std::string sdk;
156+
std::string licenseKey;
157+
std::string createdAt;
158+
};
159+
160+
/// Armored `.authforge-request` text from explicit fields. Empty optional
161+
/// strings are omitted from the payload.
162+
std::string FormatActivationRequest(const std::string &appId, const std::string &hwid,
163+
const std::string &createdAt, const std::string &machineName = "",
164+
const std::string &os = "", const std::string &sdk = "",
165+
const std::string &licenseKey = "");
166+
147167
class AuthForgeClient {
148168
public:
149169
static constexpr const char *kDefaultApiBaseUrl = "https://auth.authforge.cc";
@@ -246,6 +266,11 @@ class AuthForgeClient {
246266
/// `.authforge` file can be bound to it.
247267
const std::string &GetHwid() const noexcept { return hwid_; }
248268

269+
/// Build an activation request (`.authforge-request`) for this machine.
270+
/// No network, no session, no app secret. machineName is omitted unless
271+
/// options.includeMachineName is true.
272+
std::string CreateActivationRequest(const ActivationRequestOptions &options = ActivationRequestOptions()) const;
273+
249274
/// Authorize from a cloud-minted offline license file (`.authforge`) with
250275
/// NO network access. Accepts a filesystem path or the armored text.
251276
///

0 commit comments

Comments
 (0)