CluPilotCloud/app/Support/HostEnrolment.php

140 lines
5.1 KiB
PHP

<?php
namespace App\Support;
use App\Models\Host;
use App\Services\Wireguard\Keypair;
use App\Services\Wireguard\WireguardHub;
use Illuminate\Support\Str;
/**
* Der Einmal-Code und das Schlüsselpaar, mit denen ein Host sich übernehmen
* lässt.
*
* Das Henne-Ei-Problem und seine Auflösung stehen in §5 der Spec: damit der Hub
* den Host hereinlässt, muss er dessen öffentlichen Schlüssel kennen — erzeugte
* der Host ihn selbst, müsste er ihn melden, BEVOR der Tunnel steht, und genau
* dafür wäre ein öffentlicher Endpunkt nötig. Also erzeugt CluPilot das Paar
* hier, nimmt den Peer sofort auf und gibt den privaten Teil in der kopierten
* Befehlszeile mit.
*
* Der private Schlüssel wird NICHT gespeichert. Er steht in der Zwischenablage
* des Betreibers und sonst nirgends, und Task 9 des Bootstrap-Skripts tauscht
* ihn ohnehin gegen einen frisch auf der Maschine erzeugten — danach ist er
* wertlos. Ihn aufzubewahren hieße, ein Geheimnis mit einer Lebensdauer von
* Minuten dauerhaft zu lagern.
*/
final class HostEnrolment
{
/**
* Großzügig, weil der Code nichts nach außen öffnet: er ist nur aus dem
* WireGuard-Subnetz überhaupt verwendbar (Spec §5). Er begrenzt, wie lange
* ein Betreiber zwischen „Host anlegen" und „Rettungssystem starten" Zeit
* hat, und einen Server zu bestellen dauert manchmal einen Nachmittag.
*/
private const LIFETIME_HOURS = 24;
/**
* SHA-256 statt bcrypt, mit Absicht.
*
* Der Code sind 32 Zeichen aus einem kryptografischen Zufallsgenerator —
* er hat die Entropie, die ein Passwort erst durch Streckung bekommt, und
* ist gegen Erraten nicht zu verstärken. Entscheidend ist die andere Seite:
* beide Endpunkte müssen den Host ÜBER den Code finden. Mit bcrypt ginge das
* nur, indem man jede Zeile der Tabelle durchprobiert; mit einem Hash ist es
* ein indizierter Zugriff.
*/
private static function hash(string $code): string
{
return hash('sha256', $code);
}
/** Legt Code, Schlüsselpaar und Peer an und gibt den Klartext-Code zurück. */
public static function issue(Host $host): string
{
return self::issueWithKeys($host)['code'];
}
/**
* Wie `issue()`, gibt aber auch den privaten Schlüssel zurück — den braucht
* die Befehlszeile, und er ist danach nirgends mehr zu holen.
*
* @return array{code: string, private_key: string, public_key: string, wg_ip: string}
*/
public static function issueWithKeys(Host $host): array
{
$hub = app(WireguardHub::class);
$keypair = Keypair::generate();
// Die Tunneladresse bleibt, wenn es schon eine gibt. Ein zweiter
// Übernahmeversuch soll denselben Host an derselben Adresse ergeben —
// die Spalte ist eindeutig indiziert, und eine neue Adresse hieße, die
// alte für immer belegt zu lassen.
$ip = $host->wg_ip ?: $hub->allocateIp();
// Der Reihenfolge nach: erst den neuen Peer aufnehmen, dann den alten
// entfernen. Dieselbe Regel wie beim Schlüsseltausch in §6 — dazwischen
// darf es keinen Moment geben, in dem gar kein Peer eingetragen ist.
$previous = $host->wg_pubkey;
$hub->addPeer($keypair->publicKey, $ip);
if (filled($previous) && $previous !== $keypair->publicKey) {
$hub->removePeer($previous);
}
$code = Str::random(32);
$host->forceFill([
'wg_ip' => $ip,
'wg_pubkey' => $keypair->publicKey,
'enrolment_code_hash' => self::hash($code),
'enrolment_expires_at' => now()->addHours(self::LIFETIME_HOURS),
// Ein neuer Code macht den alten wertlos. Sonst hätte eine Maschine,
// die im Rettungssystem hängengeblieben ist, weiter einen gültigen
// Ausweis für einen Host, den der Betreiber gerade neu übernimmt.
'enrolment_used_at' => null,
])->save();
return [
'code' => $code,
'private_key' => $keypair->privateKey,
'public_key' => $keypair->publicKey,
'wg_ip' => $ip,
];
}
/**
* Löst einen gültigen, unverbrauchten Code auf, OHNE ihn zu verbrauchen.
*
* Das ist der Weg für `/host/progress`: die Fortschrittsmeldungen kommen vor
* der Registrierung, und würden sie den Code verbrauchen, könnte der Host
* sich nach seiner ersten Meldung nie mehr registrieren.
*/
public static function resolve(string $code): ?Host
{
if ($code === '') {
return null;
}
return Host::query()
->where('enrolment_code_hash', self::hash($code))
->whereNull('enrolment_used_at')
->where('enrolment_expires_at', '>', now())
->first();
}
/** Löst einen Code auf und verbraucht ihn. Der Weg für `/host/register`. */
public static function claim(string $code): ?Host
{
$host = self::resolve($code);
if ($host === null) {
return null;
}
$host->forceFill(['enrolment_used_at' => now()])->save();
return $host;
}
}