|
| 1 | +import capWasm from "@cap.js/wasm"; |
| 2 | + |
| 3 | +const MAX_CHALLENGES = 256; |
| 4 | +const MAX_POW_INPUT_LENGTH = 256; |
| 5 | +const MAX_ESTIMATED_HASHES = 50_000_000; |
| 6 | + |
| 7 | +function seededHex(seed, length) { |
| 8 | + let state = 2166136261; |
| 9 | + for (let i = 0; i < seed.length; i++) { |
| 10 | + state ^= seed.charCodeAt(i); |
| 11 | + state += (state << 1) + (state << 4) + (state << 7) + (state << 8) + (state << 24); |
| 12 | + } |
| 13 | + state >>>= 0; |
| 14 | + |
| 15 | + let out = ""; |
| 16 | + while (out.length < length) { |
| 17 | + state ^= state << 13; |
| 18 | + state ^= state >>> 17; |
| 19 | + state ^= state << 5; |
| 20 | + out += (state >>> 0).toString(16).padStart(8, "0"); |
| 21 | + } |
| 22 | + return out.slice(0, length); |
| 23 | +} |
| 24 | + |
| 25 | +function validatePowInput(salt, target) { |
| 26 | + if (typeof salt !== "string" || typeof target !== "string" || |
| 27 | + !salt || !/^[0-9a-f]+$/i.test(target) || |
| 28 | + salt.length > MAX_POW_INPUT_LENGTH || target.length > MAX_POW_INPUT_LENGTH) { |
| 29 | + throw new Error("Cap challenge 格式无效"); |
| 30 | + } |
| 31 | +} |
| 32 | + |
| 33 | +function solvePow(salt, target) { |
| 34 | + validatePowInput(salt, target); |
| 35 | + return Number(capWasm.solve_pow(salt, target)); |
| 36 | +} |
| 37 | + |
| 38 | +function oldFormatChallenges(body) { |
| 39 | + if (Array.isArray(body.challenge)) return body.challenge; |
| 40 | + const spec = body.challenge; |
| 41 | + const count = Number(spec?.c); |
| 42 | + const saltLength = Number(spec?.s); |
| 43 | + const difficulty = Number(spec?.d); |
| 44 | + if (!Number.isInteger(count) || count < 1 || count > MAX_CHALLENGES || |
| 45 | + !Number.isInteger(saltLength) || saltLength < 1 || saltLength > MAX_POW_INPUT_LENGTH || |
| 46 | + !Number.isInteger(difficulty) || difficulty < 1 || difficulty > 16) { |
| 47 | + throw new Error("Cap challenge 参数无效"); |
| 48 | + } |
| 49 | + |
| 50 | + return Array.from({ length: count }, (_, index) => { |
| 51 | + const n = index + 1; |
| 52 | + return [ |
| 53 | + seededHex(`${body.token}${n}`, saltLength), |
| 54 | + seededHex(`${body.token}${n}d`, difficulty), |
| 55 | + ]; |
| 56 | + }); |
| 57 | +} |
| 58 | + |
| 59 | +function solveChallenge(body) { |
| 60 | + if (!body?.token) throw new Error("Cap challenge 缺少 token"); |
| 61 | + |
| 62 | + if (body.format === 2 && Array.isArray(body.challenges)) { |
| 63 | + if (body.challenges.length < 1 || body.challenges.length > MAX_CHALLENGES) { |
| 64 | + throw new Error("Cap challenge 数量无效"); |
| 65 | + } |
| 66 | + const pow = body.challenges.map((challenge) => { |
| 67 | + if (challenge?.protocol !== "sha256-pow") { |
| 68 | + throw new Error(`Cap challenge 需要 sha256-pow,收到:${challenge?.protocol || "unknown"}`); |
| 69 | + } |
| 70 | + validatePowInput(challenge.payload?.salt, challenge.payload?.target); |
| 71 | + return challenge.payload; |
| 72 | + }); |
| 73 | + const estimatedHashes = pow.reduce((sum, item) => sum + 16 ** item.target.length, 0); |
| 74 | + if (!Number.isFinite(estimatedHashes) || estimatedHashes > MAX_ESTIMATED_HASHES) { |
| 75 | + throw new Error("Cap challenge 工作量超出单次登录限制"); |
| 76 | + } |
| 77 | + return pow.map((item) => ({ nonce: solvePow(item.salt, item.target) })); |
| 78 | + } |
| 79 | + |
| 80 | + const challenges = oldFormatChallenges(body); |
| 81 | + if (challenges.length < 1 || challenges.length > MAX_CHALLENGES) { |
| 82 | + throw new Error("Cap challenge 数量无效"); |
| 83 | + } |
| 84 | + const estimatedHashes = challenges.reduce((sum, [, target]) => sum + 16 ** target.length, 0); |
| 85 | + if (!Number.isFinite(estimatedHashes) || estimatedHashes > MAX_ESTIMATED_HASHES) { |
| 86 | + throw new Error("Cap challenge 工作量超出单次登录限制"); |
| 87 | + } |
| 88 | + return challenges.map(([salt, target]) => solvePow(salt, target)); |
| 89 | +} |
| 90 | + |
| 91 | +function capEndpoint(stationBase, apiEndpoint, siteKey) { |
| 92 | + const origin = new URL(stationBase).origin; |
| 93 | + const rawEndpoint = String(apiEndpoint || "").trim(); |
| 94 | + const key = String(siteKey || "").trim().replace(/^\/+|\/+$/g, ""); |
| 95 | + if (!rawEndpoint || !key) throw new Error("Cap 验证码配置不完整"); |
| 96 | + const path = new URL(rawEndpoint, `${origin}/`).pathname.replace(/\/+$/, ""); |
| 97 | + if (!path) throw new Error("Cap 验证码配置不完整"); |
| 98 | + return `${origin}${path}/${encodeURIComponent(key)}/`; |
| 99 | +} |
| 100 | + |
| 101 | +// Sub2API 把验证码配置放在公开设置里。只有启用 Cap 时才生成令牌, |
| 102 | +// 未开启验证码或使用其他 provider 的站点继续走原有登录流程。 |
| 103 | +export async function capTokenForLogin(stationBase, request) { |
| 104 | + let settingsResponse; |
| 105 | + try { |
| 106 | + settingsResponse = await request(`${stationBase}/api/v1/settings/public`); |
| 107 | + } catch { |
| 108 | + return null; |
| 109 | + } |
| 110 | + if (settingsResponse.status >= 300) return null; |
| 111 | + |
| 112 | + const settings = settingsResponse.body?.data ?? settingsResponse.body ?? {}; |
| 113 | + if (settings.captcha_provider !== "cap" || settings.turnstile_enabled === false) return null; |
| 114 | + |
| 115 | + const endpoint = capEndpoint(stationBase, settings.cap_api_endpoint, settings.cap_site_key); |
| 116 | + const challengeResponse = await request(`${endpoint}challenge`, { method: "POST", timeoutMs: 15000 }); |
| 117 | + if (challengeResponse.status >= 300) { |
| 118 | + throw new Error(`Cap challenge 获取失败:HTTP ${challengeResponse.status}`); |
| 119 | + } |
| 120 | + const challenge = challengeResponse.body || {}; |
| 121 | + if (challenge.error) throw new Error(`Cap challenge 获取失败:${challenge.error}`); |
| 122 | + |
| 123 | + const solutions = solveChallenge(challenge); |
| 124 | + const redeemResponse = await request(`${endpoint}redeem`, { |
| 125 | + method: "POST", |
| 126 | + json: { token: challenge.token, solutions }, |
| 127 | + timeoutMs: 15000, |
| 128 | + }); |
| 129 | + const redeemed = redeemResponse.body || {}; |
| 130 | + if (redeemResponse.status >= 300 || !redeemed.success || !redeemed.token) { |
| 131 | + throw new Error(`Cap challenge 验证失败:${redeemed.error || `HTTP ${redeemResponse.status}`}`); |
| 132 | + } |
| 133 | + return redeemed.token; |
| 134 | +} |
0 commit comments