import java.nio.charset.StandardCharsets import java.security.MessageDigest import java.util.TreeMap import java.util.UUID import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec /** * NetcCheck 官方签名模块 · Kotlin(JVM,零依赖) * * 用法: * val body = sortedMapOf( * "appkey" to APPKEY, * "time" to (System.currentTimeMillis() / 1000).toString(), * "nonce" to UUID.randomUUID().toString().replace("-", ""), * "username" to "tom" // ...其余业务参数 * ) * body["sign"] = SignModule.sign(body, SECRET) * val resp = httpPostJson(API + "/user/login", body) // 发请求见接入文档示例 * check(SignModule.verifyRsign(resp, body.getValue("nonce"), SECRET)) { "响应来源不可信(疑似假服务器),已中止" } * * 设备会话模式(推荐,见接入文档「设备会话」章节): * 本模块对会话签名完全适用——签名流程不变,只换密钥: * ① 设备注册 body["sign"] = SignModule.sign(body, APPSECRET) // /device/enroll,AppSecret 仅此一步 * ② 换取会话 body["sign"] = SignModule.sign(body, deviceSecret) // /device/session,含 deviceId 参数 * ③ 会话内业务 body["sid"] = sessionId; body["sign"] = SignModule.sign(body, sessionKey) * 响应验签密钥跟随当前签名密钥:会话请求传 sessionKey,未带 sid 的响应用 AppSecret。 * strict_mode 软件的 AppSecret 仅用于设备注册,泄露也无法伪造业务请求。 * * 约定: * - sign() 对除 "sign" 本身外的全部参数计算签名;开启传输加密后 enc/iv/data 同样参与;会话请求的 sid 也参与。 * - 参数值一律以字符串传入(时间戳、enc 等与接入文档示例保持一致)。 * - 算法细节随平台升级可能调整,请勿依据旧版资料自行实现——以本模块为准。 * - 免费提供,商用授权随套餐附送。获取/更新:控制台「总览 → 接入信息」或联系客服。 */ object SignModule { /** 签名基准串:除 sign 外的全部参数按参数名升序 k=v 用 & 连接,末尾拼 "&key=secret" */ private fun canonical(params: Map, secret: String): String { val keys = params.keys.filter { it != "sign" }.sorted() return keys.joinToString("&") { "$it=${params[it]}" } + "&key=" + secret } /** 计算请求签名(HMAC-SHA256,返回 hex 小写)。params 中 sign 键会被忽略。 */ fun sign(params: Map, secret: String): String { val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec(secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256")) return hex(mac.doFinal(canonical(params, secret).toByteArray(StandardCharsets.UTF_8))) } /** * 验证响应签名(防「改 hosts 假服务器恒返 200」攻击)。 * respJson 为原始响应文本;nonce 为本次请求使用的 nonce。 * key = 本次请求使用的签名密钥:常规请求传 AppSecret;设备会话请求(带 sid)传 sessionKey。 * 返回 false = 响应不可信,立即中止,不要使用其中的任何数据。 * 仅 200 响应需要且必须验签(鉴权未通过的失败响应不带 rsign)。 */ fun verifyRsign(respJson: String, nonce: String, key: String): Boolean { val rsign = extractString(respJson, "rsign") ?: return false if (rsign.isEmpty()) return false val code = extractNumber(respJson, "code") ?: "" val msg = unescape(extractString(respJson, "msg")) ?: "" val message = "ncrs|$nonce|$code|$msg" return try { val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec(key.toByteArray(StandardCharsets.UTF_8), "HmacSHA256")) val expect = hex(mac.doFinal(message.toByteArray(StandardCharsets.UTF_8))) MessageDigest.isEqual(expect.toByteArray(StandardCharsets.UTF_8), rsign.toLowerCase().toByteArray(StandardCharsets.UTF_8)) } catch (e: Exception) { false } } /** 极简 JSON 字符串字段提取(返回不含引号的内容;生产环境建议改用 JSON 库) */ private fun extractString(json: String, field: String): String? = Regex("\"" + field + "\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"").find(json)?.groupValues?.get(1) /** 极简 JSON 数字字段提取 */ private fun extractNumber(json: String, field: String): String? = Regex("\"" + field + "\"\\s*:\\s*(-?\\d+)").find(json)?.groupValues?.get(1) private fun unescape(s: String?): String? = s?.replace("\\\"", "\"")?.replace("\\\\", "\\") private fun hex(data: ByteArray): String = data.joinToString("") { String.format("%02x", it) } /** 签名 v2(对接简化版,推荐新接入):sign = HMAC-SHA256(secret, 原始请求体字符串),请求体须与签名时逐字节一致。 */ fun signV2(rawBody: String, secret: String): String = hex(Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256")) }.doFinal(rawBody.toByteArray(Charsets.UTF_8))) } /** 由 AppSecret 派生传输加密密钥(enc_key 留空的软件;HKDF-SHA256,与服务器一致),返回 64 位 hex。 */ fun deriveEncKey(appsecretHex: String, appkey: String): String { val prk = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec("netccheck-enc-v1".toByteArray(Charsets.UTF_8), "HmacSHA256")) }.doFinal(appsecretHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()) return Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(prk, "HmacSHA256")) update("nk:$appkey".toByteArray(Charsets.UTF_8)) update(byteArrayOf(1)) }.doFinal().joinToString("") { "%02x".format(it) } } }}