Files
Deploymentcenter/public/api/auth/v1/index.php
T
Deploymentcenter BotandClaude Opus 5 073d52ab7d feat(2.9.2): Nur geprueft in Hardware entstandene Schluessel als zweiter Faktor
Befund aus dem Betrieb: Ein Passkey aus 1Password liess sich als
Hardwarekey registrieren. Fuer das Deploymentcenter hinnehmbar, fuer ein
Protokoll, das ausdruecklich Hardware fordert, nicht.

Zwei Dinge, die wie eine Pruefung aussahen und keine waren - beide seit
2.9.0 gesetzt: authenticatorAttachment "cross-platform" steuert, was der
Browser anbietet, und ein Passwortverwalter meldet sich ebenso. Und
response.authenticatorAttachment setzt der Client, also genau die Instanz,
gegen die sich nichts beweisen laesst.

Was traegt, ist Basic Attestation. Neu geprueft wird die Kette bis zu einer
hinterlegten Herstellerwurzel; die drei FIDO-Wurzeln von Yubico liegen bei.
Ein x5c allein genuegt ausdruecklich nicht: Der Fall "eigenes Zertifikat,
Name Yubico, AAGUID eines echten YubiKey 5, gueltige Unterschrift" wurde
nachgebaut und wird abgewiesen - an der Kette, nicht an der Signatur.

Drei Stufen statt eines Schalters, weil alle bisher registrierten
Schluessel als nicht nachgewiesen gelten - auch echte Yubikeys. Bei
attestation "none" ersetzt der Browser die Geraetekennung durch Nullbytes,
bevor der Server sie sieht; es gab also nie eine Herkunft. Ein einzelner
Schalter haette beim Umlegen den eigenen Zugang gesperrt. Der Wechsel nach
"strict" wird zusaetzlich abgelehnt, solange das eigene Konto keinen
nachgewiesenen Schluessel hat.

- Attestation.php: packed und fido-u2f, Kettenpruefung gegen eigene
  Wurzeln statt gegen den Speicher des Betriebssystems, Gueltigkeit,
  Gegenprobe der AAGUID aus der Zertifikatserweiterung
- HardwarePolicy.php: off / register / strict, optionale Modellliste,
  Wurzeln aus der Konfiguration erweiterbar
- Migration 021: attested und drei Anzeigespalten, Vorgabe 0
- WebUI: Herkunft je Schluessel, Regel vor der Beruehrung angesagt
- testctl clear_credentials, damit ein Testlauf den naechsten nicht
  aussperrt
- Testreihe "hardware", 5 Faelle, ohne Krypto-Bibliothek

184 von 184 Testfaellen gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 10:44:19 +02:00

321 lines
13 KiB
PHP

