Files
ark-stronghold-protocol/server/skland/crypto.js
T
herryyun 7c25c89992
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
实现账号绑定、持久登录与跨设备对局恢复
2026-10-14 23:22:50 +08:00

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;