|
| 1 | +import { BaseClient } from "@evex/linejs/base"; |
| 2 | +import { LINEStruct } from "@evex/linejs/thrift"; |
| 3 | +import crypto from "node:crypto"; |
| 4 | +import https from "node:https"; |
| 5 | +import xxhashInit from "xxhash-wasm"; |
| 6 | + |
| 7 | +const APP_VER = "26.2.0"; |
| 8 | +const SYSTEM_NAME = "Android OS"; |
| 9 | +const SYSTEM_VER = "15"; |
| 10 | +const X_LINE_APP = `ANDROID\t${APP_VER}\t${SYSTEM_NAME}\t${SYSTEM_VER}`; |
| 11 | +const USER_AGENT = `Line/${APP_VER}`; |
| 12 | + |
| 13 | +// authKeyでもPrimaryTokenでも大丈夫 |
| 14 | +const AUTH_KEY = "u***************:++++++++++++++++++"; |
| 15 | + |
| 16 | +function createToken(authKey) { |
| 17 | + const [mid, ...rest] = authKey.split(":"); |
| 18 | + const key = Buffer.from(rest.join(":"), "base64"); |
| 19 | + const iat = |
| 20 | + Buffer.from(`iat: ${Math.floor(Date.now() / 1000) * 60}\n`, "utf-8").toString("base64") + "."; |
| 21 | + const digest = crypto.createHmac("sha1", key).update(iat).digest("base64"); |
| 22 | + return `${mid}:${iat}.${digest}`; |
| 23 | +} |
| 24 | + |
| 25 | +function isAlreadyToken(value) { |
| 26 | + const idx = value.indexOf(":"); |
| 27 | + if (idx === -1) return false; |
| 28 | + const payload = value.substring(idx + 1); |
| 29 | + try { |
| 30 | + const decoded = Buffer.from(payload.split(".")[0], "base64").toString("utf-8"); |
| 31 | + return decoded.startsWith("iat:"); |
| 32 | + } catch { |
| 33 | + return false; |
| 34 | + } |
| 35 | +} |
| 36 | + |
| 37 | +function resolveToken(value) { |
| 38 | + if (isAlreadyToken(value)) { |
| 39 | + return value; |
| 40 | + } |
| 41 | + return createToken(value); |
| 42 | +} |
| 43 | + |
| 44 | +// legy enc |
| 45 | +const LINE_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- |
| 46 | +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsMC6HAYeMq4R59e2yRw6 |
| 47 | +W1OWT2t9aepiAp4fbSCXzRj7A29BOAFAvKlzAub4oxN13Nt8dbcB+ICAufyDnN5N |
| 48 | +d3+vXgDxEXZ/sx2/wuFbC3B3evSNKR4hKcs80suRs8aL6EeWi+bAU2oYIc78Bbqh |
| 49 | +Nzx0WCzZSJbMBFw1VlsU/HQ/XdiUufopl5QSa0S246XXmwJmmXRO0v7bNvrxaNV0 |
| 50 | +cbviGkOvTlBt1+RerIFHMTw3SwLDnCOolTz3CuE5V2OrPZCmC0nlmPRzwUfxoxxs |
| 51 | +/6qFdpZNoORH/s5mQenSyqPkmH8TBOlHJWPH3eN1k6aZIlK5S54mcUb/oNRRq9wD |
| 52 | +1wIDAQAB |
| 53 | +-----END PUBLIC KEY-----`; |
| 54 | + |
| 55 | +const LEGY_IV = Buffer.from([78, 9, 72, 62, 56, 245, 255, 114, 128, 18, 123, 158, 251, 92, 45, 51]); |
| 56 | +const LEGY_LE = "7"; |
| 57 | +const LEGY_LCS_PREFIX = "0008"; |
| 58 | +const LEGY_GF_URL = "https://gf.line.naver.jp/enc"; |
| 59 | +const leGyAesKey = crypto.randomBytes(16); |
| 60 | +const xLcs = LEGY_LCS_PREFIX + crypto.publicEncrypt( |
| 61 | + { key: LINE_PUBLIC_KEY, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha1" }, |
| 62 | + leGyAesKey, |
| 63 | +).toString("base64"); |
| 64 | +let xxh = null; |
| 65 | + |
| 66 | +// Serialize |
| 67 | +function encHeaders(headers) { |
| 68 | + const keys = Object.keys(headers); |
| 69 | + const parts = []; |
| 70 | + parts.push(Buffer.from([(keys.length >> 8) & 0xff, keys.length & 0xff])); |
| 71 | + for (const k of keys) { |
| 72 | + const kBuf = Buffer.from(k, "ascii"); |
| 73 | + const vBuf = Buffer.from(headers[k], "ascii"); |
| 74 | + parts.push(Buffer.from([(kBuf.length >> 8) & 0xff, kBuf.length & 0xff])); |
| 75 | + parts.push(kBuf); |
| 76 | + parts.push(Buffer.from([(vBuf.length >> 8) & 0xff, vBuf.length & 0xff])); |
| 77 | + parts.push(vBuf); |
| 78 | + } |
| 79 | + const body = Buffer.concat(parts); |
| 80 | + return Buffer.concat([Buffer.from([(body.length >> 8) & 0xff, body.length & 0xff]), body]); |
| 81 | +} |
| 82 | + |
| 83 | +// parse |
| 84 | +function decHeaders(data) { |
| 85 | + let off = 0; |
| 86 | + const ri16 = () => { const v = (data[off] << 8) | data[off + 1]; off += 2; return v; }; |
| 87 | + const dataLen = ri16() + 2; |
| 88 | + const count = ri16(); |
| 89 | + const headers = {}; |
| 90 | + for (let i = 0; i < count; i++) { |
| 91 | + const kl = ri16(); const k = data.subarray(off, off + kl).toString("ascii"); off += kl; |
| 92 | + const vl = ri16(); const v = data.subarray(off, off + vl).toString("ascii"); off += vl; |
| 93 | + headers[k] = v; |
| 94 | + } |
| 95 | + return { headers, data: data.subarray(dataLen) }; |
| 96 | +} |
| 97 | + |
| 98 | +function pkcs7Unpad(buf) { |
| 99 | + const n = buf[buf.length - 1]; |
| 100 | + return (n > 0 && n <= 16) ? buf.subarray(0, buf.length - n) : buf; |
| 101 | +} |
| 102 | + |
| 103 | +function pkcs7Pad(buf, bs) { |
| 104 | + const n = bs - (buf.length % bs); |
| 105 | + return Buffer.concat([buf, Buffer.alloc(n, n)]); |
| 106 | +} |
| 107 | + |
| 108 | +// AES-128-CBC enc |
| 109 | +function leGyEncrypt(pt) { |
| 110 | + const c = crypto.createCipheriv("aes-128-cbc", leGyAesKey, LEGY_IV); |
| 111 | + c.setAutoPadding(true); |
| 112 | + return Buffer.concat([c.update(pt), c.final()]); |
| 113 | +} |
| 114 | + |
| 115 | +// AES-128-CBC dec |
| 116 | +function leGyDecrypt(ct) { |
| 117 | + const padded = pkcs7Pad(ct, 16); |
| 118 | + const d = crypto.createDecipheriv("aes-128-cbc", leGyAesKey, LEGY_IV); |
| 119 | + d.setAutoPadding(false); |
| 120 | + const dec = Buffer.concat([d.update(padded), d.final()]); |
| 121 | + return pkcs7Unpad(dec.subarray(0, dec.length - 16)); |
| 122 | +} |
| 123 | + |
| 124 | +// xxHash32 HMAC |
| 125 | +function leGyHmac(key, data, h) { |
| 126 | + const opad = Buffer.alloc(16); |
| 127 | + const ipad = Buffer.alloc(16); |
| 128 | + for (let i = 0; i < 16; i++) { |
| 129 | + opad[i] = 0x5c ^ key[i]; |
| 130 | + ipad[i] = 0x36 ^ key[i]; |
| 131 | + } |
| 132 | + const innerHex = (h.h32Raw(Buffer.concat([ipad, data]), 0) >>> 0).toString(16).padStart(8, "0"); |
| 133 | + const outerHex = (h.h32Raw(Buffer.concat([opad, Buffer.from(innerHex, "hex")]), 0) >>> 0).toString(16).padStart(8, "0"); |
| 134 | + return Buffer.from(outerHex, "hex"); |
| 135 | +} |
| 136 | + |
| 137 | +async function leGyFetch(request) { |
| 138 | + if (!xxh) xxh = await xxhashInit(); |
| 139 | + const url = new URL(request.url); |
| 140 | + const path = url.pathname; |
| 141 | + const thriftBody = Buffer.from(await request.arrayBuffer()); |
| 142 | + const token = request.headers.get("x-line-access"); |
| 143 | + // legy header |
| 144 | + const inner = token |
| 145 | + ? { "x-lt": token, "x-lpqs": path } |
| 146 | + : { "x-lpqs": path }; |
| 147 | + const plaintext = Buffer.concat([encHeaders(inner), thriftBody]); |
| 148 | + const leInt = parseInt(LEGY_LE, 10); |
| 149 | + const fixBytes = (leInt & 4) === 4; |
| 150 | + let toEncrypt = fixBytes |
| 151 | + ? Buffer.concat([Buffer.from([leInt]), plaintext]) |
| 152 | + : plaintext; |
| 153 | + let enc = leGyEncrypt(toEncrypt); |
| 154 | + if ((leInt & 2) === 2) { |
| 155 | + enc = Buffer.concat([enc, leGyHmac(leGyAesKey, enc, xxh)]); |
| 156 | + } |
| 157 | + // gf.line.naver.jp/enc |
| 158 | + const { statusCode, responseBody, responseHeaders } = await new Promise((resolve, reject) => { |
| 159 | + const gfUrl = new URL(LEGY_GF_URL); |
| 160 | + const req = https.request({ |
| 161 | + hostname: gfUrl.hostname, |
| 162 | + port: 443, |
| 163 | + path: gfUrl.pathname, |
| 164 | + method: "POST", |
| 165 | + headers: { |
| 166 | + "x-line-application": X_LINE_APP, |
| 167 | + "x-le": LEGY_LE, |
| 168 | + "x-lap": "5", |
| 169 | + "x-lpv": "1", |
| 170 | + "x-lcs": xLcs, |
| 171 | + "User-Agent": USER_AGENT, |
| 172 | + "content-type": "application/x-thrift; protocol=TBINARY", |
| 173 | + "x-lal": "ja_JP", |
| 174 | + "x-lhm": "POST", |
| 175 | + "accept": "*/*", |
| 176 | + "accept-encoding": "gzip, deflate", |
| 177 | + "connection": "keep-alive", |
| 178 | + "Content-Length": enc.length, |
| 179 | + }, |
| 180 | + }, (res) => { |
| 181 | + const chunks = []; |
| 182 | + res.on("data", (chunk) => chunks.push(chunk)); |
| 183 | + res.on("end", () => resolve({ |
| 184 | + statusCode: res.statusCode, |
| 185 | + responseBody: Buffer.concat(chunks), |
| 186 | + responseHeaders: res.headers, |
| 187 | + })); |
| 188 | + }); |
| 189 | + req.on("error", reject); |
| 190 | + req.write(enc); |
| 191 | + req.end(); |
| 192 | + }); |
| 193 | + if (!responseBody.length) { |
| 194 | + return new Response(responseBody, { status: statusCode }); |
| 195 | + } |
| 196 | + // legy dec |
| 197 | + let dec = leGyDecrypt(responseBody); |
| 198 | + if (fixBytes) dec = dec.subarray(1); |
| 199 | + const { headers: innerH, data: thriftData } = decHeaders(dec); |
| 200 | + const innerStatus = innerH["x-lc"]; |
| 201 | + const httpStatus = (innerStatus && innerStatus !== "200") ? parseInt(innerStatus, 10) : statusCode; |
| 202 | + return new Response(thriftData, { |
| 203 | + status: httpStatus, |
| 204 | + headers: { "content-type": "application/x-thrift" }, |
| 205 | + }); |
| 206 | +} |
| 207 | + |
| 208 | +// linejs BaseClient |
| 209 | +const base = new BaseClient({ device: "ANDROID", version: APP_VER, fetch: leGyFetch }); |
| 210 | +base.authToken = resolveToken(AUTH_KEY); |
| 211 | +base.request.systemType = X_LINE_APP; |
| 212 | +base.request.userAgent = USER_AGENT; |
| 213 | +base.talk.requestPath = "/S3"; |
| 214 | +base.talk.protocolType = 3; |
| 215 | + |
| 216 | +export { base, LINEStruct }; |
0 commit comments