从零对接:先选一条路
不用先读完整份文档。对照下面的表选一条路,照 5~8 步做完就能跑通。
| 你的情况 | 推荐方式 | 需要构建令牌吗 |
|---|---|---|
| Android APK,不想改代码 | 官方 NCCInjector:登录开发者账号 → 下拉选择软件 → 选 APK → 注入 | 不需要 |
| 自己写注入器 / 批处理脚本 | 登录开发者账号拿 token,调用 /api/dev/injector/register | 不需要,登录态就是凭证 |
| 第三方注入器,无登录只填 AppKey | 软件开启「开放注入」,调用 /api/v1/injector/open-register | 软件开启「强制构建令牌」时需要 |
| 自己写客户端(不做 APK 注入) | 按本页 API 接入 AppKey / AppSecret | 不涉及 |
路线 A:官方注入器(推荐,零代码)
- 注册并登录:打开注册页,登录后进入控制台。
- 新建软件:控制台 → 软件管理 → 新建软件。AppKey 自动生成;AppSecret 只显示一次,先保存好(官方注入器不需要你手抄 AppKey)。
- 打开 NCCInjector 并登录:用同一个开发者账号登录。登录一次后本机记住 7 天,下次打开自动进入。
- 选择软件:登录后自动加载你名下的软件列表,直接在下拉框选择目标软件。列表只显示当前账号有权限的软件,不用再手动填 AppKey。
- 选择宿主 APK:点「选择 APK」,可一次选多个;工具会显示包名、版本和 ABI。
- 选择登录方式:账号 / 卡密 / 仅设备,按业务选一个;建议保留「开启心跳验证」(默认开),需要硬件完整性证明时再勾选 Key Attestation。
- 选择保护与签名:标准 / 加强 / 最强。最强保护按次收费,会弹窗验证 NCCInjector 软件下生成的次数卡(与目标软件无关);采用预授权 + 注入成功后提交,失败/取消不扣次数。签名可用工具自动生成的证书,也可以选自己的 keystore。
- 开始注入并测试:点「注入并重签名」,装到测试机验证注册、登录、卡密、心跳;确认无误再分发。
公告支持换行、[文字](https://链接)、;裸 https 链接/图片直链也会自动识别。后台公告框有「插入链接 / 插入图片 / 插入换行」和实时预览,图片请使用 https 直链。
发版时在控制台软件编辑里设置「版本号 / 最低可用版本 / 更新说明 / 公告 / 更新地址」。注入器登录后会显示公告;检测到更高版本会弹窗,是否强制由后台「强制更新」和「最低可用版本」控制。
路线 B:自己写客户端(完全掌控)
- 准备密钥:软件详情里拿
AppKey与AppSecret;下载对应语言的签名模块。 - 启动检查:调
/app/info,拿公告、版本与强更结论。 - 注册 / 登录:调
/user/register、/user/login,保存返回的 token 与用户信息。 - 心跳续期:按 10 分钟间隔调
/user/heartbeat;失败回到登录页。 - 按需加卡密 / 云变量 / 设备会话:分别看卡模式、云变量、设备会话。
- 上线前:先在一台测试机跑通,再按安全配置指南逐项开启 AES、机器码等会中断旧客户端的开关。
构建令牌到底什么时候用
构建令牌(build_token)不是客户端验证用的,也不影响登录 / 卡密 / 心跳。它只回答一个问题:这次「登记构建」的请求,凭什么证明你是被授权的注入器?
- 官方注入器(登录态):账号换来的 token 已经证明身份 → 不需要 build_token。
- 你自己的登录态脚本:同样用开发者 token 调
/api/dev/injector/register→ 不需要 build_token;软件也不需要开启开放注入。 - 第三方注入器(无登录、只填 AppKey):软件必须开启「开放注入」;如果还开启了「强制构建令牌」,请求必须带有效的 build_token,否则返回 426。
快速开始
创建软件
注册后在控制台新建软件,拿到 AppKey / AppSecret(用官方注入器时登录后会自动列出软件,无需手抄 AppKey)。新软件默认关闭注册,跑示例前请在创建弹窗勾选「允许注册」(或编辑软件开启)。
获取签名模块
Python / Java / Kotlin 示例依赖官方签名模块,直接点击下载:sign_module.py · SignModule.java · SignModule.kt(控制台总览「接入信息」卡也有入口)。放到示例代码同目录再运行——没有它示例无法通过签名校验。
跑通最小示例
切换下方语言标签(Python / Java / Kotlin / C# / C++),复制代码、替换开头两个密钥,直接运行。
整合进你的软件
照 基础接入流程 把登录 / 心跳整合进去,或逐个查看接口文档。不想写代码?用注入器一键加固 APK。
import json, time, uuid, urllib.request
from sign_module import sign, verify_rsign # 官方签名模块(获取方式见文末「签名模块」)
APPKEY, SECRET = "nkxxxx", "你的AppSecret" # 控制台创建软件后获得
API = "https://app.netccheck.tech/api/v1"
def call(path, **biz):
nonce = uuid.uuid4().hex
body = {"appkey": APPKEY, "time": str(int(time.time())), "nonce": nonce, **biz}
body["sign"] = sign(body, SECRET) # 签名实现以官方模块为准(随平台升级更新)
req = urllib.request.Request(API + path, json.dumps(body).encode(),
{"Content-Type": "application/json", "User-Agent": "NetcCheck-SDK/1.0"})
resp = json.loads(urllib.request.urlopen(req).read())
if not verify_rsign(resp, nonce, SECRET): # 200 响应必须验签:失败=疑似假服务器,立即中止
raise SystemExit("响应验签失败(疑似假服务器)")
return resp
info = call("/app/info", ver="1.0.0") # 启动:公告 + 强更结论
if info["data"]["need_update"]:
raise SystemExit("请先更新到 " + info["data"]["version"])
print(call("/user/register", username="tom", password="123456")) # 首次运行先注册(已存在返回 411,忽略即可)
print(call("/user/login", username="tom", password="123456", machine="PC-01"))
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
public class QuickStart {
static final String APPKEY = "nkxxxx", SECRET = "你的AppSecret"; // 控制台创建软件后获得
static final String API = "https://app.netccheck.tech/api/v1";
static final HttpClient HTTP = HttpClient.newHttpClient();
// SignModule.sign / verifyRsign 来自官方签名模块(获取方式见文末「签名模块」)
static String call(String path, String... kv) throws Exception {
Map<String, String> body = new TreeMap<>(); // 含 appkey/time/nonce + 业务参数
body.put("appkey", APPKEY);
body.put("time", String.valueOf(System.currentTimeMillis() / 1000));
body.put("nonce", UUID.randomUUID().toString().replace("-", ""));
for (int i = 0; i < kv.length; i += 2) body.put(kv[i], kv[i + 1]);
body.put("sign", SignModule.sign(body, SECRET));
StringBuilder json = new StringBuilder("{");
for (var e : body.entrySet()) json.append('"').append(e.getKey()).append("\":\"")
.append(e.getValue().replace("\\", "\\\\").replace("\"", "\\\"")).append("\",");
json.setCharAt(json.length() - 1, '}');
HttpRequest req = HttpRequest.newBuilder(URI.create(API + path))
.header("Content-Type", "application/json")
.header("User-Agent", "NetcCheck-SDK/1.0") // 必设:默认 UA 会被 CF 拦截
.POST(HttpRequest.BodyPublishers.ofString(json.toString())).build();
String resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
if (!SignModule.verifyRsign(resp, body.get("nonce"), SECRET)) // 200 必须验签:假服务器立即中止
throw new IllegalStateException("响应验签失败(疑似假服务器)");
return resp;
}
public static void main(String[] args) throws Exception {
String info = call("/app/info", "ver", "1.0.0"); // 启动:公告 + 强更结论
if (info.contains("\"need_update\":true")) // 生产请用 JSON 库
throw new IllegalStateException("发现新版本,请先更新");
System.out.println(call("/user/register", "username", "tom",
"password", "123456")); // 首次运行先注册(已存在返回 411,忽略即可)
System.out.println(call("/user/login", "username", "tom",
"password", "123456", "machine", "PC-01"));
}
}
import java.net.URI
import java.net.http.*
import java.nio.charset.StandardCharsets
import java.util.*
val APPKEY = "nkxxxx" // 控制台创建软件后获得
val SECRET = "你的AppSecret"
val API = "https://app.netccheck.tech/api/v1"
val HTTP = HttpClient.newHttpClient()
// SignModule.sign / verifyRsign 来自官方签名模块(获取方式见文末「签名模块」)
fun call(path: String, vararg kv: Pair<String, String>): String {
val body = sortedMapOf(
"appkey" to APPKEY,
"time" to (System.currentTimeMillis() / 1000).toString(),
"nonce" to UUID.randomUUID().toString().replace("-", ""),
*kv
)
body["sign"] = SignModule.sign(body, SECRET)
val json = body.entries.joinToString(",", "{", "}") {
"\"${it.key}\":\"${it.value.replace("\\", "\\\\").replace("\"", "\\\"")}\""
}
val req = HttpRequest.newBuilder(URI.create(API + path))
.header("Content-Type", "application/json")
.header("User-Agent", "NetcCheck-SDK/1.0") // 必设:默认 UA 会被 CF 拦截
.POST(HttpRequest.BodyPublishers.ofString(json)).build()
val resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body()
check(SignModule.verifyRsign(resp, body["nonce"]!!, SECRET)) { "响应验签失败(疑似假服务器)" } // 200 必须验签
return resp
}
fun main() {
val info = call("/app/info", "ver" to "1.0.0") // 启动:公告 + 强更结论
if ("\"need_update\":true" in info) // 生产请用 JSON 库
error("发现新版本,请先更新")
println(call("/user/register", "username" to "tom", "password" to "123456")) // 首次运行先注册(已存在 411 忽略)
println(call("/user/login", "username" to "tom", "password" to "123456", "machine" to "PC-01"))
}
// .NET 6+:零第三方依赖(JSON 为极简拼接,生产环境建议 System.Text.Json)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
class QuickStart {
const string APPKEY = "nkxxxx", SECRET = "你的AppSecret"; // 控制台创建软件后获得
const string API = "https://app.netccheck.tech/api/v1";
static readonly HttpClient HTTP = new HttpClient();
// 签名:与官方签名模块同规则——除 sign 外全部参数按名升序 k=v 用 & 连接,末尾拼 &key=SECRET,HMAC-SHA256 hex 小写
static string Sign(SortedDictionary<string, string> p, string secret) {
string bas = string.Join("&", p.Where(x => x.Key != "sign").Select(x => x.Key + "=" + x.Value)) + "&key=" + secret;
using var h = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
return BitConverter.ToString(h.ComputeHash(Encoding.UTF8.GetBytes(bas))).Replace("-", "").ToLower();
}
// 最小提取响应 JSON 顶层字段(示例版;生产环境请用 JSON 库)
static string JGet(string s, string k) {
int i = s.IndexOf("\"" + k + "\":", StringComparison.Ordinal);
if (i < 0) return "";
i += k.Length + 3;
if (i < s.Length && s[i] == '"') { int e = s.IndexOf('"', i + 1); return e < 0 ? "" : s.Substring(i + 1, e - i - 1); }
int j = s.IndexOfAny(new[] { ',', '}' }, i);
return j < 0 ? "" : s.Substring(i, j - i);
}
// 响应验签:rsign = HMAC-SHA256(secret, "ncrs|nonce|code|msg")。200 响应必须验签,失败立即中止
static void VerifyRsign(string resp, string nonce, string secret) {
using var h = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
string expect = BitConverter.ToString(h.ComputeHash(Encoding.UTF8.GetBytes(
"ncrs|" + nonce + "|" + JGet(resp, "code") + "|" + JGet(resp, "msg")))).Replace("-", "").ToLower();
if (expect != JGet(resp, "rsign").ToLower())
throw new Exception("响应验签失败(疑似假服务器)");
}
static string Esc(string v) => v.Replace("\\", "\\\\").Replace("\"", "\\\"");
static string Call(string path, params string[] kv) {
var body = new SortedDictionary<string, string> { // SortedDictionary 自动按参数名排序
{ "appkey", APPKEY },
{ "time", ((DateTimeOffset)DateTime.UtcNow).ToUnixTimeSeconds().ToString() },
{ "nonce", Guid.NewGuid().ToString("N") }
};
for (int i = 0; i < kv.Length; i += 2) body[kv[i]] = kv[i + 1];
body["sign"] = Sign(body, SECRET);
string json = "{" + string.Join(",", body.Select(e => "\"" + e.Key + "\":\"" + Esc(e.Value) + "\"")) + "}";
using var resp = HTTP.PostAsync(API + path, new StringContent(json, Encoding.UTF8, "application/json")).Result;
string text = resp.Content.ReadAsStringAsync().Result;
VerifyRsign(text, body["nonce"], SECRET);
return text;
}
public static void Main() {
Console.WriteLine(Call("/app/info", "ver", "1.0.0")); // 启动:公告 + 强更结论
Console.WriteLine(Call("/user/register", "username", "tom", "password", "123456")); // 首次运行先注册(已存在 411 忽略)
Console.WriteLine(Call("/user/login", "username", "tom", "password", "123456", "machine", "PC-01"));
}
}
// Windows(VS2019+ / MinGW-w64):WinHTTP 请求 + CNG 签名验签 // 编译(MSVC):cl /EHsc /utf-8 qs.cpp winhttp.lib bcrypt.lib // 编译(MinGW):g++ qs.cpp -lwinhttp -lbcrypt -std=c++17 #include#include #include #include #include
接口速查
| 接口 | 用途 | 需登录态 |
|---|---|---|
/app/info | 公告 / 版本 / 强制更新结论 | 否 |
/user/register | 用户注册 | 否 |
/user/login | 登录 + 机器码绑定 | 否(返回 token) |
/user/heartbeat | 心跳保活 / 到期校验 | 是 |
/user/recharge | 卡密充值续费 | 否 |
/user/unbind | 账号模式自助解绑机器码(换机) | 否(账号密码即凭证) |
/card/login · /card/reset · /card/check | 卡模式(卡密即授权)激活 / 解绑 / 预检 | 否(卡密即凭证) |
/var/get · /var/set | 云变量(远程配置 / 开关) | 是 |
/device/enroll · /device/session | 设备会话:注册设备 / 换短时会话密钥(见 设备会话) | 否(设备凭证即身份) |
/security/report | 客户端抓包环境上报 | 否(见 防抓包检测) |
所有请求都需要公共参数(appkey / time / nonce / sign),签名算法见下节;完整接入看 基础接入流程,开启加密后看 进阶接入流程。
签名与防重放
每个请求都必须携带签名(sign),它向服务器证明两件事:参数没有被篡改、请求出自持有 AppSecret 的一方。签名细节封装在官方签名模块中(Python/Java/Kotlin 的示例代码内的 sign();C# / C++ 示例内置同规则实现),接入方直接调用即可。
| 约定 | 说明 |
|---|---|
| 签名范围 | 除 sign 本身外的全部参数——业务参数与 appkey/time/nonce 等公共参数一起参与;开启传输加密后 enc/iv/data 也参与 |
| 时间戳 | time 为 Unix 秒级时间戳,与服务器偏差超过 ±300 秒会被拒绝(请确保设备时间大致准确) |
| 防重放 | nonce 每次请求必须唯一,同一 appkey 下短时间重复的 nonce 会被拒绝 |
| 失败表现 | 签名 / 时间戳 / nonce / AppKey 任一不对,统一返回 401(不区分具体原因,防探测) |
| 自研接入 | 签名模块源码可运行、可直接照抄实现——拿到模块后按其中的拼接与摘要规则实现即为对接成功;请勿凭旧版资料自行发挥(算法升级会导致失效) |
安全说明:AppSecret 只作为签名密钥在你本地参与计算,永不随请求传输——泄露 AppSecret 等于泄露伪造请求的能力,请妥善保管。
签名 v2(推荐新接入使用):对原始请求体做一次 HMAC
不想处理「参数排序、拼接、类型转字符串」?v2 把签名简化成一句话——对你发出去的 JSON 原文做一次 HMAC-SHA256:
| 项 | 说明 |
|---|---|
| 算法 | sign = HMAC-SHA256(密钥, 原始请求体字符串),hex 小写。密钥选择与 v1 完全相同(常规请求 AppSecret;会话内业务 sessionKey;换取会话 deviceSecret) |
| 启用方式 | 公共参数里加一个 sver: "2",并把 sign 按 v2 计算即可;不加 sver 则按 v1 规则验签,存量客户端零影响 |
| 一致性要求 | 请求体必须与签名时的字符串逐字节一致(先序列化定稿 → 对原文签名 → 原样发送)。签名覆盖整个请求体,time/nonce 在体内照常防重放 |
| 安全性 | v2 仅接受 HMAC-SHA256(不走 MD5 兼容路径);签名基准与发送内容天然一致,消除了拼接/编码类错误导致的验签失败,也消除了参数规范化歧义 |
| 模块支持 | 官方签名模块(Python / Java / Kotlin)已提供 sign_v2(raw, secret) / signV2() |
传输加密(AES-256-GCM,可选)
后台「软件管理 → 编辑 → AES 传输加密」开启后,含业务数据的接口(注册/登录/心跳/充值/云变量,以及设备会话的注册设备与换取会话)必须加密提交;
/app/info 仅含公告等公开数据,明文即可调用(响应保持原样)。加密封装已内置在五语言示例代码中(encrypt() / decrypt()),直接复制使用即可,无需了解内部细节。加密密钥按阶段分层:常规请求与设备注册用「加密密钥」;换取会话用 deviceSecret;会话内业务用 sessionKey(协议不变,见设备会话):
| 项 | 约定 |
|---|---|
| 密钥 | 控制台「加密密钥」。新建软件默认无需配置:留空时服务端与客户端约定由 AppSecret 经 HKDF-SHA256 派生(官方示例代码已内置),接入只管 AppSecret + AppKey 两把钥匙;需要独立密钥时在控制台一键「重置为独立密钥」。密钥仅存在于你的服务端配置与客户端本地,永不参与网络传输 |
| 重置为独立密钥 | 位置:控制台「软件管理 → 编辑 → 密钥」区(查看/操作需输入你的账号密码确认)。什么时候需要:默认的派生模式下,加密密钥与 AppSecret 绑定——一旦重置 AppSecret(如怀疑泄露),派生的加密密钥会连带变化,已发布客户端的签名与加解密会同时失效,两把都要换。点「重置为独立密钥」后,加密密钥变为独立随机值,与 AppSecret 彻底解耦:此后无论怎么重置 AppSecret,加密密钥都不变,客户端只需更新签名密钥。注意:重置瞬间旧加密密钥立即失效,所有已发布客户端会 400 加密数据解密失败,必须随新版本同步更新 ENC_KEY——请配合版本强制更新使用,不要在用户在线高峰期操作 |
| 请求 | 在公共参数基础上加 enc=1、iv、data 三个字段(它们同样参与签名) |
| 响应 | code/msg 保持明文(便于客户端判错),业务数据放在 enc: {iv, data} 中,用示例代码内的 decrypt() 还原 |
| 失败 | 明文调业务接口 → 400 该软件已启用传输加密;密钥错/密文被改 → 400 加密数据解密失败;密文体超过 32KB → 400 加密数据过大(正常业务远用不到该上限) |
示例实现:JVM 自带 AES-GCM 零依赖;Python 需 cryptography 库。
import os, json, base64
from cryptography.hazmat.primitives.ciphers.aead import AESGCM # pip install cryptography
KEY = bytes.fromhex("控制台64位hex加密密钥") # 32 字节
def encrypt(biz: dict):
iv = os.urandom(12)
ct = AESGCM(KEY).encrypt(iv, json.dumps(biz, ensure_ascii=False).encode(), None) # Tag 自动拼接在尾部
return {"enc": 1, "iv": base64.b64encode(iv).decode(),
"data": base64.b64encode(ct).decode()} # = 密文+Tag
def decrypt(iv_b64: str, data_b64: str) -> dict:
return json.loads(AESGCM(KEY).decrypt(base64.b64decode(iv_b64),
base64.b64decode(data_b64), None))
// Java 11+:javax.crypto 自带 AES-GCM,无需第三方库
static final byte[] KEY = hexToBytes("控制台64位hex加密密钥"); // 32 字节
static byte[] hexToBytes(String hex) {
byte[] out = new byte[hex.length() / 2];
for (int i = 0; i < out.length; i++)
out[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
return out;
}
/** 返回 {iv, data};Java 的 GCM doFinal 自动把 16 字节 Tag 拼在密文尾部 */
static String[] encrypt(String json) throws Exception {
byte[] iv = new byte[12];
new SecureRandom().nextBytes(iv);
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(KEY, "AES"), new GCMParameterSpec(128, iv));
return new String[]{ Base64.getEncoder().encodeToString(iv),
Base64.getEncoder().encodeToString(c.doFinal(json.getBytes(StandardCharsets.UTF_8))) };
}
static String decrypt(String ivB64, String dataB64) throws Exception {
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(KEY, "AES"),
new GCMParameterSpec(128, Base64.getDecoder().decode(ivB64)));
return new String(c.doFinal(Base64.getDecoder().decode(dataB64)), StandardCharsets.UTF_8);
}
// JVM 自带 AES-GCM,无需第三方库
val KEY = hexToBytes("控制台64位hex加密密钥") // 32 字节
fun hexToBytes(hex: String): ByteArray {
val out = ByteArray(hex.length / 2)
for (i in out.indices) out[i] = hex.substring(i * 2, i * 2 + 2).toInt(16).toByte()
return out
}
/** 返回 (iv, data);JVM 的 GCM doFinal 自动把 16 字节 Tag 拼在密文尾部 */
fun encrypt(json: String): Pair<String, String> {
val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
val c = Cipher.getInstance("AES/GCM/NoPadding")
c.init(Cipher.ENCRYPT_MODE, SecretKeySpec(KEY, "AES"), GCMParameterSpec(128, iv))
return Base64.getEncoder().encodeToString(iv) to // iv
Base64.getEncoder().encodeToString(c.doFinal(json.toByteArray())) // data = 密文+Tag
}
fun decrypt(ivB64: String, dataB64: String): String {
val c = Cipher.getInstance("AES/GCM/NoPadding")
c.init(Cipher.DECRYPT_MODE, SecretKeySpec(KEY, "AES"),
GCMParameterSpec(128, Base64.getDecoder().decode(ivB64)))
return String(c.doFinal(Base64.getDecoder().decode(dataB64)))
}
// .NET 6+:内置 AesGcm,零第三方依赖
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
static class AesUtil {
const string KEY = "控制台64位hex加密密钥"; // 32 字节
// 返回 (iv, data):data = base64(密文+16字节Tag),与服务端及各语言示例一致
public static Tuple<string, string> Encrypt(string json) {
byte[] key = Convert.FromHexString(KEY), iv = new byte[12];
RandomNumberGenerator.Fill(iv);
byte[] pt = Encoding.UTF8.GetBytes(json), ct = new byte[pt.Length], tag = new byte[16];
using var g = new AesGcm(key, 16);
g.Encrypt(iv, pt, ct, tag);
return Tuple.Create(Convert.ToBase64String(iv), Convert.ToBase64String(ct.Concat(tag).ToArray()));
}
public static string Decrypt(string ivB64, string dataB64) {
byte[] key = Convert.FromHexString(KEY);
byte[] all = Convert.FromBase64String(dataB64), iv = Convert.FromBase64String(ivB64);
byte[] ct = all.Take(all.Length - 16).ToArray(), tag = all.Skip(all.Length - 16).ToArray();
byte[] pt = new byte[ct.Length];
using var g = new AesGcm(key, 16);
g.Decrypt(iv, ct, tag, pt); // 密文被改/密钥错时抛异常
return Encoding.UTF8.GetString(pt);
}
}
// CNG AES-256-GCM:data = base64(密文+16字节Tag),与服务端及各语言示例一致(Windows 自带,零第三方依赖)
#include <windows.h>
#include <bcrypt.h>
#include <string>
#include <vector>
#pragma comment(lib, "bcrypt.lib")
static const std::string B64T = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
static std::string B64(const unsigned char* d, int n) {
std::string o;
for (int i = 0; i < n; i += 3) {
int left = n - i;
unsigned v = (unsigned)d[i] << 16 | (left > 1 ? (unsigned)d[i + 1] << 8 : 0) | (left > 2 ? d[i + 2] : 0);
o.push_back(B64T[(v >> 18) & 63]); o.push_back(B64T[(v >> 12) & 63]);
o.push_back(left > 1 ? B64T[(v >> 6) & 63] : '='); o.push_back(left > 2 ? B64T[v & 63] : '=');
}
return o;
}
static std::vector<unsigned char> UnB64(const std::string& s) {
auto V = [](char c) -> int {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '+') return 62; if (c == '/') return 63; return -1;
};
std::vector<unsigned char> o; int acc = 0, bits = 0;
for (char c : s) { int v = V(c); if (v < 0) continue; acc = (acc << 6) | v; bits += 6;
if (bits >= 8) { bits -= 8; o.push_back((acc >> bits) & 255); } }
return o;
}
static std::string HexToRaw(const std::string& hex) {
auto V = [](char c) -> int { return c <= '9' ? c - '0' : (c | 32) - 'a' + 10; };
std::string o; for (size_t i = 0; i + 1 < hex.size(); i += 2) o.push_back((char)((V(hex[i]) << 4) | V(hex[i + 1])));
return o;
}
// GCM 一次加密:返回 false = 参数/环境错误;tag 16 字节拼在密文尾部由调用方 base64
static bool AesGcm(const std::string& key32, const unsigned char iv[12],
const std::string& in, std::string& out, unsigned char tag[16], bool decrypt) {
BCRYPT_ALG_HANDLE alg = nullptr; BCRYPT_KEY_HANDLE k = nullptr;
bool ok = false;
if (BCryptOpenAlgorithmProvider(&alg, BCRYPT_AES_ALGORITHM, nullptr, 0) == 0
&& BCryptSetProperty(alg, BCRYPT_CHAINING_MODE, (PUCHAR)BCRYPT_CHAIN_MODE_GCM,
sizeof(BCRYPT_CHAIN_MODE_GCM), 0) == 0
&& BCryptGenerateSymmetricKey(alg, &k, nullptr, 0, (PUCHAR)key32.data(), (ULONG)key32.size(), 0) == 0) {
BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO info;
BCRYPT_INIT_AUTH_MODE_INFO(info);
info.pbNonce = (PUCHAR)iv; info.cbNonce = 12; info.pbTag = tag; info.cbTag = 16;
ULONG done = 0;
out.resize(in.size());
ok = decrypt ? BCryptDecrypt(k, (PUCHAR)in.data(), (ULONG)in.size(), &info,
nullptr, 0, (PUCHAR)out.data(), (ULONG)out.size(), &done, 0) == 0
: BCryptEncrypt(k, (PUCHAR)in.data(), (ULONG)in.size(), &info,
nullptr, 0, (PUCHAR)out.data(), (ULONG)out.size(), &done, 0) == 0;
// 解密时 Tag 不匹配(密文被改/密钥错)会返回 AUTH_TAG_FAILURE,ok=false
out.resize(done);
}
if (k) BCryptDestroyKey(k);
if (alg) BCryptCloseAlgorithmProvider(alg, 0);
return ok;
}
完整可运行的加密 + 心跳线程 + 错误分派示例见 进阶接入流程。
响应签名 防假服务器rsign
每个 /api/v1 响应都附带 rsign 字段,客户端可验证响应确实来自持有你 AppSecret 的真服务器——堵住「改 hosts / DNS 劫持把验证请求指向假服务器、恒返 code:200」这条经典破解路径(该攻击不需要任何密钥)。
| 项 | 约定 |
|---|---|
| 来源 | rsign 由服务端用你的 AppSecret 对本次请求的 nonce 与响应结论计算得出——具体算法封装在官方签名模块的 verify_rsign() 中,直接调用即可 |
| 绑定关系 | 绑定本次请求的 nonce(防重放旧响应)与 code/msg(假服务器恒返 200 无法产出合法签名) |
| 覆盖范围 | AppKey 校验通过后的全部响应(含加密响应的 code/msg 明文部分);鉴权未通过的错误响应不带 rsign |
| 验证策略 | 200 响应必须验签:调用官方 verify_rsign(响应, 请求nonce, 当前签名密钥)——常规请求与设备注册传 AppSecret,设备会话的换取会话传 deviceSecret、会话内业务传 sessionKey(见设备会话);返回 false = 响应来自假服务器,立即中止并提示 |
| 兼容性 | 旧客户端忽略该额外字段,零影响;官方 SDK 将强制验签 |
# verify_rsign() 由官方签名模块提供(获取方式见文末「签名模块」),与 sign() 同模块分发 ok = verify_rsign(resp, nonce, SECRET) # ok = False → 响应来源不可信(假服务器),立即中止,不要使用响应中的任何数据 # 注意:不要自己实现验签算法——以官方模块为准,算法若有升级无需改接入代码
设备会话(会话密钥模式)推荐
为什么需要:常规模式下请求签名密钥(AppSecret)必须随客户端下发——算法与密钥同时存在于客户端,静态分析即可离线还原,且一次泄露、所有安装通用、无法作废。这是固定共享密钥的原理上限,客户端侧任何加固只能抬高成本。设备会话从架构上根治:密钥由服务端签发、与设备绑定、短时有效、可按设备吊销——AppSecret 不再需要随客户端分发。
| 步骤 | 说明 |
|---|---|
① 注册设备 /device/enroll | 凭用户账号密码 + 机器码调用(AppSecret 签名,仅此一步用到);卡模式软件改用 /card/enroll(传 card_key + machine,卡密即身份,未激活的卡在此步完成激活)。服务端为该设备签发独立的 deviceSecret,仅此一次明文下发,请妥善保存。同一用户同一机器码重复注册会换发新密钥(重装场景),并使旧会话失效 |
② 换取会话 /device/session | 传 deviceId(/device/session 或卡模式的 /card/session),公共参数的 sign 改用 deviceSecret 计算(两次签发需间隔 10 秒以上,服务端防刷;正常重启场景天然满足)。服务端返回短时 sessionId + sessionKey(默认 15 分钟,过期前重新调用即可,无感续期)。签发会使该设备旧会话立即失效 |
| ③ 会话内业务请求 | 所有业务请求在公共参数基础上加 sid = sessionId,sign 改用 sessionKey 计算——流程与常规签名完全一致,只是换了密钥。计费/限额与常规模式一致 |
| ④ 过期与吊销 | 会话过期后 401,重新执行②即可;设备被作者在控制台吊销后,会话立即失效且无法再换取,同机重新①也会被拒绝(416)——吊销即黑名单,只有作者在控制台「设备」页「恢复」后才能重新①。作者删除设备记录则视为放行,用户可重新注册。另外每次换取会话时服务端都会复查账号的封禁与到期状态(封禁 416 / 过期 412) |
| 项 | 约定 |
|---|---|
| strict_mode | 作者在控制台软件编辑弹窗开启「设备会话模式」(尊享版权益)后:未携带 sid 的业务请求一律拒绝(400 该软件已启用设备会话模式),仅公开信息、设备/卡密会话引导、安全上报,以及账号生命周期引导接口(注册受「允许注册」控制,充值/自助解绑必须提供账密与卡密)放行;已有账号的登录、心跳、云变量等业务请求必须使用会话密钥——静态提取 AppSecret 无法伪造已有账号业务。卡密软件用 /card/enroll、/card/session 走同样流程(见卡模式) |
| 参数明文约定 | deviceId 与 sid 是明文公共参数(与 appkey/time 同层,参与签名但不进入密文)——服务端需要在解密前用它们定位设备/会话并确定解密密钥。业务参数(账密等)在 AES 软件上仍整体加密 |
| AES 软件的密钥分层 | 开启传输加密的软件:注册设备用控制台「加密密钥」加密;换取会话用 deviceSecret 作为 AES 密钥(响应同密钥加密);会话内业务用 sessionKey。响应加密与 rsign 一律跟随当前请求的签名密钥——换取会话的响应用 deviceSecret 验签/解密(首次换会话前不存在旧会话密钥)。会话建立后 enc_key 与 AppSecret 均不再需要 |
| 响应验签 | rsign跟随请求签名所用的密钥:sid 业务用 sessionKey;/device/session 用 deviceSecret;其余(含 /device/enroll)用 AppSecret。验签规则同响应签名章节 |
| 安全边界 | deviceSecret / sessionKey 仍是客户端内存中的一段数据(非硬件保护),但泄露影响面=单台设备:作者可从「设备」页看到该设备并吊销;sessionKey 更是数分钟内自动作废。AppSecret 在 strict_mode 下不再参与任何业务请求,静态提取它毫无价值 |
| 迁移建议 | 阶段1:客户端同时保留旧签名与新会话两条路径 → 阶段2:默认走会话 → 阶段3:开启 strict_mode 封死旧路径。开启前确保存量客户端已全部升级,否则旧版将无法验证 |
官方签名模块(控制台「接入信息」卡下载)的 sign() / verify_rsign() 对会话签名完全适用:注册用 AppSecret、换会话用 deviceSecret、会话内业务用 sessionKey,验签密钥跟随当前签名密钥——流程不变,只换 key。下方示例以未开启 AES 的软件为例;开启 AES 时按上表密钥分层加密(enroll 用加密密钥、session 用 deviceSecret、业务用 sessionKey),deviceId/sid 保持明文公共参数。
import json, time, uuid, urllib.request, hashlib, platform, getpass
from sign_module import sign, verify_rsign # 官方签名模块:sign/verify_rsign 对会话密钥完全通用,只换 key
APPKEY, APPSECRET = "nkxxxx", "你的AppSecret" # strict_mode 下 APPSECRET 仅用于注册设备
API = "https://app.netccheck.tech/api/v1"
UA = {"User-Agent": "NetcCheck-SDK/1.0"}
def call(path, key, **biz): # 统一请求:签名密钥由调用方决定(AppSecret/deviceSecret/sessionKey)
nonce = uuid.uuid4().hex
body = {"appkey": APPKEY, "time": str(int(time.time())), "nonce": nonce, **biz}
body["sign"] = sign(body, key)
req = urllib.request.Request(API + path, json.dumps(body).encode(),
{"Content-Type": "application/json", **UA})
resp = json.loads(urllib.request.urlopen(req).read())
if not verify_rsign(resp, nonce, key): # 200 响应必须验签(密钥跟随当前签名密钥)
raise SystemExit("响应验签失败(疑似假服务器)")
return resp
def machine_code():
return hashlib.sha256(f"{platform.node()}-{getpass.getuser()}".encode()).hexdigest()[:32]
# ① 注册设备(仅需一次;deviceSecret 建议加密保存,重装后同机注册会换发)
enr = call("/device/enroll", APPSECRET, username="tom", password="123456",
machine=machine_code(), device_name="我的电脑")
DID, DSECRET = enr["data"]["deviceId"], enr["data"]["deviceSecret"]
def fresh_session(): # ② 换取会话(过期前调用即可无感续期)
s = call("/device/session", DSECRET, deviceId=DID)["data"]
return s["sessionId"], s["sessionKey"]
SID, SKEY = fresh_session()
def api(path, **biz): # ③ 会话内业务:签名与验签都用 sessionKey
global SID, SKEY
r = call(path, SKEY, sid=SID, **biz)
if r["code"] == 401: # 会话过期:重签一次再试
SID, SKEY = fresh_session()
r = call(path, SKEY, sid=SID, **biz)
return r
# ⚠ 生产环境若多线程共用会话:刷新必须加锁(single-flight),并注意刷新后旧 SKEY 在途响应的验签
login = api("/user/login", username="tom", password="123456", machine=machine_code())
print("登录成功", login["data"]["expire_at"]) # ④ 作者可在控制台「设备」页随时吊销该设备
import java.net.URI;
import java.net.http.*;
import java.util.*;
/** 设备会话 · Java:call 与基础示例同构,唯一差异是签名密钥改为参数(AppSecret / deviceSecret / sessionKey) */
public class DeviceSession {
static final String APPKEY = "nkxxxx", APPSECRET = "你的AppSecret"; // strict 下 APPSECRET 仅用于注册设备
static final String API = "https://app.netccheck.tech/api/v1";
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String key, String... kv) throws Exception {
Map<String, String> body = new TreeMap<>();
body.put("appkey", APPKEY);
body.put("time", String.valueOf(System.currentTimeMillis() / 1000));
body.put("nonce", UUID.randomUUID().toString().replace("-", ""));
for (int i = 0; i < kv.length; i += 2) body.put(kv[i], kv[i + 1]);
body.put("sign", SignModule.sign(body, key));
StringBuilder json = new StringBuilder("{");
for (var e : body.entrySet()) json.append('"').append(e.getKey()).append("\":\"")
.append(e.getValue().replace("\\", "\\\\").replace("\"", "\\\"")).append("\",");
json.setCharAt(json.length() - 1, '}');
HttpRequest req = HttpRequest.newBuilder(URI.create(API + path))
.header("Content-Type", "application/json")
.header("User-Agent", "NetcCheck-SDK/1.0")
.POST(HttpRequest.BodyPublishers.ofString(json.toString())).build();
String resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
if (!SignModule.verifyRsign(resp, body.get("nonce"), key)) // 密钥跟随当前签名密钥
throw new IllegalStateException("响应验签失败(疑似假服务器)");
return resp;
}
/** 最小提取响应 JSON 顶层字段(生产请用 JSON 库) */
static String jget(String s, String k) {
var m = java.util.regex.Pattern.compile("\"" + k + "\":\"?([^,}\"]+)").matcher(s);
return m.find() ? m.group(1) : "";
}
public static void main(String[] args) throws Exception {
// ① 注册设备(AppSecret 仅此一步;同机重复注册幂等换发新密钥)
String enr = call("/device/enroll", APPSECRET, "username", "tom", "password", "123456",
"machine", "PC-01", "device_name", "我的电脑");
String did = jget(enr, "deviceId"), dsec = jget(enr, "deviceSecret");
// ② 换取短时会话(两次签发需间隔 10 秒以上,服务端防刷)
String s = call("/device/session", dsec, "deviceId", did);
String sid = jget(s, "sessionId"), skey = jget(s, "sessionKey");
// ③ 会话内业务:签名与验签都用 sessionKey;收到 401 重签一次再试(多线程共用需加锁)
System.out.println(call("/user/login", skey, "sid", sid,
"username", "tom", "password", "123456", "machine", "PC-01"));
// ④ 作者可在控制台「设备」页随时吊销该设备
}
}
import java.net.URI
import java.net.http.*
import java.util.*
// 设备会话 · Kotlin:call 与基础示例同构,唯一差异是签名密钥改为参数
val APPKEY = "nkxxxx" // strict 下 APPSECRET 仅用于注册设备
val APPSECRET = "你的AppSecret"
val API = "https://app.netccheck.tech/api/v1"
val HTTP = HttpClient.newHttpClient()
fun call(path: String, key: String, vararg kv: Pair<String, String>): String {
val body = sortedMapOf(
"appkey" to APPKEY,
"time" to (System.currentTimeMillis() / 1000).toString(),
"nonce" to UUID.randomUUID().toString().replace("-", ""),
*kv
)
body["sign"] = SignModule.sign(body, key)
val json = body.entries.joinToString(",", "{", "}") {
"\"${it.key}\":\"${it.value.replace("\\", "\\\\").replace("\"", "\\\"")}\""
}
val req = HttpRequest.newBuilder(URI.create(API + path))
.header("Content-Type", "application/json")
.header("User-Agent", "NetcCheck-SDK/1.0")
.POST(HttpRequest.BodyPublishers.ofString(json)).build()
val resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body()
check(SignModule.verifyRsign(resp, body["nonce"]!!, key)) { "响应验签失败(疑似假服务器)" }
return resp
}
fun jget(s: String, k: String): String = // 最小提取顶层字段(生产请用 JSON 库)
Regex("\"$k\":\"?([^,}\"]+)").find(s)?.groupValues?.get(1) ?: ""
fun main() {
// ① 注册设备(AppSecret 仅此一步;同机重复注册幂等换发新密钥)
val enr = call("/device/enroll", APPSECRET, "username" to "tom", "password" to "123456",
"machine" to "PC-01", "device_name" to "我的电脑")
val did = jget(enr, "deviceId"); val dsec = jget(enr, "deviceSecret")
// ② 换取短时会话(两次签发需间隔 10 秒以上,服务端防刷)
val sess = call("/device/session", dsec, "deviceId" to did)
val sid = jget(sess, "sessionId"); val skey = jget(sess, "sessionKey")
// ③ 会话内业务:签名与验签都用 sessionKey;收到 401 重签一次再试(多线程共用需加锁)
println(call("/user/login", skey, "sid" to sid,
"username" to "tom", "password" to "123456", "machine" to "PC-01"))
// ④ 作者可在控制台「设备」页随时吊销该设备
}
// 设备会话 · C#:Call 与基础示例同构,唯一差异是签名密钥改为参数(AppSecret / deviceSecret / sessionKey)
// Sign / JGet / VerifyRsign / Esc 复制「最小可运行示例 · C#」;JSON 为极简拼接,生产建议 System.Text.Json
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
class DeviceSession {
const string APPKEY = "nkxxxx", APPSECRET = "你的AppSecret"; // strict 下 APPSECRET 仅用于注册设备
const string API = "https://app.netccheck.tech/api/v1";
static readonly HttpClient HTTP = new HttpClient();
static string Call(string path, string key, params string[] kv) {
var body = new SortedDictionary<string, string> {
{ "appkey", APPKEY },
{ "time", ((DateTimeOffset)DateTime.UtcNow).ToUnixTimeSeconds().ToString() },
{ "nonce", Guid.NewGuid().ToString("N") }
};
for (int i = 0; i < kv.Length; i += 2) body[kv[i]] = kv[i + 1];
body["sign"] = Sign(body, key);
string json = "{" + string.Join(",", body.Select(e => "\"" + e.Key + "\":\"" + Esc(e.Value) + "\"")) + "}";
using var resp = HTTP.PostAsync(API + path, new StringContent(json, Encoding.UTF8, "application/json")).Result;
string text = resp.Content.ReadAsStringAsync().Result;
VerifyRsign(text, body["nonce"], key);
return text;
}
public static void Main() {
// ① 注册设备(AppSecret 仅此一步;同机重复注册幂等换发新密钥)
string enr = Call("/device/enroll", APPSECRET, "username", "tom", "password", "123456",
"machine", Environment.MachineName, "device_name", "我的电脑");
string did = JGet(enr, "deviceId"); string dsec = JGet(enr, "deviceSecret");
// ② 换取短时会话(两次签发需间隔 10 秒以上,服务端防刷)
string sess = Call("/device/session", dsec, "deviceId", did);
string sid = JGet(sess, "sessionId"); string skey = JGet(sess, "sessionKey");
// ③ 会话内业务:签名与验签都用 sessionKey;收到 401 重签一次再试(多线程共用需加锁)
Console.WriteLine(Call("/user/login", skey, "sid", sid,
"username", "tom", "password", "123456", "machine", Environment.MachineName));
// ④ 作者可在控制台「设备」页随时吊销该设备
}
}
// 设备会话 · C++:Call 与最小示例同构,唯一差异是签名密钥作为参数传入(AppSecret / deviceSecret / sessionKey)
// 公共代码(Hmac/Sign/Post/JGet/Esc)复制「最小可运行示例 · C++」,编译参数相同
int main() {
// ① 注册设备(AppSecret 仅此一步;同机重复注册幂等换发新密钥)
std::string enr = Call("/device/enroll", {},
"\"username\":\"tom\",\"password\":\"123456\",\"machine\":\"PC-01\",\"device_name\":\"我的电脑\"", SECRET);
std::string did = JGet(enr, "deviceId"), dsec = JGet(enr, "deviceSecret");
// ② 换取短时会话(两次签发需间隔 10 秒以上,服务端防刷)
std::string sess = Call("/device/session", {}, "\"deviceId\":\"" + Esc(did) + "\"", dsec);
std::string sid = JGet(sess, "sessionId"), skey = JGet(sess, "sessionKey");
// ③ 会话内业务:签名与验签都用 sessionKey;收到 401 重签一次再试(多线程共用需加锁)
std::cout << Call("/user/login", { {"sid", sid} },
"\"username\":\"tom\",\"password\":\"123456\",\"machine\":\"PC-01\"", skey) << "\n";
// ④ 作者可在控制台「设备」页随时吊销该设备
return 0;
}
import json, time, uuid, urllib.request
from sign_module import sign, verify_rsign # 与上方同一模块,只换密钥
APPKEY, APPSECRET = "nkxxxx", "你的AppSecret"
CARD_KEY, MACHINE = "NCCK-XXXX-XXXX-XXXX", "CARD-PC-01" # 卡密即身份,无需账号
API = "https://app.netccheck.tech/api/v1"
UA = {"User-Agent": "NetcCheck-SDK/1.0"}
def call(path, key, **biz): # 与上方 call() 完全同构
nonce = uuid.uuid4().hex
body = {"appkey": APPKEY, "time": str(int(time.time())), "nonce": nonce, **biz}
body["sign"] = sign(body, key)
req = urllib.request.Request(API + path, json.dumps(body).encode(),
{"Content-Type": "application/json", **UA})
resp = json.loads(urllib.request.urlopen(req).read())
if not verify_rsign(resp, nonce, key):
raise SystemExit("响应验签失败(疑似假服务器)")
return resp
# ① 注册卡设备(未激活的卡在此步完成激活;同卡同机重跑幂等换发)
enr = call("/card/enroll", APPSECRET, card_key=CARD_KEY, machine=MACHINE)
DID, DSECRET = enr["data"]["deviceId"], enr["data"]["deviceSecret"]
def fresh_session(): # ② 换短时会话(两次签发需间隔 10 秒以上,服务端防刷)
s = call("/card/session", DSECRET, deviceId=DID)["data"]
return s["sessionId"], s["sessionKey"]
SID, SKEY = fresh_session()
def check_card(): # ③ 定期复查:sid 即凭证,免 card_key/machine
global SID, SKEY
r = call("/card/login", SKEY, sid=SID)
if r["code"] == 401: # 会话过期:重签一次再试
SID, SKEY = fresh_session()
r = call("/card/login", SKEY, sid=SID)
return r
st = check_card()
print("卡授权有效,剩余秒数:", st["data"]["remaining"]) # 412=过期/次数用尽 416=封禁/被吊销 → 立即退出
// 与「设备会话 · Java」唯一差异:① 用 /card/enroll(card_key+machine,无需账号);② /card/session 换会话;
// ③ 复查带 sid 免 card_key/machine。call()/jget() 与该节完全一致,此处仅展示流程差异
public static void main(String[] args) throws Exception {
String CARD_KEY = "NCCK-XXXX-XXXX-XXXX", MACHINE = "CARD-PC-01";
// ① 注册卡设备(未激活的卡在此步完成激活;同卡同机重跑幂等换发)
String enr = call("/card/enroll", APPSECRET, "card_key", CARD_KEY,
"machine", MACHINE, "device_name", "卡测试机");
String did = jget(enr, "deviceId"), dsec = jget(enr, "deviceSecret");
// ② 换短时会话;③ 定期复查:sid 即凭证
String sess = call("/card/session", dsec, "deviceId", did);
String sid = jget(sess, "sessionId"), skey = jget(sess, "sessionKey");
String st = call("/card/login", skey, "sid", sid);
System.out.println("剩余秒数: " + jget(st, "remaining")); // 412=过期/次数用尽 416=封禁/被吊销 → 立即退出
}
// 与「设备会话 · Kotlin」唯一差异:① /card/enroll(card_key+machine,无需账号);② /card/session;
// ③ 复查带 sid 免 card_key/machine。call()/jget() 与该节完全一致,此处仅展示流程差异
fun main() {
val cardKey = "NCCK-XXXX-XXXX-XXXX"; val machine = "CARD-PC-01"
// ① 注册卡设备(未激活的卡在此步完成激活;同卡同机重跑幂等换发)
val enr = call("/card/enroll", APPSECRET, "card_key" to cardKey,
"machine" to machine, "device_name" to "卡测试机")
val did = jget(enr, "deviceId"); val dsec = jget(enr, "deviceSecret")
// ② 换短时会话;③ 定期复查:sid 即凭证
val sess = call("/card/session", dsec, "deviceId" to did)
val sid = jget(sess, "sessionId"); val skey = jget(sess, "sessionKey")
val st = call("/card/login", skey, "sid" to sid)
println("剩余秒数: " + jget(st, "remaining")) // 412=过期/次数用尽 416=封禁/被吊销 → 立即退出
}
// 与「设备会话 · C#」唯一差异:① /card/enroll(card_key+machine,无需账号);② /card/session;
// ③ 复查带 sid 免 card_key/machine。Call()/JGet() 与该节完全一致,此处仅展示流程差异
public static void Main() {
const string CARD_KEY = "NCCK-XXXX-XXXX-XXXX", MACHINE = "CARD-PC-01";
// ① 注册卡设备(未激活的卡在此步完成激活;同卡同机重跑幂等换发)
string enr = Call("/card/enroll", APPSECRET, "card_key", CARD_KEY,
"machine", MACHINE, "device_name", "卡测试机");
string did = JGet(enr, "deviceId"); string dsec = JGet(enr, "deviceSecret");
// ② 换短时会话;③ 定期复查:sid 即凭证
string sess = Call("/card/session", dsec, "deviceId", did);
string sid = JGet(sess, "sessionId"); string skey = JGet(sess, "sessionKey");
string st = Call("/card/login", skey, "sid", sid);
Console.WriteLine("剩余秒数: " + JGet(st, "remaining")); // 412=过期/次数用尽 416=封禁/被吊销 → 立即退出
}
// 与「设备会话 · C++」唯一差异:① /card/enroll(card_key+machine,无需账号);② /card/session;
// ③ 复查带 sid 免 card_key/machine。公共代码与该节完全一致,此处仅展示流程差异
int main() {
const char* CARD_KEY = "NCCK-XXXX-XXXX-XXXX"; const char* MACHINE = "CARD-PC-01";
std::string bk = std::string("\"card_key\":\"") + CARD_KEY + "\",\"machine\":\"" + MACHINE + "\",\"device_name\":\"卡测试机\"";
// ① 注册卡设备(未激活的卡在此步完成激活;同卡同机重跑幂等换发)
std::string enr = Call("/card/enroll", {}, bk, SECRET);
std::string did = JGet(enr, "deviceId"), dsec = JGet(enr, "deviceSecret");
// ② 换短时会话;③ 定期复查:sid 即凭证
std::string sess = Call("/card/session", {}, "\"deviceId\":\"" + Esc(did) + "\"", dsec);
std::string sid = JGet(sess, "sessionId"), skey = JGet(sess, "sessionKey");
std::string st = Call("/card/login", { {"sid", sid} }, "", skey);
std::cout << "剩余秒数: " << JGet(st, "remaining") << "\n"; // 412=过期/次数用尽 416=封禁/被吊销 → 立即退出
return 0;
}
套餐说明:设备会话模式(strict_mode)为尊享版权益;未开启 strict_mode 时任何套餐均可按上述流程接入(此时 AppSecret 仍可用于常规签名路径)。已开启 AES 的软件在会话内加密不额外计费,计费口径与常规模式完全一致。
客户端防抓包检测 客户端侧
抓包检测只能在你的客户端软件里做,服务端做不到:HTTPS 的加密端点是用户的设备本机,中间人代理对服务器完全透明。本平台的防护分工是——传输层 HTTPS + 可选 AES 应用层加密(专业版)保证抓到的都是密文;客户端层由你的软件检测异常网络调试环境并在检测到时调用上报接口,让你能看见谁在分析自己的软件。
隐私与合规提示:检测目标只限于「网络调试环境」这一类与抓包直接相关的信号,请在你的软件隐私政策中如实告知用户本功能的存在与目的,不要扩展到其他用途;检测到时优先提醒而非直接拒绝服务,避免误伤正常用户。
| 检测类别 | 上报 type | 说明 |
|---|---|---|
| 系统代理被开启 | proxy | 最常见的抓包前兆;正常用户挂 VPN 可能触发,建议提醒为主 |
| 检测到网络调试工具 | tool | 移动端常见于安装了抓包证书或调试组件的设备;由你的客户端自行实现识别逻辑 |
| 检测到调试器 | debugger | 正在被动态分析;建议弹窗提醒并持续观察 |
| 证书链异常 | cert | HTTPS 校验失败或发现用户安装的可疑证书(Android 用户证书存储) |
| 其他异常 | other | 无法归入以上类别时使用 |
检测到后的三种处理策略(按软件性质选择,不建议一刀切拒绝启动):①静默上报——不打扰用户,调用上报接口记录,你事后在日志里分析;②弹窗提醒——告知用户检测到网络调试环境,功能可能受限;③拒绝启动——仅适用于强对抗场景,注意正常用户挂 VPN 可能误伤。建议默认①+②。具体检测实现由你在客户端内自行完成——本文档只约定上报协议,不提供检测实现细节(避免给绕过者递刀)。
上报接口
| 项 | 约定 |
|---|---|
| 接口 | POST /api/v1/security/report(公共参数与签名/加密同其它接口,无需登录态) |
| 参数 | type 必填(单条上报):proxy / tool / debugger / cert / other;注入器还支持环境风控类型 root / emulator / debug / hook / vpn / tamper。传入属于当前软件的 deployment_id 时,环境类型会额外写入 DAG 风控报表(env_*)。detail 选填 ≤200 字(建议只写类别,不要上报用户敏感信息);token/machine 选填,带上时日志关联到具体用户/设备;items 选填:一次上报多个检测结果时使用——传 JSON 数组文本(≤10 项,每项 {"type": "...", "detail": "..."}),传 items 后单条的 type/detail 不再需要 |
| 限制 | 每 IP 每 5 分钟 10 次;不计入验证次数;响应 data.received=true(多项上报额外返回 count=项数) |
| 日志与报表 | 控制台「日志」页动作筛选「抓包检测」;带 deployment_id 的环境异常同时出现在「风控报表 → 异常设备/事件」中。多项上报合并为一条日志 |
# 适用于:App 内嵌脚本层 / 跨平台框架的 Python 运行时
# call() 定义见「基础接入流程」——time/nonce/sign/验签全部内置,与其它接口完全一致。
# 检测逻辑由你实现(略),此处演示:检测结果 → 组参 → 上报
def report_capture(typ, detail="", token=None, machine=None):
biz = {"type": typ, "detail": detail[:200]} # type: proxy/tool/debugger/cert/other
if token: biz["token"] = token # 可选:关联到具体登录用户
if machine: biz["machine"] = machine # 可选:关联到具体设备
return call("/security/report", **biz)
def report_capture_many(items, token=None, machine=None):
# 一次上报多个检测结果(如 2 个 tool + 1 个 cert):items 为 JSON 数组文本,≤10 项
# items 形如 [{"type": "tool", "detail": "proc capA"},
# {"type": "tool", "detail": "proc capB"},
# {"type": "cert", "detail": "user cert"}]
biz = {"items": json.dumps(items[:10], ensure_ascii=False)}
if token: biz["token"] = token
if machine: biz["machine"] = machine
return call("/security/report", **biz)
# 典型调用:检测到异常网络环境时(策略自选)
# report_capture("proxy", "system proxy on", token=TOKEN, machine=MACHINE)
# report_capture("tool", "debug tool detected")
# report_capture("cert", "untrusted user cert")
# report_capture_many([{"type": "tool"}, {"type": "tool", "detail": "p2"}, {"type": "cert"}])
// Android:复用「基础接入流程」的 call(path, kv...)(time/nonce/sign/验签已内置)
// 检测逻辑由你实现(略)——本 SDK 文档只约定上报协议
public void reportCapture(String type, String detail) {
// path 不带 /api/v1 前缀(call() 内部已拼);type: proxy / tool / debugger / cert / other
System.out.println(call("/security/report",
"type", type,
"detail", detail == null ? "" : detail));
// 需要关联到具体用户/设备时按 kv 续写:
// call("/security/report", "type", type, "detail", "", "token", TOKEN, "machine", MACHINE);
}
// 典型调用(建议在启动与关键操作前各检测一次):
// reportCapture("proxy", "system proxy on");
// reportCapture("cert", "user cert present");
// 多项一次上报(如 2 个 tool + 1 个 cert):items 传 JSON 数组文本(≤10 项):
// call("/security/report", "items",
// "[{\"type\":\"tool\",\"detail\":\"procA\"},{\"type\":\"cert\"}]");
// Android:复用「基础接入流程」的 call(path, vararg kv)(time/nonce/sign/验签已内置)
fun reportCapture(type: String, detail: String = "") {
// path 不带 /api/v1 前缀(call() 内部已拼);type: proxy / tool / debugger / cert / other
println(NetcCheck.call("/security/report",
"type" to type,
"detail" to detail))
// 需要关联到具体用户/设备时按 kv 续写:
// NetcCheck.call("/security/report", "type" to type, "token" to TOKEN, "machine" to MACHINE)
}
// 典型调用(启动时 + 关键操作前各一次):
// reportCapture("proxy", "system proxy on")
// reportCapture("debugger", "attached")
// 多项一次上报(如 2 个 tool + 1 个 cert):items 传 JSON 数组文本(≤10 项):
// NetcCheck.call("/security/report", "items",
// """[{"type":"tool","detail":"procA"},{"type":"cert"}]""")
公共参数
| 参数 | 必填 | 说明 |
|---|---|---|
appkey | 是 | 软件标识,控制台创建软件后获得 |
time | 是 | Unix 时间戳(秒),允许 ±300 秒误差 |
nonce | 是 | 随机字符串,每次请求必须更换(防重放) |
sign | 是 | 请求签名,见上方算法 |
统一响应:{"code":200,"msg":"ok","data":{}},code=200 即成功。业务类错误(41x/412~430)的 msg 面向最终用户,可直接展示;401/403 属于接入配置或环境问题,建议提示「网络验证失败,请联系软件作者」并记录日志,而非把技术信息弹给用户;收到 429 表示触发限流,应稍作退避再重试,勿立即高频重试。
错误码
| code | 含义 | code | 含义 |
|---|---|---|---|
| 200 | 成功 | 412 | 账号已过期 |
| 400 | 参数错误 | 413 | 卡密无效 / 已被使用 / 已作废 |
| 401 | 签名/时间戳/重放/AppKey 错误(统一返回,防枚举) | 414 | 机器码超上限(data 含当前占用 machines 与 max_machines),可引导用户调 /user/unbind 自助解绑;开启「每设备限注一账」的软件,同设备重复注册也返回 414(该设备已注册过账号) |
| 403 | 软件已停用 | 415 | 登录态失效 |
| 404 | 接口路径不存在(检查请求 URL) | 416 | 账号/卡密已封禁 |
| 410 | 账号或密码错误 | 418 | 作者套餐到期(超宽限期) |
| 411 | 账号已存在 | 429 | 请求过于频繁 |
| 430 | 本月验证额度已用尽(data.need_upgrade=true) |
说明:AppKey 不存在与签名错误统一返回 401,无法通过响应差异探测有效 AppKey。
软件信息 POST/app/info
客户端启动时调用,获取公告 / 版本 / 强制更新结论。仅需公共参数;建议附带 ver(客户端当前版本号),服务端直接返回「是否需要更新」的结论,客户端无需自行实现版本比较。
| 参数 | 必填 | 说明 |
|---|---|---|
ver | 否 | 客户端当前版本号(如 1.2.0)。传入后返回 need_update / suggest_update 结论 |
{ "code": 200, "data": {
"name": "示例软件", "version": "2.0.0",
"force_update": false,
"need_update": true, // 传入 ver 时才有意义:true=强制更新(阻断)
"suggest_update": false, // true=有新版但可继续使用(仅提醒)
"min_version": "1.5.0", // 最低可用版本(低于它强制更新,空=未启用)
"update_url": "https://example.com/download",
"changelog": "修复登录崩溃,新增云变量缓存",
"notice": "欢迎使用",
"server_time": 1727700000 } }
更新判定:need_update = force_update(且低于最新版)或 ver < min_version;未达强制但低于最新版时 suggest_update = true。客户端标准处理:need_update=true 弹不可跳过的更新框(附 update_url 与 changelog)并退出;suggest_update=true 弹可跳过的提醒。
版本发布与强制更新
强更能力由三个后台字段驱动(控制台「软件管理」新建/编辑弹窗设置):min_version 最低可用版本、update_url 更新包地址、changelog 更新说明。版本比较在服务端完成(按段数值比较,1.9 < 1.10 < 2.0),客户端只消费结论。
| 场景 | 后台操作 | 客户端表现 |
|---|---|---|
| 常规发布新版(旧版仍可用) | 更新「最新版本号」,可不填 min_version | suggest_update=true,客户端弹可跳过的升级提醒 |
| 修复严重 bug,旧版必须升级 | 更新「最新版本号」并把「最低可用版本」设为新版号 | 低于新版号的客户端 need_update=true,阻断使用 |
| 只封杀个别老版本 | 仅把「最低可用版本」设为门槛(如 1.5.0) | 低于 1.5.0 阻断;其余正常 |
| 所有旧版一律强制(旧行为) | 勾选「强制更新」开关 | 所有低于最新版的客户端阻断 |
发布流程建议:① 更新「最新版本号」+ 填写 changelog(用户在更新弹窗看到改了什么)→ ② 填 update_url(新版下载地址,客户端更新框直达)→ ③ 视严重程度决定是否设置 min_version 或勾选强制更新。老版本客户端不传 ver 时拿不到强更结论(保持旧版行为),建议客户端首次接入即携带 ver。
注册 POST/user/register
| 参数 | 必填 | 说明 |
|---|---|---|
username | 是 | 3~32 位字母/数字/_/@/-;同一软件内不区分大小写查重(已有 tom 则 Tom 不可再注册);admin/root/system 等保留名不可用(返回 400) |
password | 是 | 6~64 位 |
machine | 见说明 | 机器码。作者开启「每设备限注一账」后必传:一台设备只能注册绑定一个账号,重复注册返回 414 该设备已注册过账号;绑定在注册时建立,用户端自助解绑不解除(防一人多号),仅作者后台「重置机器」释放 |
{ "code": 200, "msg": "注册成功", "data": { "username": "tom", "server_time": 1727700000 } }
// 411 = 账号已存在;400 = 关闭注册 / 用户名或密码不合规
重复用户名返回 411。后台可按软件关闭「允许注册」(关闭时返回 400 该软件已关闭注册);登录仍按注册时的原大小写精确匹配。作者设置了「注册赠送天数」时,新注册账号有效期 = 当前时间 + N 天(未设置则默认永久,expire_at=0,首次充值即开通)。开启「每设备限注一账」时注册成功即绑定设备码,登录也请携带同一机器码。
登录 POST/user/login
| 参数 | 必填 | 说明 |
|---|---|---|
username / password | 是 | 账号密码 |
machine | 见说明 | 机器码,传入自动绑定,超上限返回 414。作者开启「登录强制机器码」后必传,未传返回 400 该软件已开启机器码强制绑定 |
{ "code": 200, "data": {
"token": "64位十六进制…",
"expire_at": 1730000000, // 0 = 永久
"machines": ["PC-01"], "max_machines": 1 } }
重复登录顶号下线:同一账号后一次登录会使前一会话的 token 立即失效(其心跳返回 415)。token 有效 24 小时,凭心跳滑动续期。
自助解绑机器码 POST/user/unbind
| 项 | 约定 |
|---|---|
| 场景 | 用户换新设备收到 414(绑定数已满)后,凭账号密码自行解绑一台旧机器,无需联系作者 |
| 参数 | username + password(凭证,与充值同口径);machine 选填 = 要解绑的机器码,不传时解绑最早绑定的一台(典型「换新机」场景) |
| 额度 | 每次消耗一次自助解绑额度(次数由作者按软件设置,默认 3 次,0 = 关闭该功能);用尽/关闭返回 400,作者在后台「重置机器」即恢复并清零计数。密码连续错误与登录/充值共用账号锁定 |
| 效果 | 移除目标机器码绑定,不计费;成功后该账号所有已登录会话立即失效(防已解绑设备残留登录态),重新登录即在新设备完成绑定。响应 data:{ machines, max_machines, unbinds_left, server_time } |
心跳 POST/user/heartbeat
每 10 分钟调用一次,校验登录态/封禁/到期并续期 token。参数:token;返回 data.remaining 剩余秒数(-1 = 永久)。收到 412/415/416 应立即退出并提示。
计费口径(作者月度验证额度):login / register / 卡模式激活与登录验证 各计 1 次;心跳与限时/永久卡的设备复查按 0.1 次计(10 次 = 1 次验证),次数卡的会话内复查不扣卡次也不计费;充值、卡密预检、卡模式解绑与账号自助解绑不计费。开启了 AES 传输加密的软件,登录 / 注册 / 卡激活按 2 倍计(加密请求计算开销更高)。额度按自然月重置,订阅多月期间每月都有完整额度。服务端对高频重复调用有防刷保护(间隔内重复心跳不续期也不计费),按建议间隔调用即可。
卡密充值 POST/user/recharge
参数:username、password、card_key。账号过期后也可充值;未到期在现有到期时间上累加,已过期从当前时间起算。新注册账号默认永久(expire_at=0),首次充值即开通:到期时间设为当前时间 + 卡面天数。
{ "code": 200, "msg": "充值成功,+30 天", "data": {
"username": "tom", "days": 30,
"expire_at": 1730000000, // 充值后的到期时间(0 = 已转永久)
"card_type": "time", "server_time": 1727700000 } }
// 永久卡:msg「充值成功,账号已转为永久」,expire_at=0;次数卡不能充值(413)
卡密类型:限时卡 → 到期时间累加卡面天数;永久卡 → 账号转为永久(expire_at=0);次数卡 → 不能用于账号充值(次数卡仅支持卡模式,返回 413)。响应 data 含 card_type 与充值后的 expire_at。
卡模式(卡密即授权)POST/card/login · /card/reset · /card/check
不需要账号密码:卡密本身就是授权,直接绑定设备。适合「买卡 → 输卡号 → 绑定这台电脑用到期」的发放方式。公共参数与签名/加密与其它接口一致。与设备会话的关系:卡模式同样支持「设备会话模式(strict_mode)」强对抗——用 /card/enroll(传 card_key + machine,卡密即身份,无需账号)注册设备并换取 deviceSecret,再经 /card/session 换短时会话,之后卡业务请求带 sid(签名与验签用 sessionKey),复查时不再需要 card_key/machine。开启 strict_mode 后未带 sid 的卡请求一律拒绝(400)。未开启 strict_mode 时按下方常规流程接入即可,两种方式二选一;套餐说明:卡模式与次数卡为基础版权益,免费版软件的卡密仅可用于账号模式充值(次数卡不能充值,免费版不可生成),激活卡模式会返回 400(提示未开通卡模式);永久卡为专业版权益(免费版/基础版控制台不可生成,已生成的存量卡不受降级影响)。
| 项 | 约定 |
|---|---|
| 参数 | card_key + machine(机器码,与账号模式同做法) |
| 卡密类型 | 限时卡(激活起算 N 天);次数卡(共 N 次验证额度,每次登录验证扣 1 次:激活即第 1 次,之后客户端每次新启动/重新登录再扣,会话内复查不扣,用尽返回 412);永久卡(激活后永久可用,不限时长)。三种卡均绑定首台激活设备 |
| 首次调用 | 激活卡密:绑定当前设备(限时卡同时起算有效期),之后每次调用校验设备与到期/剩余次数;激活与每次登录验证都会下发 card_token(会话凭证,客户端保存在内存) |
| 会话复查 | 定期校验(建议 10 分钟)时带上上次返回的 card_token:属于同一登录会话内的复查——次数卡不扣次数,限时/永久卡按心跳级计费;复查仍校验封禁/到期/余额。token 失效(过期/重启未带/写错)按新的登录验证处理:次数卡扣 1 次并下发新 token,不会报错。会话有效期与账号登录态一致(24 小时滑动续期) |
| 预检(可选) | 激活前可调用 /card/check(只传 card_key)校验卡密有效性与面值:只读,不消耗、不绑定、不计费,返回 { card_type, days | uses_total, server_time }(永久卡无面值字段);无效/已激活/封禁按下方「常见错误」同口径返回。适合在用户输入卡密后先提示「限时卡 30 天」再确认激活 |
| 续期 | 购买新卡密在同一设备再次激活即可:限时卡与账号充值同口径——同设备上未过期的限时卡剩余时长累加到新卡(已过期则从当前时间起算);次数卡扣新卡的次数 |
| 换设备 | 两步:① 在原设备调用 /card/reset(传 card_key + 当前 machine 作凭证)自助解绑,消耗一次额度(次数由作者设置,默认 3 次);② 在新设备调用 /card/login 重新激活(有效期/剩余次数不变)。额度用完或原设备已无法启动 → 联系作者在后台解绑(免费且计数清零) |
| 响应 | data: { card_type, expire_at, remaining, card_token, server_time },remaining 剩余秒数(-1 = 永久/次数卡无到期概念);次数卡额外返回 uses_left(剩余次数);card_token 会话凭证(定期复查时带回)。解绑接口返回 { resets_left, server_time }(剩余解绑次数) |
| 常见错误 | 413 卡密无效或已作废;414 该卡密已绑定其他设备;412 卡密已过期 / 次数已用尽;416 该卡密已被封禁 / 该设备已被作者停用(吊销黑名单);解绑额度用尽 / 设备不匹配 → 400 |
POST /api/v1/card/login // 启动时登录验证(次数卡扣 1 次)+ 定期复查(带 token 不扣)
{ "card_key": "NCCK-XXXX-XXXX-XXXX", "machine": "PC-8F2A-D1",
...公共参数与签名同其它接口 }
→ 限时卡 { "code": 200, "data": { "card_type": "time", "expire_at": 1790000000, "remaining": 2589000, "card_token": "64位会话凭证", "server_time": 1727700000 } }
→ 永久卡 { "code": 200, "data": { "card_type": "forever", "expire_at": 0, "remaining": -1, "card_token": "…", "server_time": 1727700000 } }
→ 次数卡 { "code": 200, "data": { "card_type": "count", "expire_at": 0, "remaining": -1, "uses_left": 98, "card_token": "…", "server_time": 1727700000 } }
// 运行期间的定期复查:带上次返回的 card_token(次数卡不扣次数,会话内免费)
POST /api/v1/card/login
{ "card_key": "NCCK-XXXX-XXXX-XXXX", "machine": "PC-8F2A-D1", "token": "64位会话凭证", ... }
→ { "code": 200, "data": { "card_type": "count", "uses_left": 98, "card_token": "…", ... } }
// 换了新电脑(在原设备还能启动时发起解绑):
POST /api/v1/card/reset
{ "card_key": "NCCK-XXXX-XXXX-XXXX", "machine": "PC-8F2A-D1", ... }
→ { "code": 200, "msg": "解绑成功,请在新设备重新激活(有效期不变)", "data": { "resets_left": 2 } }
// 用户输入卡密后可先预检(只读,不计费):
POST /api/v1/card/check
{ "card_key": "NCCK-XXXX-XXXX-XXXX", ... }
→ 限时卡 { "code": 200, "data": { "card_type": "time", "days": 30, "server_time": 1727700000 } }
→ 无效 { "code": 413, "msg": "卡密无效或已作废" }
// 再在新设备上 /card/login 重新激活即可(到期时间/剩余次数不变)
说明:卡模式与账号模式相互独立(同一张卡只能用于其中一种;次数卡只能用于卡模式);后台「卡密」页可按类型筛选、查看绑定设备与到期/剩余次数,并可一键解绑(免费、计数清零)、封禁/解封(封禁后用户端立即返回 416,有效期/次数不变,解封即恢复);「黑名单」可把指定设备码拉黑——该设备激活/验证此卡一律 416 该设备已被禁止使用此卡密(解绑不清黑名单),适合从验证日志发现共享/泄露设备后精准封设备不废卡。
云变量 POST/var/get · /var/set
需登录态(token)+ key,写入另需 value(≤8192 字符,超出返回 400,不做静默截断)。远程开关、公告、配置下发;把关键数据放服务端,验证不过就拿不到。
重要语义:云变量是软件级全局数据——同一软件的所有登录用户共享同一份键值,读写的都是同一份。var/get 一个不存在的 key 返回 200 且 value 为空字符串,客户端请按空值而非错误码判断。
写权限:客户端 var/set 默认关闭(只读),作者在控制台「软件管理 → 编辑 → 允许客户端写云变量」开启后才可写入。安全建议:关键配置由作者在控制台「变量」页写入,客户端只 var/get;不要把安全决策依赖在客户端可写的变量上。
套餐说明:云变量为基础版权益(专业版可开启客户端写入,尊享版解锁批量导入/克隆/导出);免费版软件调用云变量接口返回 400(提示未开通云变量)。
注入器运营面板:NCCinjector 登录后可直接管理当前软件的云变量,并编辑「受保护关键数据」。后者随 attest 成功下发到 EmbedData,剥离版拿不到;适合放加密种子、接口签名 key 等。客户端环境异常(root/模拟器/调试/Hook/代理/VPN)会进入风控报表的 env_* 事件。
import json
# ① 读配置(最常用):作者在控制台「变量」页写入 key=config_json,客户端启动时读取
cfg = call("/var/get", token=token, key="config_json")["data"]["value"]
# value 为 JSON 文本时解析后使用(也可以存纯文本开关,如 "1")
conf = json.loads(cfg) if cfg else {}
if not conf.get("vip_flag"):
raise SystemExit("未获取到授权配置") # 关键开关拿不到就退出——不要判空继续!
# ② 不存在的 key 返回 200 且 value 为空串:按空值处理,不要当错误
empty = call("/var/get", token=token, key="not_exist")["data"]["value"]
print("空值:", empty == "")
# ③ 写入(默认关闭:作者在「软件管理 → 编辑 → 允许客户端写云变量」开启后才可用,否则 400)
# call("/var/set", token=token, key="last_seen", value=str(int(time.time())))
与其它功能的联合:云变量在设备会话模式下照常使用——业务请求带 sid 即可 var/get(见设备会话);心跳循环里读一次关键开关,可实现「后台一键封功能」:控制台改值 → 所有客户端 10 分钟内生效。
基础接入流程(一般情况)
不开启传输加密的最简接入:从创建软件到充值续费的完整闭环,示例照抄、换上自己的密钥即可运行。
准备密钥
注册后在控制台新建软件,拿到 AppKey / AppSecret(新软件默认关闭注册,测试/账号模式请开启,正式收费软件保持关闭走卡密开通)。
启动检查
客户端启动调 /app/info(附 ver 当前版本)拿公告与强更结论:need_update=true 弹更新框(附 update_url/changelog)并退出;suggest_update=true 仅提醒。见 版本发布与强制更新。
注册 + 登录
/user/register 创建账号,/user/login 带上机器码登录拿 token(顶号旧会话立即失效)。
心跳保活
每 10 分钟调 /user/heartbeat:校验授权、滑动续期 token。收到 412/415/416 立即退出。
读取配置
/var/get 取作者下发的公告 / 开关 / 配置;关键数据拿不到就拒绝启动,不要判空继续。
充值续费
过期账号也可 /user/recharge 用卡密续费,时长自动在现有到期时间上累加。
一整套示例代码(可运行)
覆盖上面全部 6 步。零依赖写法:Java/Kotlin 用 JDK 自带 HttpClient 与加密库;JSON 做了极简组装/提取(生产环境建议引入 JSON 库)。
import json, time, uuid, urllib.request
from sign_module import sign, verify_rsign # 官方签名模块(获取方式见文末「签名模块」)
APPKEY, SECRET = "nkxxxx", "你的AppSecret" # 控制台创建软件后获得
API = "https://app.netccheck.tech/api/v1"
UA = {"User-Agent": "NetcCheck-SDK/1.0"} # 必设:脚本默认 UA 会被 Cloudflare 拦截
def call(path: str, **biz) -> dict:
nonce = uuid.uuid4().hex
body = {"appkey": APPKEY, "time": str(int(time.time())), "nonce": nonce, **biz}
body["sign"] = sign(body, SECRET) # 签名实现以官方模块为准
req = urllib.request.Request(API + path, json.dumps(body).encode(),
{"Content-Type": "application/json", **UA})
resp = json.loads(urllib.request.urlopen(req).read())
if not verify_rsign(resp, nonce, SECRET): # 200 响应必须验签:失败=疑似假服务器
raise SystemExit("响应验签失败(疑似假服务器)")
return resp
# —— 一整套流程:信息 → 注册 → 登录 → 心跳 → 云变量 → 充值 ——
info = call("/app/info", ver="1.0.0") # 1. 公告 / 版本 / 强更结论
if info["data"]["need_update"]:
raise SystemExit("发现新版本 " + info["data"]["version"] + ",请到 " + (info["data"]["update_url"] or "官方渠道") + " 更新")
print(call("/user/register", username="tom", password="123456")) # 2. 注册(已存在返回 411)
login = call("/user/login", username="tom", password="123456",
machine="PC-01") # 3. 登录 + 机器码绑定
token = login["data"]["token"]
print("登录成功,到期时间戳:", login["data"]["expire_at"]) # 0 = 永久
print(call("/user/heartbeat", token=token)) # 4. 心跳(建议每 10 分钟)
print(call("/var/get", token=token, key="notice")) # 5. 读云变量(公告/开关)
print(call("/user/recharge", username="tom", password="123456",
card_key="NCCK-XXXX-XXXX-XXXX")) # 6. 卡密充值(可选)
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
/** NetcCheck 基础接入(零依赖示例;生产环境建议引入 Jackson/Gson 处理 JSON) */
public class NetcCheck {
static final String APPKEY = "nkxxxx", SECRET = "你的AppSecret";
static final String API = "https://app.netccheck.tech/api/v1";
static final HttpClient HTTP = HttpClient.newHttpClient();
// SignModule.sign / verifyRsign 来自官方签名模块(获取方式见文末「签名模块」)
/** 发起请求:kv 为业务参数键值对(成对传入),返回原始 JSON 文本 */
static String call(String path, String... kv) throws Exception {
Map<String, String> body = new TreeMap<>();
body.put("appkey", APPKEY);
body.put("time", String.valueOf(System.currentTimeMillis() / 1000));
body.put("nonce", UUID.randomUUID().toString().replace("-", ""));
for (int i = 0; i < kv.length; i += 2) body.put(kv[i], kv[i + 1]);
body.put("sign", SignModule.sign(body, SECRET));
StringBuilder json = new StringBuilder("{");
for (var e : body.entrySet()) json.append('"').append(e.getKey()).append("\":\"")
.append(e.getValue().replace("\\", "\\\\").replace("\"", "\\\"")).append("\",");
json.setCharAt(json.length() - 1, '}');
HttpRequest req = HttpRequest.newBuilder(URI.create(API + path))
.header("Content-Type", "application/json")
.header("User-Agent", "NetcCheck-SDK/1.0") // 必设:默认 UA 会被 CF 拦截
.POST(HttpRequest.BodyPublishers.ofString(json.toString())).build();
String resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
if (!SignModule.verifyRsign(resp, body.get("nonce"), SECRET)) // 200 必须验签:假服务器立即中止
throw new IllegalStateException("响应验签失败(疑似假服务器)");
return resp;
}
public static void main(String[] args) throws Exception {
System.out.println(call("/app/info", "ver", "1.0.0")); // 1. 公告/版本/强更结论
System.out.println(call("/user/register", "username", "tom", "password", "123456"));
String login = call("/user/login", "username", "tom", // 3. 登录+机器码
"password", "123456", "machine", "PC-01");
System.out.println(login);
String token = login.contains("\"token\"") // 生产请用 JSON 库
? login.split("\"token\":\"")[1].split("\"")[0] : "";
System.out.println(call("/user/heartbeat", "token", token)); // 4. 心跳
System.out.println(call("/var/get", "token", token, "key", "notice")); // 5. 云变量
System.out.println(call("/user/recharge", "username", "tom", // 6. 充值
"password", "123456", "card_key", "NCCK-XXXX-XXXX-XXXX"));
}
}
import java.net.URI
import java.net.http.*
import java.nio.charset.StandardCharsets
import java.util.*
// NetcCheck 基础接入(零依赖示例;生产环境建议用 kotlinx.serialization 处理 JSON)
object NetcCheck {
const val APPKEY = "nkxxxx"
const val SECRET = "你的AppSecret"
const val API = "https://app.netccheck.tech/api/v1"
val HTTP = HttpClient.newHttpClient()
// SignModule.sign / verifyRsign 来自官方签名模块(获取方式见文末「签名模块」)
fun call(path: String, vararg kv: Pair<String, String>): String {
val body = sortedMapOf(
"appkey" to APPKEY,
"time" to (System.currentTimeMillis() / 1000).toString(),
"nonce" to UUID.randomUUID().toString().replace("-", ""),
*kv
)
body["sign"] = SignModule.sign(body, SECRET)
val json = body.entries.joinToString(",", "{", "}") {
"\"${it.key}\":\"${it.value.replace("\\", "\\\\").replace("\"", "\\\"")}\""
}
val req = HttpRequest.newBuilder(URI.create(API + path))
.header("Content-Type", "application/json")
.header("User-Agent", "NetcCheck-SDK/1.0") // 必设:默认 UA 会被 CF 拦截
.POST(HttpRequest.BodyPublishers.ofString(json)).build()
val resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body()
check(SignModule.verifyRsign(resp, body["nonce"]!!, SECRET)) { "响应验签失败(疑似假服务器)" } // 200 必须验签
return resp
}
}
fun main() {
println(NetcCheck.call("/app/info", "ver" to "1.0.0")) // 1. 公告/版本/强更结论
println(NetcCheck.call("/user/register", "username" to "tom", "password" to "123456"))
val login = NetcCheck.call("/user/login", "username" to "tom", // 3. 登录+机器码
"password" to "123456", "machine" to "PC-01")
println(login)
val token = Regex("\"token\":\"([0-9a-f]{64})\"").find(login) // 生产请用 JSON 库
?.groupValues?.get(1) ?: ""
println(NetcCheck.call("/user/heartbeat", "token" to token)) // 4. 心跳
println(NetcCheck.call("/var/get", "token" to token, "key" to "notice")) // 5. 云变量
println(NetcCheck.call("/user/recharge", "username" to "tom", // 6. 充值
"password" to "123456", "card_key" to "NCCK-XXXX-XXXX-XXXX"))
}
// NetcCheck 基础接入(零第三方依赖;JSON 为极简拼接,生产环境建议 System.Text.Json)
// QuickStart 的 Sign / JGet / VerifyRsign / Call 与「最小可运行示例 · C#」完全一致,此处复用
using System;
class NetcCheck {
// ……复制 QuickStart 的 APPKEY/SECRET/Sign/JGet/VerifyRsign/Esc/Call 定义……
public static void Main() {
Console.WriteLine(Call("/app/info", "ver", "1.0.0")); // 1. 公告/版本/强更结论
Console.WriteLine(Call("/user/register", "username", "tom", "password", "123456")); // 2. 注册(已存在 411)
string login = Call("/user/login", "username", "tom", // 3. 登录+机器码
"password", "123456", "machine", "PC-01");
Console.WriteLine(login);
string token = JGet(login, "token"); // 生产请用 JSON 库
Console.WriteLine(Call("/user/heartbeat", "token", token)); // 4. 心跳
Console.WriteLine(Call("/var/get", "token", token, "key", "notice")); // 5. 云变量
Console.WriteLine(Call("/user/recharge", "username", "tom", // 6. 充值
"password", "123456", "card_key", "NCCK-XXXX-XXXX-XXXX"));
}
}
// NetcCheck 基础接入:Call/Sign/Hmac/Post/JGet 与「最小可运行示例 · C++」完全一致,此处复用
// 编译:cl /EHsc /utf-8 basic.cpp winhttp.lib bcrypt.lib 或 g++ basic.cpp -lwinhttp -lbcrypt -std=c++17
// ……复制最小示例的全部公共代码……
int main() {
std::cout << Call("/app/info", {}, "\"ver\":\"1.0.0\"", SECRET) << "\n"; // 1. 公告/版本/强更结论
std::cout << Call("/user/register", {}, "\"username\":\"tom\",\"password\":\"123456\"", SECRET) << "\n"; // 2. 注册(已存在 411)
std::string login = Call("/user/login", {}, "\"username\":\"tom\",\"password\":\"123456\",\"machine\":\"PC-01\"", SECRET); // 3. 登录+机器码
std::cout << login << "\n";
std::string token = JGet(login, "token");
std::cout << Call("/user/heartbeat", {}, "\"token\":\"" + Esc(token) + "\"", SECRET) << "\n"; // 4. 心跳
std::cout << Call("/var/get", {}, "\"token\":\"" + Esc(token) + "\",\"key\":\"notice\"", SECRET) << "\n"; // 5. 云变量
std::cout << Call("/user/recharge", {}, "\"username\":\"tom\",\"password\":\"123456\",\"card_key\":\"NCCK-XXXX-XXXX-XXXX\"", SECRET) << "\n"; // 6. 充值
return 0;
}
进阶接入流程
面向正式收费软件的完整姿势:传输加密 + 机器码 + 心跳守护线程 + 错误分派 + 密钥保护。
全链路加密
软件开启「AES 传输加密」后所有业务接口走 enc=1,响应 data 同样密文下发——抓包只能看到随机字节。
机器码采集
主机名 + 用户名(+ 硬件序列号)做哈希上传,配合「Token 绑定 IP」双开关防合租共享。
心跳守护线程
后台线程定时心跳,按错误码分派退出:412 过期 / 415 顶号 / 416 封禁 / 418 套餐到期 / 430 额度用尽。
网络宽限
连续 2 次网络异常内只重试不退出,避免误杀正常用户;业务性失败(4xx)不宽限。
服务端下发关键数据
核心功能数据放云变量 / 接口返回,验证不过就拿不到——而不是客户端判空绕过。
密钥保护
AppSecret / 加密密钥不要明文硬编码:拆分或异或存储、运行时重组,配合代码混淆/加壳;日志与报错信息永远不打印密钥。
一整套进阶示例代码(可运行)
在基础版之上增加:AES-256-GCM 加密封装(enc/iv/data 参与签名、响应密文解密)、机器码采集、心跳守护线程与 412/415/416/418/430 错误分派、网络宽限策略、关键配置校验。
import base64, hashlib, json, os, platform, getpass, threading, time, uuid, urllib.request
from cryptography.hazmat.primitives.ciphers.aead import AESGCM # pip install cryptography
from sign_module import sign, verify_rsign # 官方签名模块(获取方式见文末「签名模块」)
APPKEY, SECRET = "nkxxxx", "你的AppSecret"
ENC_KEY = "" # 控制台「加密密钥」64 位 hex;未开加密留空
API = "https://app.netccheck.tech/api/v1"
UA = {"User-Agent": "NetcCheck-SDK/1.0"}
def machine_code() -> str:
"""主机名 + 用户名做哈希(推荐再混入主板/CPU 序列号,换机由作者后台重置绑定)"""
raw = f"{platform.node()}-{getpass.getuser()}"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
def encrypt(biz: dict) -> dict:
iv = os.urandom(12)
ct = AESGCM(bytes.fromhex(ENC_KEY)).encrypt(iv, json.dumps(biz).encode(), None)
return {"enc": 1, "iv": base64.b64encode(iv).decode(), "data": base64.b64encode(ct).decode()}
def call(path: str, **biz) -> dict:
nonce = uuid.uuid4().hex
body = {"appkey": APPKEY, "time": str(int(time.time())), "nonce": nonce}
body.update(encrypt(biz) if ENC_KEY else biz) # 开启加密:业务参数整体进密文
body["sign"] = sign(body, SECRET) # enc/iv/data 三个字段同样参与签名
req = urllib.request.Request(API + path, json.dumps(body).encode(),
{"Content-Type": "application/json", **UA})
resp = json.loads(urllib.request.urlopen(req).read())
if not verify_rsign(resp, nonce, SECRET): # 200 必须验签(rsign 在 code/msg 明文上,先验再解密)
raise SystemExit("响应验签失败(疑似假服务器)")
if "enc" in resp: # 响应业务数据解密
resp["data"] = json.loads(AESGCM(bytes.fromhex(ENC_KEY)).decrypt(
base64.b64decode(resp["enc"]["iv"]), base64.b64decode(resp["enc"]["data"]), None))
return resp
ON_EXIT = {410: "账号或密码错误", 412: "账号已过期,请续费", 414: "机器码数量已达上限",
415: "登录态失效(可能被顶号),请重新登录", 416: "账号已封禁",
418: "软件授权已到期", 429: "请求过于频繁", 430: "本月验证额度已用尽"}
def heartbeat_loop(token: str):
"""守护线程:每 10 分钟心跳;网络抖动宽限 2 次,随后终止整个业务进程。
⚠ 宽限耗尽必须真正退出业务(exitProcess / SystemExit)——只结束心跳线程
等于验证停止而业务继续运行,是可被断网利用的绕过漏洞。"""
fails = 0
while True:
try:
r = call("/user/heartbeat", token=token)
fails = 0
except Exception:
fails += 1
if fails >= 3:
raise SystemExit(1) # 网络彻底不可达:终止业务,而非只退出线程
time.sleep(30); continue # 宽限:抖动重试
if r["code"] != 200:
print("验证失败退出:", ON_EXIT.get(r["code"], r["msg"])); raise SystemExit(1)
time.sleep(600)
# —— 启动流程 ——
login = call("/user/login", username="tom", password="123456", machine=machine_code())
if login["code"] != 200:
print(ON_EXIT.get(login["code"], login["msg"])); raise SystemExit(1)
token = login["data"]["token"]
cfg = call("/var/get", token=token, key="license_flag")["data"]["value"]
if not cfg: # 关键数据拿不到就拒绝启动(不要判空继续!)
print("未获取到授权配置"); raise SystemExit(1)
threading.Thread(target=heartbeat_loop, args=(token,), daemon=True).start()
print("验证通过,业务逻辑运行中…") # ……你的业务代码
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.*;
import java.util.regex.Pattern;
import javax.crypto.Cipher;
import javax.crypto.Mac;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/** NetcCheck 进阶接入:传输加密 + 机器码 + 心跳守护线程 + 错误分派(零依赖示例,JSON 用正则简析) */
public class NetcCheckPro {
static final String APPKEY = "nkxxxx", SECRET = "你的AppSecret";
static final String ENC_KEY_HEX = ""; // 控制台 64 位 hex 加密密钥,未开加密留空
static final byte[] ENC_KEY = hexToBytes(ENC_KEY_HEX);
static final String API = "https://app.netccheck.tech/api/v1";
static final HttpClient HTTP = HttpClient.newHttpClient();
static volatile String token = "";
static byte[] hexToBytes(String hex) {
byte[] out = new byte[hex.length() / 2];
for (int i = 0; i < out.length; i++)
out[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
return out;
}
/** 机器码:主机名 + 用户名 SHA-256(推荐再混入硬件序列号,换机由作者后台重置) */
static String machineCode() throws Exception {
String raw = java.net.InetAddress.getLocalHost().getHostName() + "-" + System.getProperty("user.name");
byte[] h = MessageDigest.getInstance("SHA-256").digest(raw.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : Arrays.copyOf(h, 16)) sb.append(String.format("%02x", b));
return sb.toString();
}
// SignModule.sign / verifyRsign 来自官方签名模块(获取方式见文末「签名模块」)
// 调用处:body.put("sign", SignModule.sign(body, SECRET));
/** AES-256-GCM:返回 {iv, data},data = base64(密文+Tag) */
static String[] encrypt(String json) throws Exception {
byte[] iv = new byte[12];
new SecureRandom().nextBytes(iv);
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(ENC_KEY, "AES"), new GCMParameterSpec(128, iv));
return new String[]{ Base64.getEncoder().encodeToString(iv),
Base64.getEncoder().encodeToString(c.doFinal(json.getBytes(StandardCharsets.UTF_8))) };
}
static String call(String path, String... kv) throws Exception {
TreeMap<String, String> body = new TreeMap<>();
body.put("appkey", APPKEY);
body.put("time", String.valueOf(System.currentTimeMillis() / 1000));
body.put("nonce", UUID.randomUUID().toString().replace("-", ""));
for (int i = 0; i < kv.length; i += 2) body.put(kv[i], kv[i + 1]);
if (ENC_KEY.length > 0) { // 开启加密:业务参数整体进密文(appkey 除外)
StringBuilder biz = new StringBuilder("{");
for (int i = 0; i < kv.length; i += 2)
biz.append('"').append(kv[i]).append("\":\"")
.append(kv[i + 1].replace("\\", "\\\\").replace("\"", "\\\"")).append("\",");
if (kv.length > 0) biz.setCharAt(biz.length() - 1, '}'); else biz.append('}');
String[] enc = encrypt(biz.toString());
body.put("enc", "1"); body.put("iv", enc[0]); body.put("data", enc[1]);
}
body.put("sign", SignModule.sign(body, SECRET)); // enc/iv/data 同样参与签名
StringBuilder json = new StringBuilder("{");
for (var e : body.entrySet()) json.append('"').append(e.getKey()).append("\":\"")
.append(e.getValue().replace("\\", "\\\\").replace("\"", "\\\"")).append("\",");
json.setCharAt(json.length() - 1, '}');
HttpRequest req = HttpRequest.newBuilder(URI.create(API + path))
.header("Content-Type", "application/json")
.header("User-Agent", "NetcCheck-SDK/1.0")
.POST(HttpRequest.BodyPublishers.ofString(json.toString())).build();
String resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
if (!SignModule.verifyRsign(resp, body.get("nonce"), SECRET)) // 200 必须验签(rsign 在明文上,先验再解密)
throw new IllegalStateException("响应验签失败(疑似假服务器)");
return resp;
}
static int codeOf(String resp) { // 生产环境请用 JSON 库解析
var m = Pattern.compile("\"code\"\\s*:\\s*(\\d+)").matcher(resp);
return m.find() ? Integer.parseInt(m.group(1)) : -1;
}
/** 错误分派:业务性失败给出明确文案后退出(网络宽限只用于异常,不用于业务失败) */
static void exitWith(int code) {
String msg = switch (code) {
case 410 -> "账号或密码错误"; case 412 -> "账号已过期,请续费";
case 414 -> "机器码数量已达上限"; case 415 -> "登录态失效(可能被顶号)";
case 416 -> "账号已封禁"; case 418 -> "软件授权已到期";
case 429 -> "请求过于频繁"; case 430 -> "本月验证额度已用尽";
default -> "验证失败(" + code + ")";
};
System.out.println(msg);
System.exit(1);
}
public static void main(String[] args) throws Exception {
String login = call("/user/login", "username", "tom",
"password", "123456", "machine", machineCode());
int code = codeOf(login);
if (code != 200) { exitWith(code); return; }
token = login.split("\"token\":\"")[1].split("\"")[0];
Thread hb = new Thread(() -> { // 心跳守护线程:每 10 分钟
int fails = 0;
while (true) {
try {
String r = call("/user/heartbeat", "token", token);
int c = codeOf(r);
if (c != 200) { exitWith(c); return; }
fails = 0;
Thread.sleep(600_000);
} catch (InterruptedException e) { return; }
catch (Exception e) { if (++fails >= 3) exitWith(-1); } // 网络彻底不可达:必须终止业务,而非只退出线程
}
});
hb.setDaemon(true);
hb.start();
System.out.println("验证通过,业务逻辑运行中…"); // ……你的业务代码
Thread.sleep(Long.MAX_VALUE);
}
}
import java.net.URI
import java.net.http.*
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.*
import java.util.regex.Pattern
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.concurrent.thread
// NetcCheck 进阶接入:传输加密 + 机器码 + 心跳守护线程 + 错误分派(零依赖示例,JSON 用正则简析)
object NetcCheckPro {
const val APPKEY = "nkxxxx"
const val SECRET = "你的AppSecret"
const val ENC_KEY_HEX = "" // 控制台 64 位 hex 加密密钥,未开加密留空
val ENC_KEY = hexToBytes(ENC_KEY_HEX)
const val API = "https://app.netccheck.tech/api/v1"
val HTTP = HttpClient.newHttpClient()
@Volatile var token = ""
fun hexToBytes(hex: String): ByteArray {
val out = ByteArray(hex.length / 2)
for (i in out.indices) out[i] = hex.substring(i * 2, i * 2 + 2).toInt(16).toByte()
return out
}
/** 机器码:主机名 + 用户名 SHA-256(推荐再混入硬件序列号) */
fun machineCode(): String {
val raw = java.net.InetAddress.getLocalHost().hostName + "-" + System.getProperty("user.name")
return MessageDigest.getInstance("SHA-256").digest(raw.toByteArray())
.take(16).joinToString("") { "%02x".format(it) }
}
// SignModule.sign / verifyRsign 来自官方签名模块(获取方式见文末「签名模块」)
// 调用处:body["sign"] = SignModule.sign(body, SECRET)
/** AES-256-GCM:返回 (iv, data),data = base64(密文+Tag) */
fun encrypt(json: String): Pair<String, String> {
val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
val c = Cipher.getInstance("AES/GCM/NoPadding")
c.init(Cipher.ENCRYPT_MODE, SecretKeySpec(ENC_KEY, "AES"), GCMParameterSpec(128, iv))
return Base64.getEncoder().encodeToString(iv) to
Base64.getEncoder().encodeToString(c.doFinal(json.toByteArray(StandardCharsets.UTF_8)))
}
fun call(path: String, vararg kv: Pair<String, String>): String {
val body = sortedMapOf(
"appkey" to APPKEY,
"time" to (System.currentTimeMillis() / 1000).toString(),
"nonce" to UUID.randomUUID().toString().replace("-", ""),
*kv
)
if (ENC_KEY.isNotEmpty()) { // 开启加密:业务参数整体进密文(appkey 除外)
val biz = kv.joinToString(",", "{", "}") {
"\"${it.first}\":\"${it.second.replace("\\", "\\\\").replace("\"", "\\\"")}\""
}
val (iv, data) = encrypt(biz)
body["enc"] = "1"; body["iv"] = iv; body["data"] = data
}
body["sign"] = SignModule.sign(body, SECRET) // enc/iv/data 同样参与签名
val json = body.entries.joinToString(",", "{", "}") {
"\"${it.key}\":\"${it.value.replace("\\", "\\\\").replace("\"", "\\\"")}\""
}
val req = HttpRequest.newBuilder(URI.create(API + path))
.header("Content-Type", "application/json")
.header("User-Agent", "NetcCheck-SDK/1.0")
.POST(HttpRequest.BodyPublishers.ofString(json)).build()
val resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body()
check(SignModule.verifyRsign(resp, body["nonce"]!!, SECRET)) { "响应验签失败(疑似假服务器)" } // 200 必须验签(先验再解密)
return resp
}
fun codeOf(resp: String): Int = // 生产环境请用 JSON 库解析
Pattern.compile("\"code\"\\s*:\\s*(\\d+)").matcher(resp).takeIf { it.find() }
?.let { it.group(1).toInt() } ?: -1
fun exitWith(code: Int) { // 错误分派:明确文案后退出
val msg = when (code) {
410 -> "账号或密码错误"
412 -> "账号已过期,请续费"
414 -> "机器码数量已达上限"
415 -> "登录态失效(可能被顶号)"
416 -> "账号已封禁"
418 -> "软件授权已到期"
429 -> "请求过于频繁"
430 -> "本月验证额度已用尽"
else -> "验证失败($code)"
}
println(msg)
kotlin.system.exitProcess(1)
}
}
fun main() {
val login = NetcCheckPro.call("/user/login", "username" to "tom",
"password" to "123456", "machine" to NetcCheckPro.machineCode())
if (NetcCheckPro.codeOf(login) != 200) NetcCheckPro.exitWith(NetcCheckPro.codeOf(login))
NetcCheckPro.token = Regex("\"token\":\"([0-9a-f]{64})\"").find(login)!!.groupValues[1]
thread(isDaemon = true) { // 心跳守护线程:每 10 分钟
var fails = 0
while (true) {
try {
val r = NetcCheckPro.call("/user/heartbeat", "token" to NetcCheckPro.token)
val c = NetcCheckPro.codeOf(r)
if (c != 200) NetcCheckPro.exitWith(c)
fails = 0
Thread.sleep(600_000)
} catch (e: InterruptedException) { return@thread }
catch (e: Exception) { if (++fails >= 3) NetcCheckPro.exitWith(-1) } // 网络彻底不可达:必须终止业务,而非只退出线程
}
}
println("验证通过,业务逻辑运行中…") // ……你的业务代码
Thread.sleep(Long.MAX_VALUE)
}
// NetcCheck 进阶接入:AES-256-GCM 传输加密 + 心跳后台线程 + 错误分派(.NET 6+,零第三方依赖)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
class NetcCheckPro {
const string APPKEY = "nkxxxx", SECRET = "你的AppSecret";
const string ENC_KEY = ""; // 控制台 64 位 hex 加密密钥,未开加密留空
const string API = "https://app.netccheck.tech/api/v1";
static readonly HttpClient HTTP = new HttpClient();
// ……复制 QuickStart 的 Sign / JGet / VerifyRsign / Esc / Call(含 key 版)……
// AES-256-GCM:data = base64(密文+16字节Tag)
static Tuple<string, string> Encrypt(string json) {
byte[] key = Convert.FromHexString(ENC_KEY), iv = new byte[12];
RandomNumberGenerator.Fill(iv);
byte[] pt = Encoding.UTF8.GetBytes(json), ct = new byte[pt.Length], tag = new byte[16];
using var g = new AesGcm(key, 16);
g.Encrypt(iv, pt, ct, tag);
return Tuple.Create(Convert.ToBase64String(iv), Convert.ToBase64String(ct.Concat(tag).ToArray()));
}
// 把响应中的 "enc":{...} 原位替换为解密后的 "data":{...}
static string ReplaceEnc(string text, string inner) {
int s = text.IndexOf("\"enc\":{", StringComparison.Ordinal);
int depth = 0, e = s;
for (; e < text.Length; e++) {
if (text[e] == '{') depth++;
else if (text[e] == '}') { if (--depth == 0) { e++; break; } }
}
return text.Substring(0, s) + "\"data\":" + inner + text.Substring(e);
}
static string Call(string path, params string[] kv) {
var body = new SortedDictionary<string, string> {
{ "appkey", APPKEY },
{ "time", ((DateTimeOffset)DateTime.UtcNow).ToUnixTimeSeconds().ToString() },
{ "nonce", Guid.NewGuid().ToString("N") }
};
if (ENC_KEY.Length > 0) { // 开启加密:业务参数整体进密文(enc/iv/data 同样参与签名)
string biz = "{" + string.Join(",", Enumerable.Range(0, kv.Length / 2)
.Select(i => "\"" + kv[2 * i] + "\":\"" + Esc(kv[2 * i + 1]) + "\"")) + "}";
var enc = Encrypt(biz);
body["enc"] = "1"; body["iv"] = enc.Item1; body["data"] = enc.Item2;
} else {
for (int i = 0; i < kv.Length; i += 2) body[kv[i]] = kv[i + 1];
}
body["sign"] = Sign(body, SECRET);
string json = "{" + string.Join(",", body.Select(x => "\"" + x.Key + "\":\"" + Esc(x.Value) + "\"")) + "}";
using var resp = HTTP.PostAsync(API + path, new StringContent(json, Encoding.UTF8, "application/json")).Result;
string text = resp.Content.ReadAsStringAsync().Result;
VerifyRsign(text, body["nonce"], SECRET); // rsign 在 code/msg 明文上:先验签,再解密
if (ENC_KEY.Length > 0 && text.Contains("\"enc\":")) {
byte[] all = Convert.FromBase64String(JGet(text, "data"));
byte[] iv = Convert.FromBase64String(JGet(text, "iv"));
byte[] ct = all.Take(all.Length - 16).ToArray(), tag = all.Skip(all.Length - 16).ToArray();
byte[] pt = new byte[ct.Length];
using var g = new AesGcm(Convert.FromHexString(ENC_KEY), 16);
g.Decrypt(iv, ct, tag, pt);
text = ReplaceEnc(text, Encoding.UTF8.GetString(pt));
}
return text;
}
static void ExitWith(int code) { // 错误分派:业务性失败给出明确文案后退出
string msg = code switch {
410 => "账号或密码错误", 412 => "账号已过期,请续费", 414 => "机器码数量已达上限",
415 => "登录态失效(可能被顶号)", 416 => "账号已封禁", 418 => "软件授权已到期",
429 => "请求过于频繁", 430 => "本月验证额度已用尽", _ => "验证失败(" + code + ")"
};
Console.WriteLine(msg);
Environment.Exit(1);
}
static void HeartbeatLoop(string token) { // 心跳后台线程:每 10 分钟
int fails = 0;
while (true) {
try {
string r = Call("/user/heartbeat", "token", token);
int c = int.Parse(JGet(r, "code"));
if (c != 200) ExitWith(c);
fails = 0;
Thread.Sleep(600000);
} catch (ThreadInterruptedException) { return; }
catch (Exception) { if (++fails >= 3) ExitWith(-1); } // 网络彻底不可达:必须终止业务,而非只退出线程
}
}
public static void Main() {
string login = Call("/user/login", "username", "tom",
"password", "123456", "machine", Environment.MachineName);
int code = int.Parse(JGet(login, "code"));
if (code != 200) ExitWith(code);
string token = JGet(login, "token");
// 关键数据放服务端:拿不到就拒绝启动(不要判空继续!)
string cfg = JGet(Call("/var/get", "token", token, "key", "license_flag"), "value");
if (cfg.Length == 0) { Console.WriteLine("未获取到授权配置"); Environment.Exit(1); }
var hb = new Thread(() => HeartbeatLoop(token)) { IsBackground = true };
hb.Start();
Console.WriteLine("验证通过,业务逻辑运行中…"); // ……你的业务代码
Thread.Sleep(Timeout.Infinite);
}
}
// NetcCheck 进阶接入:AES-256-GCM 传输加密 + 心跳后台线程 + 错误分派(Windows,零第三方依赖)
// 在最小示例公共代码基础上,追加 AesGcm/B64/UnB64/HexToRaw(见「加解密实现 · C++」)与以下内容
#include <thread>
#include <vector>
static const std::string ENC_KEY = ""; // 控制台 64 位 hex 加密密钥,未开加密留空
static std::string B64(const unsigned char* d, int n); // 见「加解密实现 · C++」
static std::vector<unsigned char> UnB64(const std::string& s);
static std::string HexToRaw(const std::string& hex);
static bool AesGcm(const std::string& key32, const unsigned char iv[12],
const std::string& in, std::string& out, unsigned char tag[16], bool decrypt);
static void ExitWith(int code) { // 错误分派:业务性失败给出明确文案后退出
const char* msg = code == 410 ? "账号或密码错误" : code == 412 ? "账号已过期,请续费"
: code == 414 ? "机器码数量已达上限" : code == 415 ? "登录态失效(可能被顶号)"
: code == 416 ? "账号已封禁" : code == 418 ? "软件授权已到期"
: code == 429 ? "请求过于频繁" : code == 430 ? "本月验证额度已用尽" : "验证失败";
std::cerr << msg << std::endl;
exit(1);
}
// 加密版 Call:ENC_KEY 非空时业务参数整体进密文(enc/iv/data 同样参与签名);响应先验签再解密
static std::string CallEnc(const std::string& path, std::map<std::string, std::string> body,
const std::string& biz, const std::string& key) {
body["appkey"] = APPKEY;
long long ts = (long long)std::time(nullptr);
body["time"] = std::to_string(ts);
static unsigned long long counter = 0;
body["nonce"] = std::to_string(ts) + "-" + std::to_string(counter++);
std::string payload = biz;
if (!ENC_KEY.empty()) {
std::string key32 = HexToRaw(ENC_KEY);
unsigned char iv[12];
BCryptGenRandom(nullptr, iv, 12, BCRYPT_USE_SYSTEM_PREFERRED_RNG);
std::string ct; unsigned char tag[16];
if (!AesGcm(key32, iv, payload, ct, tag, false)) ExitWith(-2);
ct.append((char*)tag, 16); // data = base64(密文+Tag)
body["enc"] = "1";
body["iv"] = B64(iv, 12);
body["data"] = B64((const unsigned char*)ct.data(), (int)ct.size());
payload = "";
}
std::string json = "{";
for (auto& kv : body) { if (json.size() > 1) json += ","; json += "\"" + kv.first + "\":\"" + Esc(kv.second) + "\""; }
if (!payload.empty()) { if (json.size() > 1) json += ","; json += payload; }
json += "}";
std::string resp = Post(path, json);
if (JGet(resp, "code") == "200") {
std::string expect = Hmac("ncrs|" + body["nonce"] + "|200|" + JGet(resp, "msg"), key);
if (expect != JGet(resp, "rsign")) ExitWith(-2); // rsign 在明文上:先验签,再解密
}
if (!ENC_KEY.empty() && !JGet(resp, "iv").empty()) {
std::vector<unsigned char> iv = UnB64(JGet(resp, "iv")), all = UnB64(JGet(resp, "data"));
std::string ct((char*)all.data(), all.size() - 16); // 尾部 16 字节为 Tag
std::string inner; unsigned char tag[16];
std::copy(all.end() - 16, all.end(), tag);
if (!AesGcm(HexToRaw(ENC_KEY), iv.data(), ct, inner, tag, true)) ExitWith(-2);
resp = "{\"code\":" + JGet(resp, "code") + ",\"msg\":\"" + JGet(resp, "msg") + "\",\"data\":" + inner + "}";
}
return resp;
}
static void HeartbeatLoop() { // 心跳后台线程:每 10 分钟
int fails = 0;
while (true) {
// token 由 main 存入全局;实际项目建议封装为类成员
std::string r = CallEnc("/user/heartbeat", {}, "\"token\":\"" + Esc(g_token) + "\"", SECRET);
int c = 0; try { c = std::stoi(JGet(r, "code")); } catch (...) {}
if (c != 200) ExitWith(c);
fails = 0;
std::this_thread::sleep_for(std::chrono::minutes(10));
}
}
static std::string g_token;
int main() {
std::string login = CallEnc("/user/login", {}, "\"username\":\"tom\",\"password\":\"123456\",\"machine\":\"PC-01\"", SECRET);
int c = 0; try { c = std::stoi(JGet(login, "code")); } catch (...) {}
if (c != 200) ExitWith(c);
g_token = JGet(login, "token");
// 关键数据放服务端:拿不到就拒绝启动(不要判空继续!)
std::string cfg = JGet(CallEnc("/var/get", {}, "\"token\":\"" + Esc(g_token) + "\",\"key\":\"license_flag\"", SECRET), "value");
if (cfg.empty()) { std::cerr << "未获取到授权配置" << std::endl; return 1; }
std::thread(HeartbeatLoop).detach(); // 网络彻底不可达时 ExitWith 终止整个进程
std::cout << "验证通过,业务逻辑运行中…" << std::endl; // ……你的业务代码
for (;;) std::this_thread::sleep_for(std::chrono::hours(1));
}
关于密钥安全:示例中的 AppSecret / 加密密钥均为占位符,接入时换成自己的并按第 6 步做存储保护。传输加密与协议流程完全公开(这是刻意的设计)——逆向者从你的客户端二进制里本就一定能提取到密钥,公开文档不会提供额外的突破口;签名实现统一以官方签名模块为准(随平台升级更新,请勿依旧资料自行实现)。你能做的是提高逆向与滥用成本:密钥不明文、混淆保护校验逻辑、服务端限速与封禁兜底。
功能组合与场景选型
每个功能既能单独用,也能叠加。先按你的软件形态选一条主路径,再按需叠加安全开关。
| 你的软件形态 | 建议配置 | 接入路径 |
|---|---|---|
| 账号模式 · 普通桌面软件 | 关闭注册走卡密开通(或开注册+赠送天数试用)、强制机器码、心跳 10 分钟 | 基础流程即可跑通 |
| 账号模式 · 强对抗 | 在左基础上 + AES 传输加密、Token 绑定 IP、设备会话 + strict_mode | 基础 → 进阶 → 设备会话迁移 |
| 一机一卡(卡密发放) | 卡模式 + 卡密设备黑名单;(可选)AES;(强对抗)卡模式设备会话 + strict_mode | 卡模式 |
| 常驻后台软件 | 心跳间隔放宽(10 分钟起步),按「日活 × 运行时长」估算月额度并备加油包 | 心跳 |
| 移动网络 / 动态 IP 用户多 | 慎用「Token 绑定 IP」(换网即掉线),靠设备会话/机器码防共享即可 | 登录 |
功能两两组合怎么用
| 组合 | 怎么用(代码层差异) |
|---|---|
| 强制机器码 + 自助解绑 | 登录必须带 machine(未带 400);用户换机收到 414 后,客户端引导调 /user/unbind(凭账号密码)释放旧名额,再登录即绑新机。卡模式对应 /card/reset |
| AES + 设备会话 | 密钥分层:注册设备用控制台加密密钥、换会话用 deviceSecret、会话内业务用 sessionKey(加解密与 rsign 验签都跟随当前签名密钥);deviceId/sid 保持明文公共参数 |
| 设备会话 + 云变量 | strict_mode 下云变量照常读:请求带 sid、用 sessionKey 签名调 /var/get,其余与常规一致 |
| 设备会话 + 卡模式 | /card/enroll(card_key+machine)替代账号 enroll,卡密即身份;/card/session 换会话;复查带 sid 免 card_key/machine(见卡模式) |
| 注册赠送天数 + 卡密 | 开注册 + 赠送 3~7 天试用拉新,用户买卡后 /user/recharge 续期(剩余时长累加,试用不清零) |
| 每设备限注一账 + 强制机器码 | 组合成「一号一机一试用」:注册必须带机器码且一台设备仅能注册一个账号(414 拦截多号),登录也必须带机器码;配合赠送天数试用 + 卡密转正式。释放绑定仅在作者后台「重置机器」——用户端自助解绑不解除注册绑定 |
| 心跳 + 云变量 | 心跳循环每 10 分钟顺便读一次关键开关(一次 /var/get),实现后台一键封功能:控制台改值 → 全体客户端 10 分钟内生效 |
| 响应签名(所有模式) | 无论是否加密/会话/卡模式,每个 200 响应都必须 verify_rsign,密钥跟随当前签名密钥(常规 AppSecret / deviceSecret / sessionKey) |
计费叠加口径:登录/注册/卡激活各 1 次;心跳与卡复查 10 次折 1 次;AES 软件的全额动作 ×2;充值/解绑/预检不计费(详见计费口径)。
接入建议
- User-Agent:SDK 请求头请显式设置稳定 UA,如
User-Agent: NetcCheck-SDK/1.0——Cloudflare 会拦截python-urllib等脚本默认 UA(403/1010),自定义 UA 不会。 - 心跳线程:登录后后台线程定时心跳,收到 412/415/416/418/430 立即按语义处理并提示。
- token 保管:只放内存不落盘,程序重启重新登录。
- 机器码:取机器名 + 硬件序列号做哈希;用户换机由作者在后台一键重置。
- 防破解:关键数据放服务端下发(拿不到,而不是判空),配合代码混淆/加壳保护校验逻辑;密钥不要明文硬编码。
- 宽限策略:网络抖动允许本地宽限 1~2 次心跳失败,避免误杀正常用户。
- 正式收费软件:关闭「允许注册」走卡密开通,开启「Token 绑定 IP」与「AES 传输加密」。
签名模块
示例代码中的 sign() / verify_rsign() 来自官方签名模块(Python/Java/Kotlin,免费提供,商用授权随套餐附送;C# / C++ 示例已内置同规则实现,直接复制)。直接点击下载:sign_module.py · SignModule.java · SignModule.kt;控制台总览「接入信息」卡也有入口,也可联系客服 1937507267@qq.com 索取。模块内置签名与防重放细节并随平台升级同步更新,已覆盖设备会话(sign / verify_rsign 对会话签名完全适用,只是换用 deviceSecret / sessionKey 作为密钥)——请勿依据旧版公开资料自行实现,以免算法升级后接入失效。