CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
106 lines
3.9 KiB
JavaScript
106 lines
3.9 KiB
JavaScript
// 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: '<seconds>', dId: '<device id>', 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<string, string>, 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;
|