原理
传统当中客户端-服务器-数据库的卡密验证架构,对于服务器,数据库存在一定需求,很容易成为攻击目标,为了弥补搭建网络卡密验证方面的缺点,写了本文章
全文分为两个公钥私钥,算法为ES256,是JWT当中常见的数字签名算法,结合与哈希与椭圆曲线,更短的密钥可以有更好的安全性
我们脱离了数据存储,自然是需要将密文解密的,本地的解密是绝对不安全的,因此需要Cloudflare进行解密,为了保证中间人不会篡改,同样用了椭圆曲线进行了前后公私密钥的签名校验
这个系统有三个关键角色:
管理员:
- 资产:拥有管理员私钥
- 职责:使用私钥签发包含授权信息(设备ID、过期时间)的令牌
Cloudflare Worker:
- 资产:拥有管理员公钥(用于验证令牌真伪)和自己的 Worker 私钥(用于证明自身身份)。
- 职责:作为一个无状态的验证端点,它接收 App 的请求,验证真实性和有效性,然后用自己的私key签名,给 App 一个可信的回执。
安卓应用(消费者):
- 资产:拥有 Worker 公钥。
- 职责:向用户索要卡密,提交给 Worker 验证,并用自己的公钥验证 Worker 的回执是否真实。
下面开始教学
:::tip
我们需要一个简单的 Node.js 环境
:::
首先,初始化项目并安装 jose 库:
npm init -y
npm install jose
Jose库需要在本地安装,因此本项目的ts并不能直接复制到CFworkers,因此全程构建需要电脑环境,低成本必须要求的牺牲,没办法,如果有大佬可以构建出脱离电脑的运行脚本,还请多多帮助
附录Jose是实现JWT的核心,有不会的可以去百度搜索,不做赘述了
我们需要两对 ECDSA P-256 密钥:一对用于管理员,一对用于 Worker。
修改 package.json,添加 "type": "module",。
卡密的生成与签发
:::tip
package.json没有就创建一个
:::
下面是js代码
generate-keys.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import { generateKeyPair, exportSPKI, exportPKCS8 } from 'jose'; import { promises as fs } from 'fs';
async function generateKeys(prefix) { const { publicKey, privateKey } = await generateKeyPair('ES256', { extractable: true }); const spkiPem = await exportSPKI(publicKey); const pkcs8Pem = await exportPKCS8(privateKey);
await fs.writeFile(`${prefix}_public_key.pem`, spkiPem); await fs.writeFile(`${prefix}_private_key.pem`, pkcs8Pem); console.log(`--- ${prefix}_public_key.pem ---`); console.log(spkiPem); }
console.log('正在生成管理员密钥对...'); await generateKeys('admin'); console.log('\n正在生成 Worker 密钥对...'); await generateKeys('worker'); console.log('\n密钥对已全部生成!');
|
在终端运行 node generate-keys.js,你将得到四个文件。请妥善保管它们:
admin_private_key.pem: 保护好了哦,用来生成卡密的
admin_public_key.pem: 将部署到 Worker。
worker_private_key.pem: 将部署到 Worker。
worker_public_key.pem: 将硬编码到安卓 App 中
issue-token.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| import { SignJWT, importPKCS8 } from 'jose'; import { promises as fs } from 'fs';
async function issueGoldenToken(deviceId, validityInDays) { const privateKeyPem = await fs.readFile('admin_private_key.pem', 'utf-8'); const privateKey = await importPKCS8(privateKeyPem, 'ES256'); const expiresAt = Math.floor(Date.now() / 1000) + (validityInDays * 24 * 60 * 60);
const goldenToken = await new SignJWT({ deviceId, expiresAt }) .setProtectedHeader({ alg: 'ES256' }) .sign(privateKey);
console.log('卡密已生成 ---'); console.log(`设备ID: ${deviceId}`); console.log(`有效期至: ${new Date(expiresAt * 1000).toLocaleString()}`); console.log('请将下面的字符串发给用户:'); console.log(goldenToken); }
const userDeviceId = process.argv[2]; const days = parseInt(process.argv[3], 10); if (!userDeviceId || !days) { console.log('用法: node issue-token.js <设备ID> <有效天数>'); } else { issueGoldenToken(userDeviceId, days); }
|
使用: node issue-token.js <用户的设备ID> <有效天数> (例如 30 天)。生成的长字符串就是发给用户的卡密。
网络端
我们将使用 Wrangler CLI 工具来创建和部署 Worker。
- 创建项目:
npx wrangler init my-license-worker (选择 Worker only 模板)。
- 安装依赖:
cd my-license-worker 然后 npm install jose
- 上传密钥: 使用
wrangler secret put 命令,将 admin_public_key.pem 和 worker_private_key.pem 的内容分别上传到名为 ADMIN_PUBLIC_KEY 和 WORKER_PRIVATE_KEY 的环境变量中(这里可能会卡,记得挂梯子)
src/index.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| import { importSPKI, jwtVerify, SignJWT, importPKCS8 } from 'jose';
export interface Env { ADMIN_PUBLIC_KEY: string; WORKER_PRIVATE_KEY: string; }
export default { async fetch(request: Request, env: Env): Promise<Response> { if (request.method !== 'POST') { return jsonResponse({ error: 'Expected POST' }, 405); }
try { const { deviceId, token }: { deviceId?: string; token?: string } = await request.json();
if (!deviceId || !token) { return jsonResponse({ isValid: false, reason: 'Missing parameters' }, 400); } const adminPublicKey = await importSPKI(env.ADMIN_PUBLIC_KEY, 'ES256'); const { payload: tokenPayload } = await jwtVerify(token, adminPublicKey);
if (tokenPayload.deviceId !== deviceId) { return jsonResponse({ isValid: false, reason: 'Device ID mismatch' }); } if (Math.floor(Date.now() / 1000) > (tokenPayload.expiresAt as number)) { return jsonResponse({ isValid: false, reason: 'Token expired' }); }
const responseData = { isValid: true, deviceId: tokenPayload.deviceId, expiresAt: tokenPayload.expiresAt, validatedAt: Math.floor(Date.now() / 1000), };
const workerPrivateKey = await importPKCS8(env.WORKER_PRIVATE_KEY, 'ES256'); const signedJwtResponse = await new SignJWT(responseData) .setProtectedHeader({ alg: 'ES256' }) .sign(workerPrivateKey); return jsonResponse({ responseToken: signedJwtResponse });
} catch (error: any) { const reason = error.code || error.message || 'Internal Server Error'; console.error(`Validation failed: ${reason}`); return jsonResponse({ isValid: false, reason }); } }, };
function jsonResponse(data: object, status: number = 200): Response { return new Response(JSON.stringify(data), { status: status, headers: { 'Content-Type': 'application/json' }}); }
|
最后,运行 npx wrangler deploy 将其部署到全球。
接下来是安卓层面对接了
成品写在MoonLightAPP了,目前还没有提交
类名:SecurityManager
相关代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184
| package bbs.yuchen.icu; import android.util.Base64; import android.util.Log; import com.nimbusds.jose.JWSVerifier; import com.nimbusds.jose.crypto.ECDSAVerifier; import com.nimbusds.jwt.SignedJWT; import org.json.JSONObject; import java.io.BufferedReader; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; import java.security.KeyFactory; import java.security.PublicKey; import java.security.interfaces.ECPublicKey; import java.security.spec.X509EncodedKeySpec; public class SecurityManager { private static final String TAG = "SecurityManagerDebug"; private static final String WORKER_PUBLIC_KEY_STRING = "-----BEGIN PUBLIC KEY-----\-----END PUBLIC KEY-----";
private static boolean verifyResponse(String responseToken) { try { Log.d(TAG, "Verifying response token: " + responseToken); SignedJWT signedJWT = SignedJWT.parse(responseToken); PublicKey publicKey = loadPublicKey(WORKER_PUBLIC_KEY_STRING); if (!(publicKey instanceof ECPublicKey)) { Log.e(TAG, "Public key is not an EC public key, cannot verify."); return false; } JWSVerifier verifier = new ECDSAVerifier((ECPublicKey) publicKey); Log.d(TAG, "Is worker signature on JWT valid? -> " + isSignatureValid); return isSignatureValid; } catch (Exception e) { Log.e(TAG, "Exception during JWT verification", e); return false; } }
public static boolean validateLicense(String deviceId, String goldenToken) { String workerUrl = "https://card.342191.xyz"; Log.d(TAG, "\n--- Starting License Validation ---"); Log.d(TAG, "Worker URL: " + workerUrl); Log.d(TAG, "Device ID: " + deviceId); Log.d(TAG, "Golden Token: " + goldenToken); HttpURLConnection conn = null; try { URL url = new URL(workerUrl); conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("POST"); conn.setRequestProperty("Content-Type", "application/json; utf-8"); conn.setConnectTimeout(15000); conn.setReadTimeout(15000); conn.setDoOutput(true); JSONObject requestPayload = new JSONObject(); requestPayload.put("deviceId", deviceId); requestPayload.put("token", goldenToken); String jsonInputString = requestPayload.toString(); Log.d(TAG, "Sending request payload: " + jsonInputString); try (OutputStream os = conn.getOutputStream()) { os.write(jsonInputString.getBytes(StandardCharsets.UTF_8)); } int responseCode = conn.getResponseCode(); String responseMessage = conn.getResponseMessage(); Log.d(TAG, "Received HTTP Response: " + responseCode + " " + responseMessage); InputStream inputStream = (responseCode >= 200 && responseCode <= 299) ? conn.getInputStream() : conn.getErrorStream(); if (inputStream == null) { Log.e(TAG, "Response input stream is null."); return false; } try (BufferedReader br = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { StringBuilder response = new StringBuilder(); String responseLine; while ((responseLine = br.readLine()) != null) { response.append(responseLine.trim()); } String rawResponse = response.toString(); Log.d(TAG, "Raw response body: " + rawResponse); if (responseCode != 200) { Log.e(TAG, "Validation failed due to non-200 response code."); return false; } JSONObject jsonResponse = new JSONObject(rawResponse); if (jsonResponse.has("isValid") && !jsonResponse.getBoolean("isValid")) { Log.e(TAG, "Worker returned a validation failure: " + jsonResponse.optString("reason")); return false; } if (!jsonResponse.has("responseToken")) { Log.e(TAG, "Response does not contain 'responseToken'"); return false; } String responseToken = jsonResponse.getString("responseToken"); if (!verifyResponse(responseToken)) { return false; } SignedJWT signedJWT = SignedJWT.parse(responseToken); JSONObject payload = new JSONObject(signedJWT.getPayload().toString()); boolean isLicenseValid = payload.getBoolean("isValid"); Log.d(TAG, "Is license valid according to TRUSTED payload? -> " + isLicenseValid); return isLicenseValid; } } catch (Exception e) { Log.e(TAG, "An exception occurred during validation", e); return false; } finally { if (conn != null) { conn.disconnect(); } Log.d(TAG, "--- License Validation Finished ---"); } }
private static PublicKey loadPublicKey(String key) throws Exception { String publicKeyPEM = key .replace("-----BEGIN PUBLIC KEY-----", "") .replaceAll("\n", "") .replace("-----END PUBLIC KEY-----", ""); byte[] encoded = Base64.decode(publicKeyPEM, Base64.DEFAULT); KeyFactory keyFactory = KeyFactory.getInstance("EC"); X509EncodedKeySpec keySpec = new X509EncodedKeySpec(encoded); return keyFactory.generatePublic(keySpec); } }
|
由于原本就封装好了,UI层面代码发出来没用,自己写吧