Skip to content

Commit

Permalink
Add client hash check
Browse files Browse the repository at this point in the history
  • Loading branch information
nanaya committed Jan 24, 2024
1 parent f27cd8a commit e279419
Show file tree
Hide file tree
Showing 14 changed files with 193 additions and 105 deletions.
12 changes: 12 additions & 0 deletions app/Exceptions/ClientCheckParseTokenException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the GNU Affero General Public License v3.0.
// See the LICENCE file in the repository root for full licence text.

declare(strict_types=1);

namespace App\Exceptions;

class ClientCheckParseTokenException extends \Exception
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,10 @@ public function store($roomId, $playlistId)
$room = Room::findOrFail($roomId);
$playlistItem = $room->playlist()->where('id', $playlistId)->firstOrFail();
$user = auth()->user();
$params = request()->all();
$request = \Request::instance();
$params = $request->all();

$buildId = ClientCheck::findBuild($user, $params)?->getKey()
?? $GLOBALS['cfg']['osu']['client']['default_build_id'];
$buildId = ClientCheck::parseToken($request)['buildId'];

$scoreToken = $room->startPlay($user, $playlistItem, $buildId);

Expand All @@ -181,6 +181,8 @@ public function store($roomId, $playlistId)
*/
public function update($roomId, $playlistItemId, $tokenId)
{
$request = \Request::instance();
$clientTokenData = ClientCheck::parseToken($request);
$scoreLink = \DB::transaction(function () use ($roomId, $playlistItemId, $tokenId) {
$room = Room::findOrFail($roomId);

Expand All @@ -203,6 +205,7 @@ public function update($roomId, $playlistItemId, $tokenId)

$score = $scoreLink->score;
if ($score->wasRecentlyCreated) {
ClientCheck::queueToken($clientTokenData, $score->getKey());
$score->queueForProcessing();
}

Expand Down
8 changes: 4 additions & 4 deletions app/Http/Controllers/ScoreTokensController.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ public function store($beatmapId)
{
$beatmap = Beatmap::increasesStatistics()->findOrFail($beatmapId);
$user = auth()->user();
$rawParams = request()->all();
$params = get_params($rawParams, null, [
$request = \Request::instance();
$params = get_params($request->all(), null, [
'beatmap_hash',
'ruleset_id:int',
]);
Expand All @@ -43,12 +43,12 @@ public function store($beatmapId)
}
}

$build = ClientCheck::findBuild($user, $rawParams);
$buildId = ClientCheck::parseToken($request)['buildId'];

try {
$scoreToken = ScoreToken::create([
'beatmap_id' => $beatmap->getKey(),
'build_id' => $build?->getKey() ?? $GLOBALS['cfg']['osu']['client']['default_build_id'],
'build_id' => $buildId,
'ruleset_id' => $params['ruleset_id'],
'user_id' => $user->getKey(),
]);
Expand Down
8 changes: 6 additions & 2 deletions app/Http/Controllers/Solo/ScoresController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
namespace App\Http\Controllers\Solo;

use App\Http\Controllers\Controller as BaseController;
use App\Libraries\ClientCheck;
use App\Models\ScoreToken;
use App\Models\Solo\Score;
use App\Transformers\ScoreTransformer;
Expand All @@ -20,7 +21,9 @@ public function __construct()

public function store($beatmapId, $tokenId)
{
$score = DB::transaction(function () use ($beatmapId, $tokenId) {
$request = \Request::instance();
$clientTokenData = ClientCheck::parseToken($request);
$score = DB::transaction(function () use ($beatmapId, $request, $tokenId) {
$user = auth()->user();
$scoreToken = ScoreToken::where([
'beatmap_id' => $beatmapId,
Expand All @@ -29,7 +32,7 @@ public function store($beatmapId, $tokenId)

// return existing score otherwise (assuming duplicated submission)
if ($scoreToken->score_id === null) {
$params = Score::extractParams(\Request::all(), $scoreToken);
$params = Score::extractParams($request->all(), $scoreToken);
$score = Score::createFromJsonOrExplode($params);
$score->createLegacyEntryOrExplode();
$scoreToken->fill(['score_id' => $score->getKey()])->saveOrExplode();
Expand All @@ -42,6 +45,7 @@ public function store($beatmapId, $tokenId)
});

if ($score->wasRecentlyCreated) {
ClientCheck::queueToken($clientTokenData, $score->getKey());
$score->queueForProcessing();
}

Expand Down
9 changes: 7 additions & 2 deletions app/Http/Controllers/UsersController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use App\Exceptions\UserProfilePageLookupException;
use App\Exceptions\ValidationException;
use App\Http\Middleware\RequestCost;
use App\Libraries\ClientCheck;
use App\Libraries\RateLimiter;
use App\Libraries\Search\ForumSearch;
use App\Libraries\Search\ForumSearchRequestParams;
Expand Down Expand Up @@ -217,11 +218,15 @@ public function store()
], 403);
}

if (!starts_with(Request::header('User-Agent'), $GLOBALS['cfg']['osu']['client']['user_agent'])) {
$request = \Request::instance();

if (!starts_with($request->header('User-Agent'), $GLOBALS['cfg']['osu']['client']['user_agent'])) {
return error_popup(osu_trans('users.store.from_client'), 403);
}

return $this->storeUser(request()->all());
ClientCheck::parseToken($request);

return $this->storeUser($request->all());
}

public function storeWeb()
Expand Down
102 changes: 82 additions & 20 deletions app/Libraries/ClientCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,101 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the GNU Affero General Public License v3.0.
// See the LICENCE file in the repository root for full licence text.

declare(strict_types=1);

namespace App\Libraries;

use App\Exceptions\ClientCheckParseTokenException;
use App\Models\Build;
use Illuminate\Http\Request;

class ClientCheck
{
public static function findBuild($user, $params): ?Build
public static function parseToken(Request $request): array
{
$assertValid = $GLOBALS['cfg']['osu']['client']['check_version'] && $user->findUserGroup(app('groups')->byIdentifier('admin'), true) === null;

$clientHash = presence(get_string($params['version_hash'] ?? null));
if ($clientHash === null) {
if ($assertValid) {
abort(422, 'missing client version');
} else {
return null;
$token = $request->header('x-token');
$assertValid = $GLOBALS['cfg']['osu']['client']['check_version'];
$ret = [
'buildId' => $GLOBALS['cfg']['osu']['client']['default_build_id'],
'token' => null,
];

try {
if ($token === null) {
throw new ClientCheckParseTokenException('missing token header');
}

$input = static::splitToken($token);

$build = Build::firstWhere([
'hash' => $input['clientHash'],
'allow_ranking' => true,
]);

if ($build === null) {
throw new ClientCheckParseTokenException('invalid client hash');
}

$ret['buildId'] = $build->getKey();

$computed = hash_hmac(
'sha1',
$input['clientData'],
static::getKey($build),
true,
);

if (!hash_equals($computed, $input['expected'])) {
throw new ClientCheckParseTokenException('invalid verification hash');
}
}

// temporary measure to allow android builds to submit without access to the underlying dll to hash
if (strlen($clientHash) !== 32) {
$clientHash = md5($clientHash);
$now = time();
static $maxTime = 15 * 60;
if (abs($now - $input['clientTime']) > $maxTime) {
throw new ClientCheckParseTokenException('expired token');
}

$ret['token'] = $token;
} catch (ClientCheckParseTokenException $e) {
abort_if($assertValid, 422, $e->getMessage());
}

$build = Build::firstWhere([
'hash' => hex2bin($clientHash),
'allow_ranking' => true,
]);
return $ret;
}

if ($build === null && $assertValid) {
abort(422, 'invalid client hash');
public static function queueToken(?array $tokenData, int $scoreId): void
{
if ($tokenData['token'] === null) {
return;
}

return $build;
\LaravelRedis::lpush($GLOBALS['cfg']['osu']['client']['token_queue'], json_encode([
'id' => $scoreId,
'token' => $tokenData['token'],
]));
}

private static function getKey(Build $build): string
{
return $GLOBALS['cfg']['osu']['client']['token_keys'][$build->platform()]
?? $GLOBALS['cfg']['osu']['client']['token_keys']['default']
?? '';
}

private static function splitToken(string $token): array
{
$data = substr($token, -82);
$clientTimeHex = substr($data, 32, 8);
$clientTime = strlen($clientTimeHex) === 8
? unpack('V', hex2bin($clientTimeHex))[1]
: 0;

return [
'clientData' => substr($data, 0, 40),
'clientHash' => hex2bin(substr($data, 0, 32)),
'clientTime' => $clientTime,
'expected' => hex2bin(substr($data, 40, 40)),
'version' => substr($data, 80, 2),
];
}
}
10 changes: 10 additions & 0 deletions app/Models/Build.php
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,16 @@ public function notificationCover()
// no image
}

public function platform(): string
{
$version = $this->version;
$suffixPos = strpos($version, '-');

return $suffixPos === false
? ''
: substr($version, $suffixPos + 1);
}

public function url()
{
return build_url($this);
Expand Down
10 changes: 10 additions & 0 deletions config/osu.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
$profileScoresNotice = markdown_plain($profileScoresNotice);
}

$clientTokenKeys = [];
foreach (explode(',', env('CLIENT_TOKEN_KEYS') ?? '') as $entry) {
if ($entry !== '') {
[$platform, $encodedKey] = explode('=', $entry, 2);
$clientTokenKeys[$platform] = hex2bin($encodedKey);
}
}

// osu config~
return [
'achievement' => [
Expand Down Expand Up @@ -93,6 +101,8 @@
'client' => [
'check_version' => get_bool(env('CLIENT_CHECK_VERSION')) ?? true,
'default_build_id' => get_int(env('DEFAULT_BUILD_ID')) ?? 0,
'token_keys' => $clientTokenKeys,
'token_queue' => env('CLIENT_TOKEN_QUEUE') ?? 'token-queue',
'user_agent' => env('CLIENT_USER_AGENT', 'osu!'),
],
'elasticsearch' => [
Expand Down
2 changes: 1 addition & 1 deletion database/factories/BuildFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public function definition(): array
{
return [
'date' => fn () => $this->faker->dateTimeBetween('-5 years'),
'hash' => fn () => md5($this->faker->word(), true),
'hash' => fn () => md5(rand(), true),
'stream_id' => fn () => array_rand_val($GLOBALS['cfg']['osu']['changelog']['update_streams']),
'users' => rand(100, 10000),

Expand Down
1 change: 1 addition & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<env name="CHAT_PRIVATE_LIMIT" value="2"/>

<env name="ADMIN_FORUM_ID" value="0"/>
<env name="CLIENT_CHECK_VERSION" value="0"/>
<env name="DOUBLE_POST_ALLOWED_FORUM_IDS" value="0"/>
<env name="FEATURE_FORUM_ID" value="0"/>
<env name="HELP_FORUM_ID" value="0"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,15 +103,18 @@ public function testShow()
*/
public function testStore($allowRanking, $hashParam, $status)
{
$origClientCheckVersion = $GLOBALS['cfg']['osu']['client']['check_version'];
config_set('osu.client.check_version', true);
$user = User::factory()->create();
$playlistItem = PlaylistItem::factory()->create();
$build = Build::factory()->create(['allow_ranking' => $allowRanking]);

$this->actAsScopedUser($user, ['*']);

$params = [];
if ($hashParam !== null) {
$params['version_hash'] = $hashParam ? bin2hex($build->hash) : md5('invalid_');
$this->withHeaders([
'x-token' => $hashParam ? static::createClientToken($build) : strtoupper(md5('invalid_')),
]);
}

$countDiff = ((string) $status)[0] === '2' ? 1 : 0;
Expand All @@ -120,7 +123,9 @@ public function testStore($allowRanking, $hashParam, $status)
$this->json('POST', route('api.rooms.playlist.scores.store', [
'room' => $playlistItem->room_id,
'playlist' => $playlistItem->getKey(),
]), $params)->assertStatus($status);
]))->assertStatus($status);

config_set('osu.client.check_version', $origClientCheckVersion);
}

/**
Expand Down
Loading

0 comments on commit e279419

Please sign in to comment.