diff --git a/src/crypto.js b/src/crypto.js index 402a4aff22..be921ae194 100644 --- a/src/crypto.js +++ b/src/crypto.js @@ -6,17 +6,122 @@ const bodyify = require('querystring').stringify; const eapiKey = 'e82ckenh8dichen8'; const linuxapiKey = 'rFgB&h#%2?^eDg:Q'; +const xeapiStaticKey = Buffer.from( + 'ab1d5a430f6bb04a3f01e81ddd72bd916d5ce591248ac128714806d7f8fb1b84', + 'hex' +); +const xeapiSignKey = + 'mUHCwVNWJbunMqAHf5MImuirT6plvs6VSFW62MGHstFQxhBGdEoIhLItH3djc4+FB/OKty3+lL2rGeoFBpVe5g=='; +const x25519SpkiPrefix = Buffer.from('302a300506032b656e032100', 'hex'); const decrypt = (buffer, key) => { - const decipher = crypto.createDecipheriv('aes-128-ecb', key, null); + const decipher = crypto.createDecipheriv( + `aes-${key.length * 8}-ecb`, + key, + null + ); return Buffer.concat([decipher.update(buffer), decipher.final()]); }; const encrypt = (buffer, key) => { - const cipher = crypto.createCipheriv('aes-128-ecb', key, null); + const cipher = crypto.createCipheriv( + `aes-${key.length * 8}-ecb`, + key, + null + ); return Buffer.concat([cipher.update(buffer), cipher.final()]); }; +const createX25519PublicKey = (raw) => { + if (!Buffer.isBuffer(raw) || raw.length !== 32) + throw new Error('xeapi public key must contain 32 bytes.'); + 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 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 encoded = Buffer.from(xored.toString('base64')); + const rotation = encoded.length ? (random[0] & 0x0f) % encoded.length : 0; + return Buffer.concat([ + random, + encoded.subarray(rotation), + encoded.subarray(0, rotation), + ]); +}; + +const xeapiEncryptSessionKey = (dynamicKey, publicKeyState, os) => { + const peerKey = createX25519PublicKey( + Buffer.from(publicKeyState.publicKey, 'base64') + ); + const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519'); + const ephemeralPublicKey = Buffer.from( + publicKey.export({ format: 'der', type: 'spki' }) + ).subarray(-32); + const sharedSecret = crypto.diffieHellman({ + privateKey, + publicKey: peerKey, + }); + const aesKey = deriveX25519AesKey(sharedSecret, ephemeralPublicKey); + 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([ + ephemeralPublicKey, + iv, + encrypted, + cipher.getAuthTag(), + ]); +}; + +const buildXeapiPlaintext = (uri, data, options = {}) => { + const fields = {}; + const contentType = + options.contentType || + 'application/x-www-form-urlencoded;charset=utf-8'; + if ( + contentType.split(';', 1)[0].toLowerCase() !== + '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; + fields.body = Buffer.from( + new URLSearchParams(bodyData).toString() + ).toString('base64'); + } + fields.queryString = fields.queryString + ? `${fields.queryString}&e_r=true` + : 'e_r=true'; + return JSON.stringify(fields); +}; + module.exports = { eapi: { encrypt: (buffer) => encrypt(buffer, eapiKey), @@ -71,6 +176,63 @@ module.exports = { }; }, }, + xeapi: { + sign: (timestamp, nonce) => + crypto + .createHmac('sha256', xeapiSignKey) + .update(String(timestamp) + nonce) + .digest('base64'), + decrypt: (buffer) => { + const plaintext = decrypt(buffer, eapiKey); + return plaintext[0] === 0x1f && plaintext[1] === 0x8b + ? require('zlib').gunzipSync(plaintext) + : plaintext; + }, + decryptPublicKey: (encryptedData) => + JSON.parse( + decrypt( + Buffer.from(encryptedData, 'base64'), + xeapiStaticKey + ).toString() + ), + encryptRequest: (url, object, options = {}) => { + if (!options.publicKeyState) + throw new Error('xeapi publicKeyState is required.'); + const parsed = parse(url); + const uri = parsed.path; + const dynamicKey = options.sessionKey + ? Buffer.from(String(options.sessionKey)) + : crypto.randomBytes(16); + const plaintext = Buffer.from( + buildXeapiPlaintext(uri, object, options) + ); + const encryptedBody = encrypt( + xeapiMidTransform(encrypt(plaintext, xeapiStaticKey)), + dynamicKey + ); + const encryptedSessionKey = xeapiEncryptSessionKey( + dynamicKey, + options.publicKeyState, + options.os || 'android' + ); + const encryptedVersion = encrypt( + Buffer.from( + `${options.publicKeyState.version}|${ + options.sessionKey ? options.sessionId || '' : '' + }` + ), + xeapiStaticKey + ); + return { + url: parsed.href.replace(/\/(?:api|eapi|weapi)\//, '/xeapi/'), + body: bodyify({ + B: encryptedBody.toString('base64'), + S: encryptedSessionKey.toString('base64'), + R: encryptedVersion.toString('base64'), + }), + }; + }, + }, miguapi: { encryptBody: (object) => { const text = JSON.stringify(object); diff --git a/src/hook.js b/src/hook.js index 43cec7b999..93615aaae9 100644 --- a/src/hook.js +++ b/src/hook.js @@ -93,6 +93,7 @@ hook.target.path = new Set([ '/api/cloudsearch/pc', '/api/v1/playlist/manipulate/tracks', '/api/song/like', + '/api/radio/like', '/api/v1/play/record', '/api/playlist/v4/detail', '/api/v1/radio/get', @@ -116,6 +117,17 @@ const domainList = [ 'interface3.music.163.com', ]; +const setHeader = (headers, name, value) => { + for (const key in headers) { + if (key.toLowerCase() === name.toLowerCase()) { + headers[key] = value; + return; + } + } + + headers[name.toLowerCase()] = value; +}; + hook.request.before = (ctx) => { const { req } = ctx; req.url = @@ -136,16 +148,35 @@ hook.request.before = (ctx) => { ) ctx.decision = 'proxy'; - if (process.env.NETEASE_COOKIE && url.path.includes('url')) { - var cookies = cookieToMap(req.headers.cookie); - var new_cookies = cookieToMap(process.env.NETEASE_COOKIE); + if (url.path.includes('url')) { + let replaced = []; - Object.entries(new_cookies).forEach(([key, value]) => { - cookies[key] = value; - }); + if (process.env.NETEASE_COOKIE) { + setHeader(req.headers, 'Cookie', process.env.NETEASE_COOKIE); + replaced.push('Cookie'); + } + + if (process.env.NETEASE_USER_AGENT) { + setHeader( + req.headers, + 'User-Agent', + process.env.NETEASE_USER_AGENT + ); + replaced.push('User-Agent'); + } - req.headers.cookie = mapToCookie(cookies); - logger.debug('Replace netease cookie'); + if (process.env.NETEASE_MCONFIG_INFO) { + setHeader( + req.headers, + 'MConfig-Info', + process.env.NETEASE_MCONFIG_INFO + ); + replaced.push('MConfig-Info'); + } + + if (replaced.length) { + logger.debug(`Replace netease ${replaced.join(', ')}`); + } } if ( @@ -154,6 +185,7 @@ hook.request.before = (ctx) => { ) && req.method === 'POST' && (url.path.startsWith('/eapi/') || // eapi + url.path.startsWith('/xeapi/') || // xeapi // url.path.startsWith('/api/') || // api url.path.startsWith('/api/linux/forward')) // linuxapi ) { @@ -164,7 +196,8 @@ hook.request.before = (ctx) => { if ('x-napm-retry' in req.headers) delete req.headers['x-napm-retry']; req.headers['X-Real-IP'] = '118.88.88.88'; - if ('x-aeapi' in req.headers) req.headers['x-aeapi'] = 'false'; + if (url.path.startsWith('/eapi/') && 'x-aeapi' in req.headers) + req.headers['x-aeapi'] = 'false'; if ( req.url.includes('stream') || req.url.includes('/eapi/cloud/upload/check') @@ -177,6 +210,13 @@ hook.request.before = (ctx) => { netease.pad = (body.match(/%0+$/) || [''])[0]; if (url.path === '/api/linux/forward') { netease.crypto = 'linuxapi'; + } else if (url.path.startsWith('/xeapi/')) { + // xeapi uses a per-request X25519 session key. Its request + // body cannot be decrypted by this proxy, but its response can. + netease.crypto = 'xeapi'; + netease.path = url.path.replace(/^\/xeapi\//, '/api/'); + netease.param = {}; + netease.e_r = true; } else if (url.path.startsWith('/eapi/')) { netease.crypto = 'eapi'; } else if (url.path.startsWith('/api/')) { @@ -246,10 +286,16 @@ hook.request.before = (ctx) => { ctx.netease = netease; // console.log(netease.path, netease.param) - if (netease.path === '/api/song/enhance/download/url') + if ( + netease.crypto !== 'xeapi' && + netease.path === '/api/song/enhance/download/url' + ) return pretendPlay(ctx); - if (netease.path === '/api/song/enhance/download/url/v1') + if ( + netease.crypto !== 'xeapi' && + netease.path === '/api/song/enhance/download/url/v1' + ) return pretendPlayV1(ctx); if (BLOCK_ADS) { @@ -337,7 +383,11 @@ hook.request.after = (ctx) => { '$1"$2L"$3' ); // for js precision - if (netease.e_r) { + if (netease.crypto === 'xeapi') { + netease.jsonBody = JSON.parse( + patch(crypto.xeapi.decrypt(buffer).toString()) + ); + } else if (netease.e_r) { // eapi's e_r is true, needs to be encrypted netease.jsonBody = JSON.parse( patch(crypto.eapi.decrypt(buffer).toString()) @@ -431,7 +481,10 @@ hook.request.after = (ctx) => { ) { if (netease.path.includes('manipulate')) return tryCollect(ctx); - else if (netease.path === '/api/song/like') + else if ( + netease.path === '/api/song/like' || + netease.path === '/api/radio/like' + ) return tryLike(ctx); } else if (netease.path.includes('url')) return tryMatch(ctx); else if (netease.path.includes('/usertool/sound/')) @@ -510,7 +563,7 @@ hook.request.after = (ctx) => { /([^\\]"\s*:\s*)"(\d{16,})L"(\s*[}|,])/g, '$1$2$3' ); // for js precision - proxyRes.body = netease.e_r // eapi's e_r is true, needs to be encrypted + proxyRes.body = netease.e_r // encrypted eapi/xeapi responses ? crypto.eapi.encrypt(Buffer.from(body)) : body; }) @@ -685,16 +738,58 @@ const computeHash = (task) => const tryMatch = (ctx) => { const { req, netease } = ctx; const { jsonBody } = netease; - /** @type {number} */ + + if (jsonBody.code === -460) { + logger.debug( + 'Official player url blocked (-460), fallback to provider.' + ); + + // 优先从请求参数取 id(eapi/linuxapi 等可解密请求体的场景)。 + // xeapi 请求体由客户端 X25519 会话密钥加密,netease.param 为空, + // 此时退化为从已解密响应体 data 中已有的歌曲 id 获取。 + let id; + try { + id = Number( + netease.param.id ?? + (Array.isArray(netease.param.ids) + ? netease.param.ids + : JSON.parse(netease.param.ids))[0] + .toString() + .replace('_0', '') + ); + } catch (error) { + const item = Array.isArray(jsonBody.data) + ? jsonBody.data[0] + : jsonBody.data; + id = item && Number(item.id); + } + if (isNaN(id)) return; + + jsonBody.data = [ + { + id, + code: -460, + url: null, + br: 0, + freeTrialInfo: null, + }, + ]; + } const min_br = Number(process.env.MIN_BR) || 0; /** @type {Promise[]} */ let tasks; let target = 0; const inject = (item) => { + if (!item) return; + item.flag = 0; + if ( - (item.code !== 200 || item.freeTrialInfo || item.br < min_br) && + (item.code !== 200 || + !item.url || + item.freeTrialInfo || + item.br < min_br) && (target === 0 || item.id === target) ) { return match(item.id) @@ -802,20 +897,32 @@ const tryMatch = (ctx) => { jsonBody.data = jsonBody.data[0]; tasks = [inject(jsonBody.data)]; } else { - target = netease.web - ? 0 - : parseInt( - ( - (Array.isArray(netease.param.ids) - ? netease.param.ids - : JSON.parse(netease.param.ids))[0] || 0 - ) - .toString() - .replace('_0', '') - ); // reduce time cost + if (netease.crypto !== 'xeapi') { + target = netease.web + ? 0 + : parseInt( + ( + (Array.isArray(netease.param.ids) + ? netease.param.ids + : JSON.parse(netease.param.ids))[0] || 0 + ) + .toString() + .replace('_0', '') + ); // reduce time cost + } tasks = jsonBody.data.map((item) => inject(item)); } - return Promise.all(tasks).catch((e) => e && logger.error(e)); + return Promise.all(tasks) + .then(() => { + if ( + jsonBody.code === -460 && + jsonBody.data?.some((item) => item?.code === 200 && item?.url) + ) { + jsonBody.code = 200; + delete jsonBody.message; + } + }) + .catch((e) => e && logger.error(e)); }; const unblockSoundEffects = (obj) => {