实现账号绑定、持久登录与跨设备对局恢复
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
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
This commit is contained in:
1 parent
488db49f11
commit
7c25c89992
62 files changed
+6855
-1005
No files matched your search
@@ -0,0 +1,105 @@
|
||||
// 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;
|
||||
Reference in new issue
Block a user