Files
ark-stronghold-protocol/test/skland/crypto.test.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

93 lines
4.8 KiB
JavaScript

// The Skland request signature (server/skland/crypto.js): the composition of the signed header set.
//
// The upstream's algorithm has two independent references in the community (the 3mua.cn write-up on the account login
// surface and TNXG/skland-api's crypto module). This file pins the *composition* — which bytes go into the HMAC, and
// in what order — because that is the part a refactor can silently break. The digest itself is checked against
// node:crypto's own HMAC-SHA256 and MD5, so a change of either primitive shows up here.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHmac, createHash } from 'node:crypto';
import { signedHeaders, hmacSha256Hex, md5, compactJson, makeDeviceId, deviceHeaders } from '../../server/skland/crypto.js';
const ref = (key, msg) => createHash('md5').update(createHmac('sha256', key).update(msg, 'utf8').digest('hex'), 'utf8').digest('hex');
test('compactJson: no whitespace, insertion order kept (the upstream signs the exact body bytes)', () => {
assert.equal(compactJson({ b: 1, a: 2 }), '{"b":1,"a":2}');
assert.equal(compactJson({ gameId: 1, uid: '123' }), '{"gameId":1,"uid":"123"}');
assert.equal(compactJson({}), '{}');
});
test('hmacSha256Hex / md5 are the plain primitives', () => {
assert.equal(hmacSha256Hex('key', 'msg'), createHmac('sha256', 'key').update('msg').digest('hex'));
assert.equal(md5('abc'), '900150983cd24fb0d6963f7d28e17f72');
});
test('a GET sign input is path + query + timestamp + compact(header_ca)', () => {
const { signInput, headers } = signedHeaders({
cred: 'CRED', credToken: 'CTOK', dId: 'DEV', method: 'GET', path: '/api/v1/game/player/binding',
query: 'a=1', timestampSeconds: 1700000000,
});
const ca = '{"platform":"3","timestamp":"1700000000","dId":"DEV","vName":"1.0.0"}';
assert.equal(signInput, `/api/v1/game/player/bindinga=11700000000${ca}`);
assert.equal(headers.sign, ref('CTOK', signInput));
assert.equal(headers.cred, 'CRED');
assert.equal(headers.platform, '3');
assert.equal(headers.vName, '1.0.0');
assert.equal(headers.dId, 'DEV');
});
test('a GET without a query signs the bare path', () => {
const { signInput } = signedHeaders({ cred: 'c', credToken: 'k', dId: 'd', method: 'GET', path: '/p', timestampSeconds: 1 });
assert.equal(signInput, `/p1{"platform":"3","timestamp":"1","dId":"d","vName":"1.0.0"}`);
});
test('a POST sign input is path + compact(body) + timestamp + compact(header_ca)', () => {
const body = { gameId: 1, uid: '123' };
const { signInput, headers } = signedHeaders({
cred: 'CRED', credToken: 'CTOK', dId: 'DEV', method: 'POST', path: '/api/v1/game/attendance',
body, timestampSeconds: 1700000000,
});
const ca = '{"platform":"3","timestamp":"1700000000","dId":"DEV","vName":"1.0.0"}';
assert.equal(signInput, `/api/v1/game/attendance{"gameId":1,"uid":"123"}1700000000${ca}`);
assert.equal(headers.sign, ref('CTOK', signInput));
});
test('a POST without a body signs "{}"', () => {
const { signInput } = signedHeaders({ cred: 'c', credToken: 'k', dId: 'd', method: 'POST', path: '/p', timestampSeconds: 1 });
assert.equal(signInput, `/p{}1{"platform":"3","timestamp":"1","dId":"d","vName":"1.0.0"}`);
});
test('the signature is stable for equal inputs and moves with any single input', () => {
const base = { cred: 'c', credToken: 'k', dId: 'd', method: 'POST', path: '/p', body: { x: 1 }, timestampSeconds: 5 };
const a = signedHeaders(base).headers.sign;
assert.equal(signedHeaders({ ...base }).headers.sign, a);
assert.notEqual(signedHeaders({ ...base, credToken: 'k2' }).headers.sign, a);
assert.notEqual(signedHeaders({ ...base, dId: 'd2' }).headers.sign, a);
assert.notEqual(signedHeaders({ ...base, timestampSeconds: 6 }).headers.sign, a);
assert.notEqual(signedHeaders({ ...base, body: { x: 2 } }).headers.sign, a);
assert.notEqual(signedHeaders({ ...base, path: '/q' }).headers.sign, a);
assert.notEqual(signedHeaders({ ...base, method: 'GET', query: '' }).headers.sign, a);
});
test('the mobile client identity headers the upstream expects', () => {
const h = signedHeaders({ cred: 'c', credToken: 'k', dId: 'd', method: 'GET', path: '/p', timestampSeconds: 1 }).headers;
assert.match(h['User-Agent'], /^Skland\//);
assert.equal(h['X-Requested-With'], 'com.hypergryph.skland');
assert.equal(h['Accept-Encoding'], 'gzip');
});
test('makeDeviceId: stable shape, a fresh value each call', () => {
const a = makeDeviceId();
const b = makeDeviceId();
assert.match(a, /^B[0-9a-f]{32}$/);
assert.notEqual(a, b);
});
test('deviceHeaders: the login device headers, cached for the process', () => {
const h = deviceHeaders();
assert.match(h['X-DeviceId'], /^[0-9a-f]{10}$/);
assert.equal(h['X-DeviceType'], '2');
assert.ok(h['X-DeviceModel'].length > 0);
assert.equal(deviceHeaders(), h, 'one device per process (the upstream sees a stable device)');
});