140 lines
5.1 KiB
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;
|
|
}
|
|
}
|