// 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)'); });