// server/skland/crypto.js — the Skland API request signature (docs/PACKS.md §4 keeps packs data-only; this module is // server code, not a pack). Same algorithm the community clients use, reconstructed from the public descriptions: // // header_ca = { platform: '3', timestamp: '', dId: '', vName: '1.0.0' } // GET: raw = path + query + timestamp + compactJson(header_ca) // POST: raw = path + compactJson(body) + timestamp + compactJson(header_ca) // sign = md5( hmacSha256Hex(credToken, raw) ) // // Only Web Crypto / node:crypto are used; no third-party dependency. Pure functions — fully unit-tested in // test/skland/crypto.test.js against pinned vectors. import { createHmac, createHash, randomUUID, randomBytes } from 'node:crypto'; /** The Skland mobile client's appCode (public SDK constant, visible in decompiled clients). */ export const SKLAND_APP_CODE = '4ca99fa6b56cc2ba'; /** User-Agent the upstream expects (a Skland Android client). */ export const SKLAND_UA = 'Skland/1.32.1 (com.hypergryph.skland; build:103201004; Android 33; ) Okhttp/4.11.0'; /** Compact JSON: no spaces, key order preserved (the upstream signs the exact body bytes). */ export function compactJson(value) { return JSON.stringify(value); } /** * Build the signed header set for one Skland API request. * @param {{ * cred: string, credToken: string, dId: string, * method: 'GET' | 'POST', path: string, * query?: string, body?: object | string, * timestampSeconds?: number, * }} p * @returns {{ headers: Record, signInput: string }} */ export function signedHeaders(p) { const timestampSeconds = p.timestampSeconds ?? Math.floor(Date.now() / 1000) - 2; const ts = String(timestampSeconds); const headerCa = { platform: '3', timestamp: ts, dId: p.dId, vName: '1.0.0' }; const caJson = compactJson(headerCa); const method = p.method.toUpperCase(); const bodyJson = method === 'POST' ? compactJson(p.body ?? {}) : ''; const query = p.query || ''; const signInput = method === 'POST' ? `${p.path}${bodyJson}${ts}${caJson}` : `${p.path}${query}${ts}${caJson}`; const sign = md5(hmacSha256Hex(p.credToken, signInput)); return { signInput, headers: { 'User-Agent': SKLAND_UA, 'Accept-Encoding': 'gzip', Connection: 'close', 'X-Requested-With': 'com.hypergryph.skland', cred: p.cred, dId: p.dId, platform: '3', timestamp: ts, vName: '1.0.0', sign, }, }; } /** HMAC-SHA256, lowercase hex digest. */ export function hmacSha256Hex(key, message) { return createHmac('sha256', key).update(message, 'utf8').digest('hex'); } /** MD5, lowercase hex digest. */ export function md5(text) { return createHash('md5').update(text, 'utf8').digest('hex'); } /** * A device fingerprint (`dId`). The upstream accepts any stable opaque value here; the community clients derive * one from a random UUID, cache it and reuse it for the account's lifetime. `B` + 32 hex mirrors their format. * @returns {string} */ export function makeDeviceId() { return `B${randomBytes(16).toString('hex')}`; } /** A fresh scan session id placeholder (not a real upstream value — tests only). */ export function randomId() { return randomUUID().replace(/-/g, ''); } /** * The account/login device headers (as.hypergryph.com 验证码 / 密码 / 扫码登录都要求): * `X-DeviceId` random ~10 chars, `X-DeviceModel` a device name, `X-DeviceType: 2` (mobile). * The device id is stable per process so the upstream sees one device for the server's lifetime. * @returns {{ 'X-DeviceId': string, 'X-DeviceModel': string, 'X-DeviceType': string }} */ export function deviceHeaders() { if (!deviceHeaders.cache) { deviceHeaders.cache = Object.freeze({ 'X-DeviceId': randomBytes(5).toString('hex'), // 10 hex chars 'X-DeviceModel': 'Stronghold-Protocol Server', 'X-DeviceType': '2', }); } return deviceHeaders.cache; } deviceHeaders.cache = null;