feat(ssh-keys): name an added SSH key so it is identifiable
The "add SSH key" modal had no name field — keys appeared only by comment + fingerprint, so additional keys weren't tellable apart. Add a name field; the name becomes the key's OpenSSH comment (what the server list shows), for both generated and pasted keys. Sanitised (control chars stripped, whitespace collapsed, capped at 64 chars) so a crafted name cannot inject a second authorized_keys line — proven by test, on top of addAuthorizedKey()'s own whitespace-collapse + base64 encoding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
7ec2c065bb
commit
d56bdadd14
|
|
@ -18,6 +18,8 @@ class AddSshKey extends ModalComponent
|
|||
{
|
||||
public int $serverId;
|
||||
|
||||
public string $keyName = '';
|
||||
|
||||
public string $publicKey = '';
|
||||
|
||||
public ?string $generatedPrivate = null;
|
||||
|
|
@ -40,10 +42,34 @@ class AddSshKey extends ModalComponent
|
|||
{
|
||||
$this->error = null;
|
||||
$key = EC::createKey('ed25519');
|
||||
$this->publicKey = $key->getPublicKey()->toString('OpenSSH', ['comment' => 'clusev-key']);
|
||||
// The comment is what identifies the key in the server's list — use the operator's name.
|
||||
$this->publicKey = $key->getPublicKey()->toString('OpenSSH', ['comment' => $this->keyComment() ?: 'clusev-key']);
|
||||
$this->generatedPrivate = (string) $key->toString('OpenSSH');
|
||||
}
|
||||
|
||||
/** Sanitised key label, safe as an authorized_keys comment (single line, ≤64 chars), or '' if none. */
|
||||
private function keyComment(): string
|
||||
{
|
||||
$name = preg_replace('/[\x00-\x1F\x7F]+/', ' ', $this->keyName) ?? '';
|
||||
$name = trim(preg_replace('/\s+/', ' ', $name) ?? '');
|
||||
|
||||
return mb_substr($name, 0, 64);
|
||||
}
|
||||
|
||||
/** Set the OpenSSH comment of a public-key line to $comment (only when a name was given). */
|
||||
private function withComment(string $pub, string $comment): string
|
||||
{
|
||||
if ($comment === '') {
|
||||
return $pub;
|
||||
}
|
||||
$parts = preg_split('/\s+/', trim($pub), 3);
|
||||
if (count($parts) < 2) {
|
||||
return $pub; // not a recognisable key line — let the SSH layer reject it
|
||||
}
|
||||
|
||||
return $parts[0].' '.$parts[1].' '.$comment;
|
||||
}
|
||||
|
||||
public function save(FleetService $fleet): void
|
||||
{
|
||||
$this->error = null;
|
||||
|
|
@ -55,8 +81,11 @@ class AddSshKey extends ModalComponent
|
|||
return;
|
||||
}
|
||||
|
||||
$name = $this->keyComment();
|
||||
|
||||
try {
|
||||
$fleet->addAuthorizedKey($server, $this->publicKey);
|
||||
// Force the key's comment to the operator's name so it is identifiable in the list.
|
||||
$fleet->addAuthorizedKey($server, $this->withComment($this->publicKey, $name));
|
||||
} catch (Throwable $e) {
|
||||
$this->error = $e->getMessage();
|
||||
|
||||
|
|
@ -68,7 +97,7 @@ class AddSshKey extends ModalComponent
|
|||
'server_id' => $server->id,
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'ssh_key.add',
|
||||
'target' => $server->name,
|
||||
'target' => $name !== '' ? $server->name.' · '.$name : $server->name,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ return [
|
|||
// ── Add SSH key ───────────────────────────────────────────────────────
|
||||
'add_ssh_key' => [
|
||||
'title' => 'SSH-Schlüssel hinzufügen',
|
||||
'name_label' => 'Name (optional)',
|
||||
'name_placeholder' => 'z.B. Backup-Laptop · Admin-Notebook',
|
||||
'subtitle_before' => 'Öffentlichen Schlüssel einfügen — er wird in',
|
||||
'subtitle_after' => 'ergänzt.',
|
||||
'public_key_label' => 'Öffentlicher Schlüssel',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ return [
|
|||
// ── Add SSH key ───────────────────────────────────────────────────────
|
||||
'add_ssh_key' => [
|
||||
'title' => 'Add SSH key',
|
||||
'name_label' => 'Name (optional)',
|
||||
'name_placeholder' => 'e.g. Backup laptop · Admin notebook',
|
||||
'subtitle_before' => 'Paste a public key — it is appended to',
|
||||
'subtitle_after' => '.',
|
||||
'public_key_label' => 'Public key',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.add_ssh_key.name_label') }}</label>
|
||||
<input wire:model="keyName" type="text" maxlength="64" placeholder="{{ __('modals.add_ssh_key.name_placeholder') }}"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="mb-1.5 flex items-center justify-between">
|
||||
<label class="font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.add_ssh_key.public_key_label') }}</label>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Modals\AddSshKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use App\Services\FleetService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AddSshKeyTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function server(): Server
|
||||
{
|
||||
return Server::create(['name' => 'box', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
|
||||
}
|
||||
|
||||
/** Mock FleetService and capture the public key actually passed to addAuthorizedKey(). */
|
||||
private function captureInstalledKey(string &$captured): void
|
||||
{
|
||||
$fleet = Mockery::mock(FleetService::class);
|
||||
$fleet->shouldReceive('addAuthorizedKey')->once()
|
||||
->andReturnUsing(function ($s, $pub) use (&$captured) {
|
||||
$captured = $pub;
|
||||
});
|
||||
app()->instance(FleetService::class, $fleet);
|
||||
}
|
||||
|
||||
public function test_the_form_renders_the_name_field_without_a_leaked_key(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
Livewire::test(AddSshKey::class, ['serverId' => $this->server()->id])
|
||||
->assertSee(__('modals.add_ssh_key.name_label'))
|
||||
->assertDontSee('add_ssh_key.name_label'); // the translation must resolve, not leak the key
|
||||
}
|
||||
|
||||
public function test_the_name_becomes_the_key_comment(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
$server = $this->server();
|
||||
$captured = '';
|
||||
$this->captureInstalledKey($captured);
|
||||
|
||||
Livewire::test(AddSshKey::class, ['serverId' => $server->id])
|
||||
->set('keyName', 'Backup Laptop')
|
||||
->set('publicKey', 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc old-comment')
|
||||
->call('save');
|
||||
|
||||
// The pasted comment is replaced by the operator's name so the key is identifiable.
|
||||
$this->assertSame('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc Backup Laptop', $captured);
|
||||
}
|
||||
|
||||
public function test_a_name_with_newlines_cannot_inject_a_second_key(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
$server = $this->server();
|
||||
$captured = '';
|
||||
$this->captureInstalledKey($captured);
|
||||
|
||||
Livewire::test(AddSshKey::class, ['serverId' => $server->id])
|
||||
->set('keyName', "Evil\nssh-ed25519 INJECTED")
|
||||
->set('publicKey', 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc')
|
||||
->call('save');
|
||||
|
||||
// Newline collapsed → the would-be injected key stays a harmless comment on ONE line.
|
||||
$this->assertStringNotContainsString("\n", $captured);
|
||||
$this->assertSame('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc Evil ssh-ed25519 INJECTED', $captured);
|
||||
}
|
||||
|
||||
public function test_without_a_name_the_pasted_comment_is_kept(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
$server = $this->server();
|
||||
$captured = '';
|
||||
$this->captureInstalledKey($captured);
|
||||
|
||||
Livewire::test(AddSshKey::class, ['serverId' => $server->id])
|
||||
->set('keyName', '')
|
||||
->set('publicKey', 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc original@host')
|
||||
->call('save');
|
||||
|
||||
$this->assertSame('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc original@host', $captured);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue