diff --git a/generateConfig.js b/generateConfig.js index d60e224..7640065 100644 --- a/generateConfig.js +++ b/generateConfig.js @@ -2,6 +2,7 @@ const fs = require('fs') const path = require('path') const { register_anonimous } = require('./main') const { cookieToJson, generateRandomChineseIP } = require('./util/index') +const { getXeapiPublicKey } = require('./util/xeapiKey') const tmpPath = require('os').tmpdir() async function generateConfig() { @@ -20,5 +21,21 @@ async function generateConfig() { } catch (error) { console.log(error) } + try { + let currentPublicKey = {} + try { + currentPublicKey = JSON.parse( + fs.readFileSync(path.resolve(tmpPath, 'xeapi_public_key'), 'utf-8'), + ) + } catch (_) {} + const publicKey = await getXeapiPublicKey(currentPublicKey, global.deviceId) + fs.writeFileSync( + path.resolve(tmpPath, 'xeapi_public_key'), + JSON.stringify(publicKey), + 'utf-8', + ) + } catch (error) { + console.log(error) + } } module.exports = generateConfig diff --git a/util/crypto.js b/util/crypto.js index a6301e7..775b1b8 100644 --- a/util/crypto.js +++ b/util/crypto.js @@ -1,4 +1,5 @@ const CryptoJS = require('crypto-js') +const crypto = require('crypto') const forge = require('node-forge') const zlib = require('zlib') const iv = '0102030405060708' @@ -9,6 +10,13 @@ const publicKey = `-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDgtQn2JZ34ZC28NWYpAUd98iZ37BUrX/aKzmFbt7clFSs6sXqHauqKWqdtLkF2KexO40H1YTX8z2lSgBBOAxLsvaklV8k4cBFK9snQXE9/DDaFt6Rr7iVZMldczhC0JNgTz+SHXT6CBHuX3e9SdB1Ua44oncaTWz7OBGLbCiK45wIDAQAB -----END PUBLIC KEY-----` const eapiKey = 'e82ckenh8dichen8' +const xeapiStaticKey = Buffer.from( + 'ab1d5a430f6bb04a3f01e81ddd72bd916d5ce591248ac128714806d7f8fb1b84', + 'hex', +) +const xeapiSignKey = + 'mUHCwVNWJbunMqAHf5MImuirT6plvs6VSFW62MGHstFQxhBGdEoIhLItH3djc4+FB/OKty3+lL2rGeoFBpVe5g==' +const x25519SpkiPrefix = Buffer.from('302a300506032b656e032100', 'hex') const aesEncrypt = (text, mode, key, iv, format = 'base64') => { let encrypted = CryptoJS.AES.encrypt( @@ -141,13 +149,172 @@ const decrypt = (cipher) => { return decryptedBytes } +const aesEcbEncrypt = (key, plaintext) => { + const cipher = crypto.createCipheriv(`aes-${key.length * 8}-ecb`, key, null) + return Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()]) +} + +const aesEcbDecrypt = (key, ciphertext) => { + const decipher = crypto.createDecipheriv( + `aes-${key.length * 8}-ecb`, + key, + null, + ) + return Buffer.concat([decipher.update(ciphertext), decipher.final()]) +} + +const createX25519PublicKey = (raw) => { + // Node's crypto API expects X25519 public keys as DER SubjectPublicKeyInfo. + // The Android SDK stores only the 32-byte raw key, so prepend the fixed + // RFC 8410 SPKI header for id-X25519 before importing it. + return crypto.createPublicKey({ + key: Buffer.concat([x25519SpkiPrefix, raw]), + format: 'der', + type: 'spki', + }) +} + +const deriveX25519AesKey = (sharedSecret, ephemeralPublicKey) => { + const prk = crypto + .createHmac('sha256', Buffer.alloc(32)) + .update(sharedSecret.length ? sharedSecret : Buffer.alloc(32)) + .digest() + return crypto + .createHmac('sha256', prk) + .update(Buffer.concat([ephemeralPublicKey, Buffer.from([1])])) + .digest() + .subarray(0, 16) +} + +const xeapiSign = (timestamp, nonce) => { + return crypto + .createHmac('sha256', xeapiSignKey) + .update(String(timestamp) + nonce) + .digest('base64') +} + +const xeapiMidTransform = (ciphertext) => { + const random = crypto.randomBytes(16) + const xored = Buffer.alloc(ciphertext.length) + for (let i = 0; i < ciphertext.length; i++) { + xored[i] = ciphertext[i] ^ random[i & 0x0f] + } + const b64 = Buffer.from(xored.toString('base64')) + const rot = b64.length ? (random[0] & 0x0f) % b64.length : 0 + return Buffer.concat([random, b64.subarray(rot), b64.subarray(0, rot)]) +} + +const xeapiEncryptS = (dynamicKey, publicKeyState, os) => { + const peerRaw = Buffer.from(publicKeyState.publicKey, 'base64') + const peerKey = createX25519PublicKey(peerRaw) + const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519') + const ephemeralRaw = Buffer.from( + publicKey.export({ format: 'der', type: 'spki' }), + ).subarray(-32) + const sharedSecret = crypto.diffieHellman({ + privateKey, + publicKey: peerKey, + }) + const aesKey = deriveX25519AesKey(sharedSecret, ephemeralRaw) + const iv = crypto.randomBytes(12) + const cipher = crypto.createCipheriv('aes-128-gcm', aesKey, iv) + const plaintext = Buffer.from( + `${dynamicKey.toString('base64')}|${os}|${publicKeyState.sk || ''}`, + ) + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]) + return Buffer.concat([ephemeralRaw, iv, encrypted, cipher.getAuthTag()]) +} + +const buildXeapiPlaintext = (uri, data, options = {}) => { + const fields = {} + const contentType = + options.contentType || 'application/x-www-form-urlencoded;charset=utf-8' + const mediaType = contentType.split(';', 1)[0].toLowerCase() + if (mediaType !== 'application/x-www-form-urlencoded') { + fields.contentType = contentType + } + + const method = (options.method || 'POST').toUpperCase() + if (method !== 'POST') fields.method = method + + const url = new URL(uri, 'https://interface.music.163.com') + if (url.search) fields.queryString = url.search.slice(1) + + if (data !== undefined && data !== null) { + const bodyData = { ...data } + delete bodyData.e_r + const body = Buffer.from(new URLSearchParams(bodyData).toString()) + fields.body = body.toString('base64') + } + + if (fields.queryString) { + fields.queryString += '&e_r=true' + } else { + fields.queryString = 'e_r=true' + } + return JSON.stringify(fields) +} + +const xeapi = (uri, data, options = {}) => { + const publicKeyState = options.publicKeyState + if (!publicKeyState) { + throw new Error('xeapi publicKeyState is required') + } + const activeSessionKey = options.sessionKey + ? Buffer.from(String(options.sessionKey)) + : null + const activeSessionId = options.sessionId || '' + const dynamicKey = activeSessionKey || crypto.randomBytes(16) + const plaintext = Buffer.from(buildXeapiPlaintext(uri, data, options)) + + const b = aesEcbEncrypt( + dynamicKey, + xeapiMidTransform(aesEcbEncrypt(xeapiStaticKey, plaintext)), + ) + const s = xeapiEncryptS(dynamicKey, publicKeyState, options.os || 'android') + const r = aesEcbEncrypt( + xeapiStaticKey, + Buffer.from( + `${publicKeyState.version}|${activeSessionKey ? activeSessionId : ''}`, + ), + ) + + return { + B: b.toString('base64'), + S: s.toString('base64'), + R: r.toString('base64'), + } +} + +const xeapiResDecrypt = (body) => { + const decrypted = aesEcbDecrypt(eapiKey, body) + const plaintext = + decrypted[0] === 0x1f && decrypted[1] === 0x8b + ? zlib.gunzipSync(decrypted) + : decrypted + return JSON.parse(plaintext.toString()) +} + +const xeapiDecryptPublicKey = (encryptedData) => { + return JSON.parse( + aesEcbDecrypt( + xeapiStaticKey, + Buffer.from(encryptedData, 'base64'), + ).toString(), + ) +} + module.exports = { weapi, linuxapi, eapi, + xeapi, decrypt, aesEncrypt, aesDecrypt, eapiReqDecrypt, eapiResDecrypt, + xeapiSign, + xeapiResDecrypt, + xeapiDecryptPublicKey, } diff --git a/util/request.js b/util/request.js index a71553d..49a74ba 100644 --- a/util/request.js +++ b/util/request.js @@ -23,6 +23,20 @@ const anonymous_token = fs.readFileSync( path.resolve(tmpPath, './anonymous_token'), 'utf-8', ) +const xeapiPublicKeyPath = path.resolve(tmpPath, './xeapi_public_key') +let xeapi_public_key = null +const loadXeapiPublicKey = () => { + if (!xeapi_public_key && fs.existsSync(xeapiPublicKeyPath)) { + try { + xeapi_public_key = JSON.parse( + fs.readFileSync(xeapiPublicKeyPath, 'utf-8'), + ) + } catch (error) { + console.log('[ERR]', error) + } + } + return xeapi_public_key +} // 预先绑定常用函数和常量 const floor = Math.floor @@ -95,9 +109,13 @@ const userAgentMap = { // 预先定义常量 const DOMAIN = APP_CONF.domain const API_DOMAIN = APP_CONF.apiDomain +const XEAPI_DOMAIN = 'https://interface3.music.163.com' const ENCRYPT_RESPONSE = APP_CONF.encryptResponse const SPECIAL_STATUS_CODES = new Set([201, 302, 400, 502, 800, 801, 802, 803]) +let xeapiSessionId = '' +let xeapiSessionKey = '' + // chooseUserAgent函数 const chooseUserAgent = (crypto, uaType = 'pc') => { return (userAgentMap[crypto] && userAgentMap[crypto][uaType]) || '' @@ -216,6 +234,52 @@ const createRequest = (uri, data, options) => { url = (options.domain || DOMAIN) + '/api/linux/forward' break + case 'xeapi': + const xeapiPublicKey = loadXeapiPublicKey() + if (!xeapiPublicKey) { + throw new Error('xeapi public key is missing') + } + const xeapiOs = cookie.os === 'android' ? cookie.os : 'android' + const xeapiAppver = + cookie.os === 'android' && cookie.appver ? cookie.appver : '9.1.65' + const xeapiOsver = + cookie.os === 'android' && cookie.osver ? cookie.osver : '16' + const xeapiBuildver = cookie.buildver || now().toString().substr(0, 10) + headers['User-Agent'] = options.ua || chooseUserAgent('api', 'android') + headers['X-Client-Enc-State'] = 'ENCRYPTED' + headers['x-aeapi'] = true + headers['content-type'] = + 'application/x-www-form-urlencoded;charset=utf-8' + headers['x-deviceid'] = cookie.deviceId + headers['x-os'] = xeapiOs + headers['x-osver'] = xeapiOsver + headers['x-appver'] = xeapiAppver + headers['x-sdeviceid'] = cookie.sDeviceId || cookie.deviceId + headers['x-buildver'] = xeapiBuildver + if (cookie.MUSIC_U) headers['x-music-u'] = cookie.MUSIC_U + const xeapiCookie = { + ...cookie, + os: xeapiOs, + osver: xeapiOsver, + appver: xeapiAppver, + buildver: xeapiBuildver, + deviceId: cookie.deviceId, + sDeviceId: cookie.sDeviceId || cookie.deviceId, + } + headers['Cookie'] = cookieObjToString(xeapiCookie) + url = (options.domain || XEAPI_DOMAIN) + '/xeapi/' + uri.substr(5) + encryptData = encrypt.xeapi(uri, data, { + ...options, + publicKeyState: xeapiPublicKey, + sessionId: xeapiSessionId, + sessionKey: xeapiSessionKey, + appver: xeapiAppver, + deviceId: cookie.deviceId, + os: xeapiOs, + uid: cookie.uid || cookie.userId || '', + }) + break + case 'eapi': case 'api': // header创建 @@ -272,7 +336,8 @@ const createRequest = (uri, data, options) => { // 使用返回值加密 const use_e_r = (crypto === 'eapi' || crypto === 'weapi') && data.e_r - if (use_e_r) { + const use_xeapi = crypto === 'xeapi' + if (use_e_r || use_xeapi) { settings.encoding = null settings.responseType = 'arraybuffer' } @@ -320,7 +385,13 @@ const createRequest = (uri, data, options) => { ) try { - if (use_e_r) { + if (use_xeapi) { + if (res.headers['x-encr-ssid'] && res.headers['x-encr-sskey']) { + xeapiSessionId = res.headers['x-encr-ssid'] + xeapiSessionKey = res.headers['x-encr-sskey'] + } + answer.body = encrypt.xeapiResDecrypt(Buffer.from(body)) + } else if (use_e_r) { answer.body = encrypt.eapiResDecrypt( body.toString('hex').toUpperCase(), headers['x-aeapi'], diff --git a/util/xeapiKey.js b/util/xeapiKey.js new file mode 100644 index 0000000..ba6ced4 --- /dev/null +++ b/util/xeapiKey.js @@ -0,0 +1,68 @@ +const { default: axios } = require('axios') +const encrypt = require('./crypto') +const { APP_CONF } = require('./config.json') + +const generateNonce = () => { + let nonce = '' + for (let i = 0; i < 16; i++) { + nonce += Math.floor(Math.random() * 10).toString() + } + return nonce +} + +const getXeapiPublicKey = async (currentPublicKey = {}, deviceId = '') => { + const nonce = generateNonce() + const timestamp = String(Date.now()) + const data = { + appVersion: '9.1.65', + currentKeyVersion: currentPublicKey.version || '', + deviceId, + nonce, + os: 'android', + requestType: 'active', + signature: encrypt.xeapiSign(timestamp, nonce), + t1: '', + t2: '', + timestamp, + uid: '', + } + const res = await axios({ + method: 'POST', + url: APP_CONF.apiDomain + '/api/gorilla/anti/crawler/security/key/get', + headers: { + 'User-Agent': + 'NeteaseMusic/9.1.65.240927161425(9001065);Dalvik/2.1.0 (Linux; U; Android 14; 23013RK75C Build/UKQ1.230804.001)', + Cookie: deviceId ? `deviceId=${encodeURIComponent(deviceId)}` : '', + }, + data: new URLSearchParams(data).toString(), + proxy: false, + }) + if ( + !res.data || + res.data.code !== 200 || + !res.data.data || + !res.data.data.encryptedData + ) { + throw new Error('xeapi public key request failed') + } + if ( + !res.data.data.signature || + encrypt.xeapiSign(res.data.data.timestamp, nonce) !== + res.data.data.signature + ) { + throw new Error('xeapi public key response signature mismatch') + } + + const publicKey = encrypt.xeapiDecryptPublicKey(res.data.data.encryptedData) + if (!publicKey.sk && currentPublicKey.sk) { + publicKey.sk = currentPublicKey.sk + } + if (!publicKey.sk) { + throw new Error('xeapi public key response missing sk') + } + return publicKey +} + +module.exports = { + getXeapiPublicKey, +}