<?php
/**
* /api/auth/v1/...
*
* Die beiden WebAuthn-Zeremonien als JSON-Endpunkt. Der Browser kann
* navigator.credentials nicht aus einem Formular heraus bedienen - er braucht
* die Vorgaben als JSON und schickt die Antwort ebenso zurueck.
*
* POST ?action=login_options Vorgaben fuer get() (Zwischenzustand)
* POST ?action=login_verify Antwort auf get() (Zwischenzustand)
* POST ?action=register_options Vorgaben fuer create() (angemeldet)
* POST ?action=register_verify Antwort auf create() (angemeldet)
*
* Bewusst NICHT ueber ApiAuth: Zwei der vier Aktionen laufen in einem
* Zustand, in dem noch niemand angemeldet ist. Sie an eine Klasse zu haengen,
* deren Aufgabe "authentifiziert oder 401" ist, hiesse, ihr eine Ausnahme
* beizubringen - und Ausnahmen in der Authentifizierung sind genau die
* Stellen, an denen man sich spaeter vertut.
*
* Der Notfalltoken laeuft nicht hier, sondern als gewoehnliches Formular in
* login.php. Er soll auch dann funktionieren, wenn JavaScript nicht laeuft -
* ein Ersatzweg, der dieselben Voraussetzungen hat wie der Hauptweg, ist
* keiner.
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../../src/bootstrap.php';
use Deploymentcenter\Core\Auth;
use Deploymentcenter\Core\Csrf;
use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Core\Logger;
use Deploymentcenter\Core\SecurityLogger;
use Deploymentcenter\Core\Webauthn\Attestation;
use Deploymentcenter\Core\Webauthn\CredentialRepo;
use Deploymentcenter\Core\Webauthn\HardwarePolicy;
use Deploymentcenter\Core\Webauthn\WebauthnServer;
// Kein publicCors: Diese Endpunkte arbeiten mit dem Sitzungscookie. Ein
// "Access-Control-Allow-Origin: *" waere hier das Gegenteil dessen, was
// gemeint ist.
Http::beginJson(['POST', 'OPTIONS'], false);
Auth::startSession();
$action = strtolower(Http::str('action') ?? '');
if (Http::method() !== 'POST') {
Http::fail(405, 'method_not_allowed', 'Dieser Endpunkt erwartet POST.');
}
$db = Db::init();
$repo = new CredentialRepo($db);
$policy = new HardwarePolicy($db);
switch ($action) {
// -----------------------------------------------------------------
// Anmelden, zweite Stufe
// -----------------------------------------------------------------
case 'login_options': {
$userId = Auth::pendingUserId();
if ($userId <= 0) {
Http::fail(401, 'no_pending_login', 'Es steht keine Anmeldung offen. Bitte von vorn beginnen.');
}
// Bei "strict" kommen nur geprueft in Hardware entstandene Schluessel
// in die Auswahl. Ein danach leeres Ergebnis ist kein Fehlerfall,
// sondern der erwartete Zustand direkt nach dem Umschalten - und die
// Meldung muss deshalb sagen, was zu tun ist, statt bloss "geht
// nicht".
$strict = $policy->appliesToLogin();
$ids = $repo->rawIdsForUser($userId, $strict);
if ($ids === []) {
if ($strict && $repo->countForUser($userId) > 0) {
Http::fail(
409,
'no_attested_credentials',
'Fuer dieses Konto ist zwar ein Schluessel hinterlegt, aber keiner davon hat '
. 'sich als Hardware ausgewiesen. Die Anmeldung laeuft ueber den Notfalltoken; '
. 'danach den Hardwarekey neu aufnehmen.'
);
}
Http::fail(409, 'no_credentials', 'Fuer dieses Konto ist kein Hardwarekey hinterlegt.');
}
$challenge = WebauthnServer::challenge();
Auth::storeChallenge($challenge);
Http::ok(['publicKey' => WebauthnServer::assertionOptions($challenge, $ids)]);
break;
}
case 'login_verify': {
$userId = Auth::pendingUserId();
if ($userId <= 0) {
Http::fail(401, 'no_pending_login', 'Es steht keine Anmeldung offen. Bitte von vorn beginnen.');
}
$challenge = Auth::takeChallenge();
if ($challenge === '') {
Http::fail(400, 'challenge_expired', 'Die Anfrage ist abgelaufen. Bitte erneut versuchen.');
}
$credentialId = WebauthnServer::b64uDecode((string)Http::str('id', ''));
$clientDataJson = WebauthnServer::b64uDecode((string)Http::str('clientDataJSON', ''));
$authenticatorRaw = WebauthnServer::b64uDecode((string)Http::str('authenticatorData', ''));
$signature = WebauthnServer::b64uDecode((string)Http::str('signature', ''));
if ($credentialId === '' || $clientDataJson === '' || $authenticatorRaw === '' || $signature === '') {
Http::fail(400, 'incomplete', 'Die Antwort des Browsers ist unvollstaendig.');
}
$credential = $repo->find($userId, $credentialId);
if ($credential === null) {
// Der Schluessel gehoert nicht zu diesem Konto. Das ist kein
// Bedienfehler, das ist ein Fehlversuch - und zaehlt als solcher.
Auth::recordSecondFactorFailure($db, 'unknown_credential');
Http::fail(403, 'unknown_credential', 'Dieser Hardwarekey ist fuer das Konto nicht hinterlegt.');
}
// allowCredentials ist eine Vorgabe an den Browser, keine Zusage des
// Clients. Wer die Anfrage von Hand stellt, kann jede Kennung
// einsetzen - also wird die Regel hier ein zweites Mal geprueft, an
// der Stelle, an der sie wirklich entscheidet.
if ($policy->appliesToLogin() && (int)($credential['attested'] ?? 0) !== 1) {
Auth::recordSecondFactorFailure($db, 'not_attested');
Http::fail(
403,
'not_attested',
'Dieser Schluessel hat sich bei der Aufnahme nicht als Hardware ausgewiesen '
. 'und ist unter der geltenden Regel nicht zugelassen.'
);
}
try {
$result = WebauthnServer::verifyAssertion(
$clientDataJson,
$authenticatorRaw,
$signature,
$challenge,
(string)$credential['public_key'],
(int)$credential['alg'],
(int)$credential['sign_count']
);
} catch (\Throwable $e) {
Auth::recordSecondFactorFailure($db, 'assertion_rejected');
Logger::warning('Hardwarekey abgelehnt', [
'user_id' => $userId,
'error' => $e->getMessage(),
]);
Http::fail(403, 'assertion_rejected', $e->getMessage());
}
$repo->touch((int)$credential['id'], $result['sign_count']);
if (!Auth::completeSecondFactor($db, 'hardware_key')) {
Http::fail(403, 'login_failed', 'Die Anmeldung konnte nicht abgeschlossen werden.');
}
Http::ok(['redirect' => '/index.php']);
break;
}
// -----------------------------------------------------------------
// Registrieren - nur aus einer bestehenden Anmeldung heraus
// -----------------------------------------------------------------
case 'register_options': {
if (!Auth::isLoggedIn()) {
Http::fail(401, 'unauthorized', 'Anmeldung erforderlich.');
}
Csrf::requireValid();
$userId = Auth::userId();
$challenge = WebauthnServer::challenge();
Auth::storeChallenge($challenge);
Http::ok([
'publicKey' => WebauthnServer::registrationOptions(
$challenge,
$userId,
Auth::username(),
$repo->rawIdsForUser($userId),
$policy->conveyance()
),
// Die Oberflaeche sagt vorher, was gefordert ist. Ohne diesen
// Hinweis erfuehre man die Regel erst nach dem Beruehren des
// Schluessels - und wuesste nicht, ob der Schluessel oder die
// Regel das Problem war.
'policy' => $policy->level(),
]);
break;
}
case 'register_verify': {
if (!Auth::isLoggedIn()) {
Http::fail(401, 'unauthorized', 'Anmeldung erforderlich.');
}
Csrf::requireValid();
$userId = Auth::userId();
$challenge = Auth::takeChallenge();
if ($challenge === '') {
Http::fail(400, 'challenge_expired', 'Die Anfrage ist abgelaufen. Bitte erneut versuchen.');
}
$clientDataJson = WebauthnServer::b64uDecode((string)Http::str('clientDataJSON', ''));
$attestationObject = WebauthnServer::b64uDecode((string)Http::str('attestationObject', ''));
if ($clientDataJson === '' || $attestationObject === '') {
Http::fail(400, 'incomplete', 'Die Antwort des Browsers ist unvollstaendig.');
}
$label = Http::str('label') ?? 'Hardwarekey';
$label = mb_substr(trim($label), 0, 64);
if ($label === '') {
$label = 'Hardwarekey';
}
$transports = Http::input('transports');
$transports = is_array($transports)
? mb_substr(implode(',', array_map('strval', $transports)), 0, 128)
: null;
try {
$registration = WebauthnServer::verifyRegistration(
$clientDataJson,
$attestationObject,
$challenge,
$policy->roots()
);
} catch (\Throwable $e) {
Logger::warning('Registrierung eines Hardwarekeys abgelehnt', [
'user_id' => $userId,
'error' => $e->getMessage(),
]);
Http::fail(400, 'registration_rejected', $e->getMessage());
}
if ($repo->find($userId, $registration['credential_id']) !== null) {
Http::fail(409, 'already_registered', 'Dieser Hardwarekey ist bereits hinterlegt.');
}
// Erst jetzt entscheidet die Regel. Die Zeremonie oben war fehlerfrei
// - der Schluessel ist echt und gehoert zu diesem Konto. Abgelehnt
// wird er, weil er nicht das ist, was hier gefordert ist. Diese
// Reihenfolge ist Absicht: Sie trennt "kaputt" von "nicht zugelassen",
// und nur so laesst sich die zweite Meldung ueberhaupt verstehen.
$verdict = $policy->acceptsRegistration($registration['attestation']);
if (!$verdict['ok']) {
SecurityLogger::log('credential_rejected', Http::clientIp(), [
'username' => Auth::username(),
'format' => (string)($registration['attestation']['format'] ?? '?'),
'reason' => (string)$verdict['reason'],
]);
Http::fail(403, 'policy_rejected', (string)$verdict['reason'], null, [
'format' => $registration['attestation']['format'] ?? null,
'attested' => false,
]);
}
try {
$repo->add(
$userId,
$registration['credential_id'],
$registration['public_key'],
$registration['alg'],
$registration['sign_count'],
$registration['aaguid'],
$label,
$transports,
$registration['attestation']
);
} catch (\PDOException $e) {
// Eindeutiger Schluessel verletzt: derselbe Key an einem anderen
// Konto. Die Kennung ist geraeteweit eindeutig, deshalb faellt
// das hier und nicht in der Suche oben auf.
if ((string)$e->getCode() === '23000') {
Http::fail(409, 'already_registered', 'Dieser Hardwarekey ist bereits einem Konto zugeordnet.');
}
throw $e;
}
// Ein neuer zweiter Faktor am eigenen Konto gehoert ins
// Sicherheitsprotokoll - es ist die Aenderung, die im Ernstfall die
// Frage beantwortet, seit wann ein fremder Schluessel mitliest.
SecurityLogger::log('credential_registered', Http::clientIp(), [
'username' => Auth::username(),
'label' => $label,
'attested' => $registration['attestation']['attested'] ?? false,
'root' => $registration['attestation']['root'] ?? null,
]);
Http::ok([
'label' => $label,
'total' => $repo->countForUser($userId),
'attested' => $registration['attestation']['attested'] ?? false,
'format' => $registration['attestation']['format'] ?? null,
'root' => $registration['attestation']['root'] ?? null,
'aaguid' => Attestation::formatAaguid($registration['aaguid']),
'message' => 'Hardwarekey registriert.',
]);
break;
}
default:
Http::fail(404, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [
'available' => ['login_options', 'login_verify', 'register_options', 'register_verify'],
]);
}