diff --git a/legacy/phpstan-baseline.neon b/legacy/phpstan-baseline.neon index 0ed32acf6..1bf8599be 100644 --- a/legacy/phpstan-baseline.neon +++ b/legacy/phpstan-baseline.neon @@ -230,11 +230,6 @@ parameters: count: 1 path: src/Command/Server/ServerStartCommand.php - - - message: "#^Parameter \\#1 \\$items of method Platformsh\\\\Cli\\\\Service\\\\QuestionHelper\\:\\:choose\\(\\) expects array\\, array\\ given\\.$#" - count: 1 - path: src/Command/SshKey/SshKeyDeleteCommand.php - - message: "#^Call to an undefined method GuzzleHttp\\\\ClientInterface\\:\\:post\\(\\)\\.$#" count: 1 diff --git a/legacy/src/Command/SshKey/SshKeyAddCommand.php b/legacy/src/Command/SshKey/SshKeyAddCommand.php index 5172caad5..a5d1de64c 100644 --- a/legacy/src/Command/SshKey/SshKeyAddCommand.php +++ b/legacy/src/Command/SshKey/SshKeyAddCommand.php @@ -4,6 +4,7 @@ namespace Platformsh\Cli\Command\SshKey; +use GuzzleHttp\Exception\BadResponseException; use Platformsh\Cli\Service\Io; use Platformsh\Cli\Service\Api; use Platformsh\Cli\Service\Config; @@ -128,7 +129,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int } // Add the new key. - $this->api->getClient()->addSshKey($publicKey, $input->getOption('name')); + try { + $this->api->addSshKey($publicKey, $input->getOption('name')); + } catch (BadResponseException $e) { + // The API rejects a key that is already registered, by anyone. + if ($e->getResponse()->getStatusCode() === 409) { + $this->stdErr->writeln('This SSH key is already registered.'); + return 1; + } + throw $e; + } $this->stdErr->writeln(\sprintf( 'The SSH key %s has been successfully added to your %s account.', @@ -152,14 +162,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int /** * Check whether the SSH key already exists in the user's account. * - * @param string $fingerprint The public key fingerprint (as an MD5 hash). + * @param string $fingerprint The public key fingerprint (as a SHA-256 hash). * * @return bool */ protected function keyExistsByFingerprint(string $fingerprint): bool { - foreach ($this->api->getClient()->getSshKeys() as $existingKey) { - if ($existingKey->fingerprint === $fingerprint) { + foreach ($this->api->getSshKeys() as $existingKey) { + if ($existingKey->sha256 === $fingerprint) { return true; } } diff --git a/legacy/src/Command/SshKey/SshKeyDeleteCommand.php b/legacy/src/Command/SshKey/SshKeyDeleteCommand.php index a0421cfdb..7d1a35722 100644 --- a/legacy/src/Command/SshKey/SshKeyDeleteCommand.php +++ b/legacy/src/Command/SshKey/SshKeyDeleteCommand.php @@ -28,7 +28,7 @@ protected function configure(): void InputArgument::OPTIONAL, 'The ID of the SSH key to delete', ); - $this->addExample('Delete the key 123', '123'); + $this->addExample('Delete the key with the given ID', '01JX7Q8YV0N4W2S6TRK3M9BAEC'); $help = 'This command lets you delete SSH keys from your account.' . "\n\n" . $this->certificateNotice($this->config); $this->setHelp($help); @@ -45,11 +45,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $options = []; foreach ($keys as $key) { - $options[(string) $key->key_id] = sprintf('%s (%s)', $key->key_id, $key->title ?: $key->fingerprint); + $options[$key->id] = sprintf('%s (%s)', $key->id, $key->label ?: $key->sha256); } $id = $this->questionHelper->choose($options, 'Enter a number to choose a key to delete:', null, false); } - if (empty($id) || !is_numeric($id)) { + if (empty($id)) { $this->stdErr->writeln('You must specify the ID of the SSH key to delete.'); $this->stdErr->writeln(''); $this->stdErr->writeln( @@ -59,15 +59,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } - $key = $this->api->getClient() - ->getSshKey((string) $id); + $key = $this->api->getSshKey((string) $id); if (!$key) { $this->stdErr->writeln("SSH key not found: $id"); return 1; } - $key->delete(); + $this->api->deleteSshKey($key->id); $this->stdErr->writeln(sprintf( 'The SSH key %s has been deleted from your %s account.', diff --git a/legacy/src/Command/SshKey/SshKeyListCommand.php b/legacy/src/Command/SshKey/SshKeyListCommand.php index a8e6a9189..788971908 100644 --- a/legacy/src/Command/SshKey/SshKeyListCommand.php +++ b/legacy/src/Command/SshKey/SshKeyListCommand.php @@ -18,12 +18,13 @@ class SshKeyListCommand extends SshKeyCommandBase /** @var array */ private array $tableHeader = [ 'id' => 'ID', - 'title' => 'Title', - 'fingerprint' => 'Fingerprint', + 'label' => 'Label', + 'sha256' => 'Fingerprint (SHA-256)', + 'active' => 'Active', 'path' => 'Local path', ]; /** @var string[] */ - private array $defaultColumns = ['id', 'title', 'path']; + private array $defaultColumns = ['id', 'label', 'active', 'path']; public function __construct(private readonly Api $api, private readonly Config $config, private readonly SshKey $sshKey, private readonly Table $table) { parent::__construct(); @@ -52,8 +53,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $sshKeyService = $this->sshKey; $rows = []; foreach ($keys as $key) { - $row = ['id' => (string) $key->key_id, 'title' => $key->title, 'fingerprint' => $key->fingerprint]; - $identity = $sshKeyService->findIdentityMatchingPublicKeys([$key->fingerprint]); + $row = ['id' => $key->id, 'label' => $key->label, 'sha256' => $key->sha256, 'active' => $key->active ? 'Yes' : 'No']; + $identity = $sshKeyService->findIdentityMatchingPublicKeys([$key->sha256]); $path = $identity ? $identity . '.pub' : ''; if (!$identity && !$table->formatIsMachineReadable()) { $path = 'Not found'; diff --git a/legacy/src/Event/LoginRequiredEvent.php b/legacy/src/Event/LoginRequiredEvent.php index 63d1ee735..5359608d2 100644 --- a/legacy/src/Event/LoginRequiredEvent.php +++ b/legacy/src/Event/LoginRequiredEvent.php @@ -72,7 +72,7 @@ public function getLoginOptionsCmdLine(): string $args = []; foreach ($this->getLoginOptions() as $option => $value) { $args[] = $option; - $args[] = OsUtil::escapeShellArg(is_array($value) ? implode(',', $value) : $value); + $args[] = OsUtil::escapeShellArg(is_array($value) ? implode(',', $value) : (string) $value); } return implode(' ', $args); } diff --git a/legacy/src/Model/SshKey.php b/legacy/src/Model/SshKey.php new file mode 100644 index 000000000..5bd46e7f8 --- /dev/null +++ b/legacy/src/Model/SshKey.php @@ -0,0 +1,44 @@ + $data + */ + public static function fromData(array $data): self + { + return new self( + (string) ($data['id'] ?? ''), + (string) ($data['sha256'] ?? ''), + (string) ($data['value'] ?? ''), + (string) ($data['label'] ?? ''), + (bool) ($data['active'] ?? true), + (string) ($data['user_id'] ?? ''), + (string) ($data['created_at'] ?? ''), + (string) ($data['updated_at'] ?? ''), + ); + } +} diff --git a/legacy/src/Service/Api.php b/legacy/src/Service/Api.php index b5858f9e5..ea0c88231 100644 --- a/legacy/src/Service/Api.php +++ b/legacy/src/Service/Api.php @@ -18,6 +18,8 @@ use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\BadResponseException; use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Psr7\Uri; +use GuzzleHttp\Psr7\UriResolver; use GuzzleHttp\Utils; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; use League\OAuth2\Client\Token\AccessToken; @@ -44,7 +46,7 @@ use Platformsh\Client\Model\Project; use Platformsh\Client\Model\Ref\UserRef; use Platformsh\Client\Model\ApiResourceBase as ApiResource; -use Platformsh\Client\Model\SshKey; +use Platformsh\Cli\Model\SshKey; use Platformsh\Client\Model\Subscription; use Platformsh\Client\Model\Team\TeamMember; use Platformsh\Client\Model\Team\TeamProjectAccess; @@ -69,6 +71,8 @@ */ class Api { + private const MAX_SSH_KEY_PAGES = 1000; + private static bool $printedApiTokenWarning = false; private readonly EventDispatcherInterface $dispatcher; @@ -911,50 +915,150 @@ public function getMyAccount(bool $reset = false): array } /** - * Get the current user's legacy account info, including SSH keys. + * Shortcut to return the ID of the current user. + */ + public function getMyUserId(bool $reset = false): string + { + $id = $this->getClient()->getMyUserId($reset); + if (!$id) { + throw new \RuntimeException('No user ID found for the current session.'); + } + return $id; + } + + /** + * Returns the base URL of the current user's SSH keys collection. + */ + private function sshKeysUrl(): string + { + return rtrim($this->config->getApiUrl(), '/') . '/users/' . rawurlencode($this->getMyUserId()) . '/ssh-keys'; + } + + /** + * Returns the cache key under which the current user's SSH keys are stored. + */ + private function sshKeysCacheKey(): string + { + return sprintf('%s:ssh-keys', $this->config->getSessionId()); + } + + /** + * Get the logged-in user's SSH keys. * * @param bool $reset * - * @return array{'id': string, 'username': string, 'mail': string, 'display_name': string, 'ssh_keys': array} + * @return SshKey[] */ - private function getLegacyAccountInfo(bool $reset = false): array + public function getSshKeys(bool $reset = false): array { - $cacheKey = sprintf('%s:my-account', $this->config->getSessionId()); - $info = $this->cache->fetch($cacheKey); - if (!$reset && $info) { - $this->io->debug('Loaded account information from cache'); + $cacheKey = $this->sshKeysCacheKey(); + $items = $this->cache->fetch($cacheKey); + if ($reset || !is_array($items)) { + $items = []; + $url = $this->sshKeysUrl(); + $visitedUrls = []; + // The list is paginated, and the "next" link may be relative to the + // API base URL, so each one is resolved against the request URL. + while ($url !== null) { + if (isset($visitedUrls[$url])) { + throw new \RuntimeException('The SSH keys API returned a circular pagination link.'); + } + if (count($visitedUrls) >= self::MAX_SSH_KEY_PAGES) { + throw new \RuntimeException('The SSH keys API returned too many pages.'); + } + $visitedUrls[$url] = true; + try { + $response = $this->getHttpClient()->request('GET', $url); + } catch (BadResponseException $e) { + throw ApiResponseException::wrapGuzzleException($e); + } + $data = (array) Utils::jsonDecode((string) $response->getBody(), true); + foreach ($data['items'] ?? [] as $item) { + $items[] = $item; + } + $next = $data['_links']['next']['href'] ?? null; + $url = $next !== null + ? (string) UriResolver::resolve(new Uri($url), new Uri((string) $next)) + : null; + } + $this->cache->save($cacheKey, $items, $this->config->getInt('api.users_ttl')); } else { - $info = $this->getClient()->getAccountInfo($reset); - $this->cache->save($cacheKey, $info, $this->config->getInt('api.users_ttl')); + $this->io->debug('Loaded SSH keys from cache'); } - return $info; + return array_map(fn(array $item): SshKey => SshKey::fromData($item), $items); } /** - * Shortcut to return the ID of the current user. + * Get a single SSH key belonging to the logged-in user. + * + * @param string $id The key's ID. + * + * @return SshKey|null The key, or null if it does not exist. */ - public function getMyUserId(bool $reset = false): string + public function getSshKey(string $id): ?SshKey { - $id = $this->getClient()->getMyUserId($reset); - if (!$id) { - throw new \RuntimeException('No user ID found for the current session.'); + try { + $response = $this->getHttpClient()->request('GET', $this->sshKeysUrl() . '/' . rawurlencode($id)); + } catch (BadResponseException $e) { + if ($e->getResponse()->getStatusCode() === 404) { + return null; + } + throw ApiResponseException::wrapGuzzleException($e); } - return $id; + + return SshKey::fromData((array) Utils::jsonDecode((string) $response->getBody(), true)); } /** - * Get the logged-in user's SSH keys. + * Add an SSH public key to the logged-in user's account. * - * @param bool $reset + * @param string $value The public key, in OpenSSH format. + * @param string|null $label A human-readable label for the key. * - * @return SshKey[] + * @return SshKey The newly created key. */ - public function getSshKeys(bool $reset = false): array + public function addSshKey(string $value, ?string $label = null): SshKey + { + $payload = ['value' => $value]; + if ($label !== null && $label !== '') { + $payload['label'] = $label; + } + try { + $response = $this->getHttpClient()->request('POST', $this->sshKeysUrl(), ['json' => $payload]); + } catch (BadResponseException $e) { + // The command gives conflicts a purpose-specific message. + if ($e->getResponse()->getStatusCode() === 409) { + throw $e; + } + throw ApiResponseException::wrapGuzzleException($e); + } + $this->clearSshKeysCache(); + + return SshKey::fromData((array) Utils::jsonDecode((string) $response->getBody(), true)); + } + + /** + * Delete an SSH key from the logged-in user's account. + * + * @param string $id The key's ID. + */ + public function deleteSshKey(string $id): void { - $data = $this->getLegacyAccountInfo($reset); + try { + $this->getHttpClient()->request('DELETE', $this->sshKeysUrl() . '/' . rawurlencode($id)); + } catch (BadResponseException $e) { + throw ApiResponseException::wrapGuzzleException($e); + } + $this->clearSshKeysCache(); + } - return SshKey::wrapCollection($data['ssh_keys'], rtrim($this->config->getApiUrl(), '/') . '/', $this->getHttpClient()); + /** + * Clear the cached list of the logged-in user's SSH keys. + */ + public function clearSshKeysCache(): void + { + $this->cache->delete($this->sshKeysCacheKey()); } /** diff --git a/legacy/src/Service/SshKey.php b/legacy/src/Service/SshKey.php index 6aa747289..468f8f940 100644 --- a/legacy/src/Service/SshKey.php +++ b/legacy/src/Service/SshKey.php @@ -4,7 +4,7 @@ namespace Platformsh\Cli\Service; -use Platformsh\Client\Model\SshKey as SshKeyModel; +use Platformsh\Cli\Model\SshKey as SshKeyModel; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -109,7 +109,7 @@ private function listPublicKeys(bool $reset = false): array } /** - * Lists SSH key MD5 fingerprints in the user's account. + * Lists SSH key SHA-256 fingerprints in the user's account. * * @return string[] */ @@ -120,7 +120,10 @@ private function listAccountKeyFingerprints(): array return []; } - return \array_map(fn(SshKeyModel $sshKey) => $sshKey->fingerprint, $keys); + return \array_map( + fn(SshKeyModel $sshKey) => $sshKey->sha256, + \array_filter($keys, fn(SshKeyModel $sshKey) => $sshKey->active), + ); } /** @@ -158,7 +161,10 @@ public function findIdentityMatchingPublicKeys(array $fingerprints): ?string } /** - * Returns an MD5 hash of a public key that matches its server fingerprint. + * Returns the SHA-256 fingerprint of a public key, matching the API. + * + * The format is the one used by OpenSSH and reported by `ssh-keygen -l`: + * "SHA256:" followed by the unpadded base64 of the hash. * * @param string $filename An absolute path to the public key. * @@ -181,6 +187,6 @@ public function getPublicKeyFingerprint(string $filename): string throw new \RuntimeException('Failed to base64-decode public key: ' . $filename); } - return \md5($key); + return 'SHA256:' . \rtrim(\base64_encode(\hash('sha256', $key, true)), '='); } } diff --git a/legacy/tests/Command/SshKey/SshKeyListCommandTest.php b/legacy/tests/Command/SshKey/SshKeyListCommandTest.php new file mode 100644 index 000000000..13e43bf2e --- /dev/null +++ b/legacy/tests/Command/SshKey/SshKeyListCommandTest.php @@ -0,0 +1,60 @@ +createMock(Api::class); + $api->method('getSshKeys')->willReturn([$key]); + + $sshKey = $this->createMock(SshKey::class); + $sshKey->expects($this->once()) + ->method('findIdentityMatchingPublicKeys') + ->with([$key->sha256]) + ->willReturn('/home/test/.ssh/custom'); + + $table = $this->createMock(Table::class); + $table->method('formatIsMachineReadable')->willReturn(false); + $table->expects($this->once()) + ->method('render') + ->with( + [[ + 'id' => 'key-id', + 'label' => 'inactive key', + 'sha256' => 'SHA256:fingerprint', + 'active' => 'No', + 'path' => '/home/test/.ssh/custom.pub', + ]], + $this->arrayHasKey('active'), + ['id', 'label', 'active', 'path'], + ); + + $command = new SshKeyListCommand($api, new Config(), $sshKey, $table); + $tester = new CommandTester($command); + + $this->assertSame(0, $tester->execute([])); + } +} diff --git a/legacy/tests/Service/ApiSshKeyTest.php b/legacy/tests/Service/ApiSshKeyTest.php new file mode 100644 index 000000000..504e0ef05 --- /dev/null +++ b/legacy/tests/Service/ApiSshKeyTest.php @@ -0,0 +1,139 @@ +jsonResponse([ + 'items' => [$this->keyData('key-1')], + '_links' => ['next' => ['href' => '?page=2']], + ]), + $this->jsonResponse(['items' => [$this->keyData('key-2')]]), + ]); + $api = $this->createApi($handler); + + $this->assertSame(['key-1', 'key-2'], array_map(fn($key) => $key->id, $api->getSshKeys())); + $this->assertSame(['key-1', 'key-2'], array_map(fn($key) => $key->id, $api->getSshKeys())); + $this->assertCount(0, $handler); + } + + public function testRejectsCircularPagination(): void + { + $handler = new MockHandler([ + $this->jsonResponse([ + 'items' => [], + '_links' => ['next' => ['href' => '/api/users/user-id/ssh-keys']], + ]), + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('circular pagination link'); + $this->createApi($handler)->getSshKeys(); + } + + public function testGetReturnsNullForNotFound(): void + { + $api = $this->createApi(new MockHandler([new Response(404)])); + + $this->assertNull($api->getSshKey('missing')); + } + + public function testApiErrorsIncludeResponseDetails(): void + { + $api = $this->createApi(new MockHandler([ + $this->jsonResponse(['detail' => 'The service is unavailable.'], 503), + ])); + + try { + $api->getSshKeys(); + $this->fail('Expected an HTTP error.'); + } catch (BadResponseException $e) { + $this->assertStringContainsString('[detail] The service is unavailable.', $e->getMessage()); + } + } + + public function testMutationsClearTheCachedList(): void + { + $handler = new MockHandler([ + $this->jsonResponse(['items' => [$this->keyData('old')]]), + $this->jsonResponse($this->keyData('new'), 201), + $this->jsonResponse(['items' => [$this->keyData('old'), $this->keyData('new')]]), + new Response(204), + $this->jsonResponse(['items' => [$this->keyData('old')]]), + ]); + $api = $this->createApi($handler); + + $this->assertCount(1, $api->getSshKeys()); + $api->addSshKey('ssh-ed25519 AAAA', 'new'); + $this->assertCount(2, $api->getSshKeys()); + $api->deleteSshKey('new'); + $this->assertCount(1, $api->getSshKeys()); + $this->assertCount(0, $handler); + } + + private function createApi(MockHandler $handler): Api + { + $client = new Client(['handler' => HandlerStack::create($handler)]); + $config = new Config([ + 'PLATFORMSH_CLI_API_URL' => 'https://api.example.test/api', + 'PLATFORMSH_CLI_SESSION_ID' => 'ssh-key-test', + ]); + $this->assertSame('https://api.example.test/api', $config->getApiUrl()); + $this->assertSame('ssh-key-test', $config->getSessionId()); + + return new class ($client, $config) extends Api { + public function __construct(private ClientInterface $httpClient, Config $config) + { + parent::__construct($config, new ArrayCache(), new BufferedOutput()); + } + + public function getHttpClient(): ClientInterface + { + return $this->httpClient; + } + + public function getMyUserId(bool $reset = false): string + { + return 'user-id'; + } + }; + } + + /** @param array $data */ + private function jsonResponse(array $data, int $status = 200): Response + { + return new Response($status, ['Content-Type' => 'application/json'], json_encode($data, JSON_THROW_ON_ERROR)); + } + + /** @return array */ + private function keyData(string $id): array + { + return [ + 'id' => $id, + 'sha256' => 'SHA256:' . $id, + 'value' => 'ssh-ed25519 AAAA', + 'label' => $id, + 'active' => true, + 'user_id' => 'user-id', + 'created_at' => '2026-01-01T00:00:00Z', + 'updated_at' => '2026-01-01T00:00:00Z', + ]; + } +} diff --git a/legacy/tests/Service/SshKeyTest.php b/legacy/tests/Service/SshKeyTest.php new file mode 100644 index 000000000..a238e585f --- /dev/null +++ b/legacy/tests/Service/SshKeyTest.php @@ -0,0 +1,89 @@ +set(InputInterface::class, new ArrayInput([])); + $container->set(OutputInterface::class, new BufferedOutput()); + $container->set(Config::class, new Config()); + $sshKey = $container->get(SshKey::class); + \assert($sshKey instanceof SshKey); + $this->sshKey = $sshKey; + $this->tempDirSetUp(); + } + + /** + * The API reports SHA-256 fingerprints in OpenSSH's format, so the local + * ones must match, or no local identity will ever be matched to an account. + */ + public function testGetPublicKeyFingerprint(): void + { + // An ed25519 public key and its fingerprint, as reported by ssh-keygen. + $value = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL4dwyaTPzTnnLbCTIU3TzT/qFtCG7SFwBnGXbFtsuKX test@example.com'; + $expected = 'SHA256:Zc8rf0C3ZFAVs8mnWl4r6jKmJN8kutsoBK2h4UkXPp8'; + + $path = $this->tempDir . '/id_ed25519.pub'; + file_put_contents($path, $value . "\n"); + + $this->assertEquals($expected, $this->sshKey->getPublicKeyFingerprint($path)); + } + + public function testGetPublicKeyFingerprintFailsOnInvalidKey(): void + { + $path = $this->tempDir . '/invalid.pub'; + file_put_contents($path, 'not-a-key'); + + $this->expectException(\RuntimeException::class); + $this->sshKey->getPublicKeyFingerprint($path); + } + + public function testInactiveAccountKeysAreNotMatched(): void + { + $sshDir = $this->tempDir . '/.ssh'; + mkdir($sshDir); + $value = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL4dwyaTPzTnnLbCTIU3TzT/qFtCG7SFwBnGXbFtsuKX test@example.com'; + file_put_contents($sshDir . '/custom.pub', $value . "\n"); + file_put_contents($sshDir . '/custom', 'private key placeholder'); + + $api = $this->createMock(Api::class); + $api->method('getSshKeys')->willReturn([ + new SshKeyModel( + 'key-id', + 'SHA256:Zc8rf0C3ZFAVs8mnWl4r6jKmJN8kutsoBK2h4UkXPp8', + $value, + 'inactive key', + false, + 'user-id', + '2026-01-01T00:00:00Z', + '2026-01-01T00:00:00Z', + ), + ]); + $config = new Config(['PLATFORMSH_CLI_HOME' => (string) $this->tempDir]); + $this->assertSame($this->tempDir, $config->getHomeDirectory()); + $service = new SshKey($config, $api, new BufferedOutput()); + + $this->assertFalse($service->hasLocalKey()); + } +}