Validando chave ssh pública no Laravel
Tags: validation, laravel, php, pest
Published: 2023-09-11 20:40:49

Quando somos chamados para desenvolver uma aplicação, devemos ter em mente que podemos ter que lidar com vários tipos de problemas, aqueles que talvez jamais tenhamos imaginado em enfrentar. Contudo, vez ou outra precisamos sair de nossa zona de conforto.

## Entendendo o problema

Há alguns dias, fui acionado para construir uma feature que iria receber a chave pública de um dev e iria enviá-la, posteriormente, para o forge, fazendo com que o usuário tivesse acesso ssh aos devidos servidores.

> Maravilha Matheusão, como vou validar essa tipo de dado?

Incialmente a gente pensa em validar o básico, como o tamanho da string, se ela já existe no banco de dados, etc:

```php
'ssh_key' => ['nullable', 'string', 'unique:users,ssh_key', 'max:5000']
```

Beleza, mas e se o meu usuário passar, sei lá, todas as letras do alfabeto? Infelizmente vai passar pela validação 🙁.

## À procura da validação perfeita

Pesquisei bastante à respeito de como realizar essa validação. Em vários blogs, vi muita gente indicando usar funções nativas, como o **`openssl_verify`,** **`openssl_get_publickey`** ou a **`openssl_pkey_get_details`,** mas elas infelizmente não funcionaram para o que eu precisava (Lembre-se, uma chave SSH é diferente de uma chave SSL, por isso essas funções não funcionam). Vi em outros fóruns um pessoal dizendo para utilizar o package [https://phpseclib.com/](https://phpseclib.com/). Mas para pra pensar, pra que instalar um pacote que você só vai utilizar uma classe e somente um método dela?

Eu vejo isso como um acoplamento totalmente desnecessário, mas enfim…

## Indo um pouco mais fundo

Depois de alguma pesquisa, vi que podemos usar o `ssk-keygen` para validar essa string pra gente, mas como?

Para isso, podemos usar duas flags, a `-l` para pegar o fingerprint e o `-f` para indicar o caminho do arquivo. Então o nosso comando ficaria assim:

```bash
ssh-keygen -lf /path/to/my/file.pub
```

E dessa forma poderemos checar se nossa chave SSH é válida ou não.

## Criando nosso comando de checagem

O Laravel trouxe, a partir da versão 10, um componente chamado [Process](https://laravel.com/docs/10.x/processes), que nada mais é do que um wrapper ao redor do componente Process do [Symfony](https://symfony.com/doc/current/components/process.html). É com esse carinha que vamos fazer a mágica.

> Logicamente que poderíamos usa a função `exec`, nativa do php. Porém, se você acha que não é necessário usar este wrapper, fique à vontade 🙂👍🏻.

Vamos pensar o que precisamos fazer:

- Preciso receber a string contendo a chave do usuário.
- Preciso salvar essa string em algum lugar acessível.
- Preciso chamar o comando `ssh-keygen` com o caminho do arquivo.
- Preciso destruir o arquivo após a validação.

### Configurando as Coisas

Vamos criar um diretório dentro de `storage/app` chamado `ssh`. Não se esqueça de remover esse novo diretório do seu versionamento:

_storage/app/.gitignore_
```bash
*
!public/
!.gitignore
!ssh/
```

_storage/app/ssh/.gitignore_
```bash
*
!.gitignore
```

### Escrevendo nossa classe

Agora podemos criar a nossa classe que fará a interação com o `ssh-keygen`

**App/Terminal/ValidateSsh.php**

```php
<?php

declare(strict_types=1);

namespace App\Terminal;

use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;

class ValidateSsh
{
    private string $keyPath;

    public function __construct(
        private readonly string $content
    ) {
        $this->keyPath = storage_path('app/ssh/' . Str::uuid() . '.pub');

        file_put_contents($this->keyPath, $this->content);
    }

    public function __invoke(): bool
    {
        return Process::run(
            command: 'ssh-keygen -lf ' . $this->keyPath . ' && rm ' . $this->keyPath,
        )->successful();
    }
}
```

Maravilha, a nossa classe está prontinha pra ser utilizada.

* Recebe o conteúdo e salva com um nome aleatório.
* Checa o arquivo e em caso de sucesso, já o apaga também.

Agora, vamos escrever os nossos testes.

**tests/Unit/Terminal/ValidateSshTest.php**

```php
<?php

declare(strict_types=1);

use App\Terminal\ValidateSsh;

it('should return true if process if file is valid', function (string $key) {
    $validateSsh = new ValidateSsh($key);

    expect($validateSsh())->toBeTruthy();
})->with([
    'RSA'   => 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQD3vYKSuh7rJf+NtWn04CFyT9+nmx+i+/sP+yMN9ueJ+Rd5Ku6d9kgscK2xwlRlkcA0sethslu0WUsG81RC1lVpF6iLrc/9O45ZhEY1CB/7dofr+7ZNwu/DJtbW6YE7oyT5G97BUW763TMq/YO9/xjMToetElTEJ4hUVWdP8q93b3MVHBazk2PEuS05wzP4p5XeQnhKq4LISetJFEgI8Y+HEpK29GiU/18fhaGZvdVwOToOxTwEwBbS3fTLNkBaUTWw9q3i7S60RRncBCHppcs2irrzw7yt7ZQOnut/BIjIGESoxx+N4ZrpTmX6P5d3/9Duk40Mfwh1ftsvze6o5AW4Xi0tki8b6bsMXmO7SapqVdiMZ5/4BWOkqHWhi926qz7I9NWoZuVFAUpSoe6fObzQBRooVp7ARw7gJ4C+Q4xc1gJJkZoQ/Wj/wHkVnbLw9M5+t5GjyWgDDOr5iyoGOyIwhuEFvATzIYH0z5B6anL1n6XQmeGh5OWKJN8wE5qVNTU= worker@envoyer.io',
    'EDCSA' => 'ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFvXWSVYzRnjxYsz/xKjOjAaPjzg98MMHaDulQYczTX28xlsMmFkviCeCCv7CLh19ydoH4LNKpvgTGiMXz8ib68= worker@envoyer.',
]);

it('should return false if ssh file is invalid', function () {
    $validateSsh = new ValidateSsh('a simple text file');

    expect($validateSsh())->toBeFalsy();
});
```

### Escrevendo nossa rule

Pensa que acabou? Não mesmo. A responsabilidade da classe `ValidateSsh` é apenas a de verificar se a chave é válida ou não.

Vamos criar uma rule para que possamos fazer uso dessa validação.

```bash
php artisan make:rule IsSshKeyValid
```

Maravilha, agora, podemos fazer o seguinte:

```php
<?php

declare(strict_types=1);

namespace App\Rules;

use App\Terminal\ValidateSsh;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;

class IsSshKeyValid implements ValidationRule
{
    /**
     * @param Closure(string): PotentiallyTranslatedString $fail
     */
    public function validate(
        string $attribute,
        mixed $value,
        Closure $fail
    ): void {
        $validateSsh = new ValidateSsh($value);

        if (!$validateSsh()) {
            $fail('The :attribute is not a valid SSH key.');
        }
    }
}
```

Com isso, já estamos prontos para fazer os nossos testes http ❤️

## Testando nosso nossa chamada Http

Antes de prosseguir para os testes http, precisamos adicionar a nossa rule em nossas regras de validação:

```php
'ssh_key' => [
    'nullable',
    'string',
    'unique:users,ssh_key',
    'max:5000',
    new IsSshKeyValid(),
],
```

E nossos testes para esse campo podem ficar dessa maneira:

```php
it('should validate `ssh_key` field', function (mixed $value, string $error) {
    login();

    postJson(route('api.users.store'), ['ssh_key' => $value])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['ssh_key' => $error]);
})->with([
    fn () => [5000, __('validation.string', ['attribute' => 'ssh key'])],
    fn () => [str_repeat('a', 5001), __('validation.max.string', ['attribute' => 'ssh key', 'max' => 5000])],
    fn () => ['aa', 'The ssh key is not a valid SSH key.'],
    function () {
        $user = User::factory()
            ->create([
                'ssh_key' => 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQD3vYKSuh7rJf+NtWn04CFyT9+nmx+i+/sP+yMN9ueJ+Rd5Ku6d9kgscK2xwlRlkcA0sethslu0WUsG81RC1lVpF6iLrc/9O45ZhEY1CB/7dofr+7ZNwu/DJtbW6YE7oyT5G97BUW763TMq/YO9/xjMToetElTEJ4hUVWdP8q93b3MVHBazk2PEuS05wzP4p5XeQnhKq4LISetJFEgI8Y+HEpK29GiU/18fhaGZvdVwOToOxTwEwBbS3fTLNkBaUTWw9q3i7S60RRncBCHppcs2irrzw7yt7ZQOnut/BIjIGESoxx+N4ZrpTmX6P5d3/9Duk40Mfwh1ftsvze6o5AW4Xi0tki8b6bsMXmO7SapqVdiMZ5/4BWOkqHWhi926qz7I9NWoZuVFAUpSoe6fObzQBRooVp7ARw7gJ4C+Q4xc1gJJkZoQ/Wj/wHkVnbLw9M5+t5GjyWgDDOr5iyoGOyIwhuEFvATzIYH0z5B6anL1n6XQmeGh5OWKJN8wE5qVNTU= worker@envoyer.io',
            ]);

        return [$user->ssh_key, __('validation.unique', ['attribute' => 'ssh key'])];
    },
]);

it('should store an user', function () {
    login();

    $data = [
        'name'    => 'Matheus Santos',
	    'email'   => 'matheusao@my-company.com',
	    'ssh_key' => 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQD3vYKSuh7rJf+NtWn04CFyT9+nmx+i+/sP+yMN9ueJ+Rd5Ku6d9kgscK2xwlRlkcA0sethslu0WUsG81RC1lVpF6iLrc/9O45ZhEY1CB/7dofr+7ZNwu/DJtbW6YE7oyT5G97BUW763TMq/YO9/xjMToetElTEJ4hUVWdP8q93b3MVHBazk2PEuS05wzP4p5XeQnhKq4LISetJFEgI8Y+HEpK29GiU/18fhaGZvdVwOToOxTwEwBbS3fTLNkBaUTWw9q3i7S60RRncBCHppcs2irrzw7yt7ZQOnut/BIjIGESoxx+N4ZrpTmX6P5d3/9Duk40Mfwh1ftsvze6o5AW4Xi0tki8b6bsMXmO7SapqVdiMZ5/4BWOkqHWhi926qz7I9NWoZuVFAUpSoe6fObzQBRooVp7ARw7gJ4C+Q4xc1gJJkZoQ/Wj/wHkVnbLw9M5+t5GjyWgDDOr5iyoGOyIwhuEFvATzIYH0z5B6anL1n6XQmeGh5OWKJN8wE5qVNTU= worker@envoyer.io',
    ];

    postJson(route('api.users.store'), $data)
        ->assertCreated();

    assertDatabaseHas(Users::class, $data);
});
```

Legal não?

Agora, posso cadastrar os usuários no meu sistema sem me preocupar com aqueles engraçadinhos que vão passar um `aaaaaaaa` no campo `ssh_key` 😃.

E lembre-se, às vezes precisamos sair do óbvio para encontrar as soluções para alguns problemas. Quanto mais mente-aberta formos, mais rápido conseguimos progredir e aprender novas coisas.

Um abraço e até a próxima 😗 🧀