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
93 lines
4.8 KiB
JavaScript
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)');
|
|
});
|