Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions src/Http/Controllers/Internal/v1/ApiCredentialController.php
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,8 @@ public static function roll($id, Request $request)
return response()->error('API credential attempted to roll could not be found.');
}

// create api credentials seed
$seed = array_map('intval', str_split(time() . $apiCredential->id));

// regenerate api key
$newCredentials = ApiCredential::generateKeys($seed, $apiCredential->test_mode);
// regenerate api key (random; see ApiCredential::generateKeys)
$newCredentials = ApiCredential::generateKeys(null, $apiCredential->test_mode);

// store the previous key
$previousApiKey = $apiCredential->key;
Expand Down
17 changes: 13 additions & 4 deletions src/Models/ApiCredential.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Fleetbase\Traits\Searchable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Permission\Traits\HasPermissions;
Expand Down Expand Up @@ -159,13 +160,21 @@ public function setExpiresAtAttribute($expiresAt)
/**
* Generate an API Key.
*
* The key is 32 random alphanumeric characters from the CSPRNG (~190 bits). It used to be
* sqids($encode), where callers passed the digits of the creation time and row id. The
* model's primary key is the uuid and `id` is never read back on insert, so the observer
* saw a null id and every key created in the same second was identical: the lookup in
* AuthenticateOnceWithBasicAuth then resolved one organization's key to another's
* credential. Keys derived from a timestamp and a sequential id are guessable anyway.
*
* @param mixed $encode ignored; kept so existing callers do not break
*
* @return array
*/
public static function generateKeys($encode, $testKey = false)
public static function generateKeys($encode = null, $testKey = false)
{
$sqids = new \Sqids\Sqids();
$key = $sqids->encode($encode);
$hash = Hash::make($key);
$key = Str::random(32);
$hash = Hash::make($key);

return [
'key' => ($testKey ? 'flb_test_' : 'flb_live_') . $key,
Expand Down
5 changes: 2 additions & 3 deletions src/Observers/ApiCredentialObserver.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,8 @@ class ApiCredentialObserver
*/
public function created(ApiCredential $apiCredential)
{
// generate the api credentials
$seed = array_map('intval', str_split(strtotime($apiCredential->created_at) . $apiCredential->id));
$credentials = ApiCredential::generateKeys($seed, $apiCredential->test_mode);
// generate the api credentials (random; see ApiCredential::generateKeys)
$credentials = ApiCredential::generateKeys(null, $apiCredential->test_mode);

// set the credentials
$apiCredential->key = data_get($credentials, 'key');
Expand Down
29 changes: 29 additions & 0 deletions tests/Unit/Models/ApiAndWebhookModelsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,35 @@ public function save(array $options = []): bool
->and($testKeys['secret'])->toBe('hashed:' . substr($testKeys['key'], strlen('flb_test_')));
});

it('api credential keys are random rather than derived from their input', function () {
bind_test_container()->instance('hash', new ApiAndWebhookModelsHashFake());

// The old keys were sqids(creation second + id); the same input gave the same key.
$first = ApiCredential::generateKeys([1, 7, 9, 0, 0, 0, 0, 0, 0, 0], false)['key'];
$second = ApiCredential::generateKeys([1, 7, 9, 0, 0, 0, 0, 0, 0, 0], false)['key'];

expect($first)->not->toBe($second)
->and($first)->toMatch('/^flb_live_[A-Za-z0-9]{32}$/')
->and(ApiCredential::generateKeys()['key'])->toMatch('/^flb_live_[A-Za-z0-9]{32}$/');
});

it('api credential observer gives credentials created in the same second different keys', function () {
$container = bind_test_container();
$container->instance('hash', new ApiAndWebhookModelsHashFake());

// The uuid primary key means `id` is never loaded on insert, so the observer sees null.
$keys = [];
foreach ([1, 2] as $n) {
$credential = new ApiCredentialObserverSaveSpy();
$credential->setDateFormat('Y-m-d H:i:s');
$credential->setRawAttributes(['id' => null, 'created_at' => '2026-07-17 12:34:56', 'test_mode' => false], true);
(new ApiCredentialObserver())->created($credential);
$keys[] = $credential->key;
}

expect($keys[0])->not->toBe($keys[1]);
});

it('api credential observer writes generated keys and persists live and test credentials', function (bool $testMode, string $expectedPrefix) {
$container = bind_test_container();
$container->instance('hash', new ApiAndWebhookModelsHashFake());
Expand Down
Loading