CluPilotCloud/tests/Feature/Admin/SshKeypairTest.php

124 lines
5.1 KiB
PHP

<?php
use App\Livewire\Admin\ConfirmGenerateSshKey;
use App\Livewire\Admin\Integrations;
use App\Services\Secrets\SecretVault;
use App\Services\Ssh\Keypair;
use App\Support\ProvisioningSettings;
use Livewire\Livewire;
use phpseclib3\Crypt\EC;
use phpseclib3\Crypt\PublicKeyLoader;
/**
* Der Tresor hielt `ssh.private_key` write-only und hatte keinen Erzeuger: das
* Paar musste in einer Shell entstehen und von Hand hineinkopiert werden. Eine
* Bereitschaftsseite, die etwas bemängelt, das nur eine Shell beheben kann,
* hat die Arbeit verschoben statt erledigt.
*/
beforeEach(function () {
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
});
it('mints a key the SSH client itself can load', function () {
// Die eigentliche Zusicherung, und der Grund für phpseclib statt eines
// selbstgeschriebenen OpenSSH-Rahmens: PhpseclibRemoteShell::connectWithKey()
// reicht den gespeicherten Text an genau dieses PublicKeyLoader::load()
// weiter. Was diese Prüfung lädt, ist damit dasselbe, was sich später an
// einem Host anmeldet — ein Format, das nur „nach OpenSSH aussieht", fiele
// erst bei der ersten Host-Übernahme auf.
$pair = Keypair::generate();
$loaded = PublicKeyLoader::load($pair->privateKey);
expect($loaded)->toBeInstanceOf(EC\PrivateKey::class)
->and($loaded->getCurve())->toBe('Ed25519');
});
it('publishes a public half that is one authorized_keys line and belongs to the private half', function () {
// EstablishSshTrust hängt genau diesen String mit `echo … >> authorized_keys`
// an. Ein Zeilenumbruch darin schriebe zwei kaputte Zeilen; ein öffentlicher
// Teil, der nicht zum privaten gehört, ergäbe eine Übernahme, die beim
// Schlüssel-Login scheitert, nachdem das Passwort schon verworfen wurde.
$pair = Keypair::generate();
expect($pair->publicKey)->toStartWith('ssh-ed25519 ')
->and($pair->publicKey)->not->toContain("\n")
->and(PublicKeyLoader::load($pair->privateKey)->getPublicKey()->toString('OpenSSH', ['comment' => 'clupilot']))
->toBe($pair->publicKey);
});
it('never mints the same key twice', function () {
expect(Keypair::generate()->privateKey)->not->toBe(Keypair::generate()->privateKey);
});
it('stores the private half in the vault and publishes only the public half', function () {
$owner = operator('Owner');
$page = Livewire::actingAs($owner, 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->call('generateSshKey')
->assertHasNoErrors();
$private = (string) app(SecretVault::class)->get('ssh.private_key');
$public = ProvisioningSettings::sshPublicKey();
expect($private)->toContain('BEGIN OPENSSH PRIVATE KEY')
->and($public)->toStartWith('ssh-ed25519 ')
// Das Feld auf der Seite zeigt den öffentlichen Teil, damit der
// Betreiber ihn ohne Umweg auf einen Host legen kann.
->and($page->get('sshPublicKey'))->toBe($public)
->and(PublicKeyLoader::load($private)->getPublicKey()->toString('OpenSSH', ['comment' => 'clupilot']))
->toBe($public);
});
it('never lets the generated private half reach the page', function () {
// Derselbe Maßstab wie für jeden anderen Tresorwert: gespeichert wird er,
// angezeigt nie. Ein erzeugter Schlüssel ist dabei der heikelste Fall — er
// entsteht in derselben Anfrage, die die Antwort schreibt.
$owner = operator('Owner');
$page = Livewire::actingAs($owner, 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->call('generateSshKey');
$private = (string) app(SecretVault::class)->get('ssh.private_key');
$body = json_encode($page->snapshot).$page->html();
expect($body)->not->toContain('BEGIN OPENSSH PRIVATE KEY');
foreach (array_slice(explode("\n", trim($private)), 1, -1) as $zeile) {
expect($body)->not->toContain(trim($zeile));
}
});
it('refuses to generate without a confirmed password', function () {
// Dieselbe Sperre wie Speichern und Vergessen: das Erzeugen ÜBERSCHREIBT
// den Schlüssel, mit dem diese Anwendung jeden Host erreicht.
Livewire::actingAs(operator('Owner'), 'operator')
->test(Integrations::class)
->call('generateSshKey')
->assertForbidden();
});
it('refuses to generate without the capability', function () {
Livewire::actingAs(operator('Admin'), 'operator')
->test(Integrations::class)
->call('generateSshKey')
->assertForbidden();
});
it('confirms in a modal before replacing the identity every host trusts', function () {
// R23, und hier mit echten Folgen: der bisherige öffentliche Teil bleibt in
// den authorized_keys jedes übernommenen Hosts stehen, der neue steht dort
// noch nicht. Zwischen Erzeugen und Ausrollen erreicht die Konsole keine
// einzige Maschine mehr.
Livewire::actingAs(operator('Owner'), 'operator')
->test(ConfirmGenerateSshKey::class)
->call('confirm')
->assertDispatched('ssh-key-generate-confirmed');
});