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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8f514eb17a
commit
073d52ab7d
@@ -34,7 +34,9 @@ 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
|
||||
@@ -50,8 +52,9 @@ if (Http::method() !== 'POST') {
|
||||
Http::fail(405, 'method_not_allowed', 'Dieser Endpunkt erwartet POST.');
|
||||
}
|
||||
|
||||
$db = Db::init();
|
||||
$repo = new CredentialRepo($db);
|
||||
$db = Db::init();
|
||||
$repo = new CredentialRepo($db);
|
||||
$policy = new HardwarePolicy($db);
|
||||
|
||||
switch ($action) {
|
||||
|
||||
@@ -64,8 +67,25 @@ switch ($action) {
|
||||
Http::fail(401, 'no_pending_login', 'Es steht keine Anmeldung offen. Bitte von vorn beginnen.');
|
||||
}
|
||||
|
||||
$ids = $repo->rawIdsForUser($userId);
|
||||
// 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.');
|
||||
}
|
||||
|
||||
@@ -105,6 +125,20 @@ switch ($action) {
|
||||
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,
|
||||
@@ -153,8 +187,14 @@ switch ($action) {
|
||||
$challenge,
|
||||
$userId,
|
||||
Auth::username(),
|
||||
$repo->rawIdsForUser($userId)
|
||||
$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;
|
||||
}
|
||||
@@ -191,7 +231,12 @@ switch ($action) {
|
||||
: null;
|
||||
|
||||
try {
|
||||
$registration = WebauthnServer::verifyRegistration($clientDataJson, $attestationObject, $challenge);
|
||||
$registration = WebauthnServer::verifyRegistration(
|
||||
$clientDataJson,
|
||||
$attestationObject,
|
||||
$challenge,
|
||||
$policy->roots()
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Logger::warning('Registrierung eines Hardwarekeys abgelehnt', [
|
||||
'user_id' => $userId,
|
||||
@@ -204,6 +249,26 @@ switch ($action) {
|
||||
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,
|
||||
@@ -213,7 +278,8 @@ switch ($action) {
|
||||
$registration['sign_count'],
|
||||
$registration['aaguid'],
|
||||
$label,
|
||||
$transports
|
||||
$transports,
|
||||
$registration['attestation']
|
||||
);
|
||||
} catch (\PDOException $e) {
|
||||
// Eindeutiger Schluessel verletzt: derselbe Key an einem anderen
|
||||
@@ -231,11 +297,17 @@ switch ($action) {
|
||||
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;
|
||||
|
||||
+14
-1
@@ -829,7 +829,20 @@ $spec = [
|
||||
. "wertlos und umgekehrt - das ist der Kern des Verfahrens und kein Mangel.\n\n"
|
||||
. "Der Ersatzweg fuer einen fehlenden Schluessel ist der Notfalltoken; der "
|
||||
. "laeuft als gewoehnliches Formular ueber `/login.php` und braucht kein "
|
||||
. "JavaScript.",
|
||||
. "JavaScript.\n\n"
|
||||
. "**Seit 2.9.2: Attestation.** Welche Authenticatoren zugelassen sind, "
|
||||
. "entscheidet eine Regel mit drei Stufen (`off`, `register`, `strict`), "
|
||||
. "einstellbar unter *System -> Benutzer*. Steht sie nicht auf `off`, fordert "
|
||||
. "`register_options` die Attestation als `direct` an, und `register_verify` "
|
||||
. "nimmt nur Schluessel auf, deren Zertifikatskette bei einer hinterlegten "
|
||||
. "Herstellerwurzel endet - ein Passwortverwalter wird mit `403 "
|
||||
. "policy_rejected` abgewiesen. Bei `strict` bietet `login_options` nur noch "
|
||||
. "geprueft in Hardware entstandene Schluessel an; ein Konto ohne solchen "
|
||||
. "Schluessel erhaelt `409 no_attested_credentials` und muss den Notfalltoken "
|
||||
. "benutzen.\n\n"
|
||||
. "`register_options` liefert die geltende Stufe als Feld `policy` mit, damit "
|
||||
. "die Oberflaeche die Anforderung nennen kann, BEVOR jemand seinen Schluessel "
|
||||
. "beruehrt.",
|
||||
'security' => [],
|
||||
'parameters' => [
|
||||
['name' => 'action', 'in' => 'query', 'required' => true,
|
||||
|
||||
@@ -1,8 +1,65 @@
|
||||
{
|
||||
"_comment": "Einzige Quelle der Wahrheit fuer den Changelog. Wird von /docs/changelog.php gerendert und von GET /api/updateservice/v1/changelog?since=X ausgeliefert. Neue Eintraege oben einfuegen.",
|
||||
"schema": 1,
|
||||
"current": "2.9.1",
|
||||
"current": "2.9.2",
|
||||
"versions": [
|
||||
{
|
||||
"version": "2.9.2",
|
||||
"date": "2026-09-21",
|
||||
"summary": "Das WebUI kann jetzt verlangen, dass ein zweiter Faktor wirklich in Hardware entstanden ist. Bisher liess sich auch ein Passwortverwalter als Hardwarekey registrieren - fuer das Deploymentcenter hinnehmbar, fuer ein Protokoll, das ausdruecklich Hardware fordert, nicht.",
|
||||
"actionRequired": true,
|
||||
"changes": [
|
||||
{
|
||||
"area": "security",
|
||||
"breaking": false,
|
||||
"title": "Attestation wird geprueft, wenn die Regel es verlangt",
|
||||
"text": "Bis 2.9.1 forderte die Registrierung attestation \"none\" an und pruefte nichts. Der Kommentar im Code begruendete das damit, dass der Betreiber seinen eigenen Schluessel an seinem eigenen Server registriert - das stimmte und uebersah, was daraus im Betrieb wird: Ein 1Password-Passkey liess sich ebenso anstandslos aufnehmen wie ein Yubikey. Neu prueft der Server auf Wunsch die Basic Attestation: Der Authenticator unterschreibt bei der Aufnahme mit einem Schluessel, der ab Werk in seinem Sicherheitselement liegt, und das zugehoerige Zertifikat muss auf eine hinterlegte Herstellerwurzel ketten. Mitgeliefert werden die drei FIDO-Wurzeln von Yubico.",
|
||||
"action": "Migration 021 ausfuehren. Danach unter System -> Benutzer die gewuenschte Stufe waehlen; ohne Umstellen bleibt das Verhalten von vorher."
|
||||
},
|
||||
{
|
||||
"area": "security",
|
||||
"breaking": false,
|
||||
"title": "Warum die Kette geprueft wird und nicht nur das Vorhandensein eines Zertifikats",
|
||||
"text": "Ein x5c allein beweist nichts: Jeder Authenticator kann sich selbst ein Zertifikat ausstellen, es \"Yubico U2F EE\" nennen, die AAGUID eines echten YubiKey 5 eintragen und kryptografisch einwandfrei unterschreiben. Genau dieser Fall wurde nachgebaut und gegen den Server gefahren - er wird abgewiesen, und zwar an der Kette, nicht an der Signatur. Was Software nicht herstellen kann, ist eine Kette, die bei Yubicos Wurzel endet; dafuer braeuchte es deren privaten Schluessel. Deshalb ist die Wurzeldatei kein Beiwerk, sondern der Kern der Pruefung.",
|
||||
"action": null
|
||||
},
|
||||
{
|
||||
"area": "security",
|
||||
"breaking": false,
|
||||
"title": "Drei Stufen, damit sich niemand aussperrt",
|
||||
"text": "off laesst jeden Authenticator zu (das Verhalten bis 2.9.1). register verlangt den Nachweis nur bei NEUEN Aufnahmen - bestehende Schluessel melden sich weiter an. strict laesst auch zur Anmeldung nur noch geprueft in Hardware entstandene Schluessel zu. Die Zwischenstufe ist nicht Bequemlichkeit, sondern notwendig: Alle vor dieser Fassung registrierten Schluessel gelten als nicht nachgewiesen, auch echte Yubikeys, weil bei attestation \"none\" der Browser die Geraetekennung durch Nullbytes ersetzt, bevor der Server sie sieht. Ein einzelner Schalter haette in dem Moment, in dem man ihn umlegt, den eigenen Zugang gesperrt. Der Wechsel nach strict wird zusaetzlich abgelehnt, solange das eigene Konto keinen nachgewiesenen Schluessel hat.",
|
||||
"action": "Wer strict nutzen will: erst auf register stellen, den Hardwarekey einmal neu aufnehmen, dann auf strict."
|
||||
},
|
||||
{
|
||||
"area": "security",
|
||||
"breaking": false,
|
||||
"title": "cross-platform war nie eine Pruefung",
|
||||
"text": "In den Registrierungsvorgaben stand seit 2.9.0 authenticatorAttachment = \"cross-platform\". Das sieht aus wie eine Beschraenkung auf angesteckte Hardware und ist keine: Die Angabe steuert, was der Browser ANBIETET, und ein Passwortverwalter meldet sich ebenfalls als cross-platform. Auch response.authenticatorAttachment traegt nicht - das setzt der Client, und der Client ist genau die Instanz, gegen die sich hier nichts beweisen laesst. Der Kommentar an dieser Stelle sagt das jetzt ausdruecklich, damit die Angabe nicht ein zweites Mal fuer eine Zusicherung gehalten wird.",
|
||||
"action": null
|
||||
},
|
||||
{
|
||||
"area": "webui",
|
||||
"breaking": false,
|
||||
"title": "Die Herkunft steht in der Schluesselliste",
|
||||
"text": "Jeder registrierte Schluessel zeigt, ob er sich als Hardware ausgewiesen hat, bei welcher Herstellerwurzel die Kette endete und welche Geraetekennung er nennt. Die Kennung steht dort in derselben Schreibweise, in der die Modellliste sie erwartet - sie laesst sich also uebernehmen, statt sie irgendwo nachschlagen zu muessen. Die Regel wird ausserdem beim Registrieren angesagt, BEVOR jemand seinen Schluessel beruehrt: Wer erst danach erfaehrt, dass nur Hardware zaehlt, sucht den Fehler beim Schluessel statt bei der Einstellung.",
|
||||
"action": null
|
||||
},
|
||||
{
|
||||
"area": "security",
|
||||
"breaking": false,
|
||||
"title": "Modellliste optional - und mit Warnhinweis",
|
||||
"text": "Zusaetzlich zur Wurzelpruefung lassen sich einzelne AAGUIDs zulassen. Die Liste ist leer voreingestellt und soll es meist bleiben: Yubico weist neuen Firmwarestaenden neue Kennungen zu, eine ungepflegte Liste weist also irgendwann einen echten Schluessel ab. Wo sie benutzt wird, wird die Kennung aus den Authenticator-Daten gegen die im Zertifikat gegengeprueft - sonst filterte man nach einer Behauptung statt nach einer Eigenschaft. Weitere Hersteller lassen sich ueber security.webauthn_root_files in der config.php ergaenzen, bewusst dort und nicht im WebUI: Eine Vertrauenswurzel ueber dieselbe Oberflaeche austauschbar zu machen, die sie schuetzt, hiesse die Pruefung dort aushebelbar zu machen.",
|
||||
"action": null
|
||||
},
|
||||
{
|
||||
"area": "tests",
|
||||
"breaking": false,
|
||||
"title": "Neue Testreihe \"hardware\" und testctl clear_credentials",
|
||||
"text": "Fuenf Faelle pruefen die Gegenueberstellung, auf die es ankommt: dieselben Bytes eines Authenticators ohne Herkunftsnachweis, einmal mit und einmal ohne Regel. Sie kommen ohne Krypto-Bibliothek aus, weil bei fmt \"none\" keine Signatur geprueft wird. Der Fall mit vorgetaeuschtem Zertifikat laeuft in einem eigenen Werkzeug ausserhalb der Suite. testctl bekommt clear_credentials: Ein Testlauf, der einen Schluessel registriert, sperrt sonst den naechsten aus, und dafuer den ganzen Datenbestand zu loeschen waere ein hoher Preis fuer eine Tabelle.",
|
||||
"action": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.9.1",
|
||||
"date": "2026-09-20",
|
||||
|
||||
+210
-4
@@ -27,8 +27,10 @@ use Deploymentcenter\Core\Csrf;
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\Http;
|
||||
use Deploymentcenter\Core\Logger;
|
||||
use Deploymentcenter\Core\SecurityLogger;
|
||||
use Deploymentcenter\Core\TokenManager as CoreTokenManager;
|
||||
use Deploymentcenter\Core\Webauthn\CredentialRepo;
|
||||
use Deploymentcenter\Core\Webauthn\HardwarePolicy;
|
||||
use Deploymentcenter\Modules\Bugtracker\BugRepo;
|
||||
use Deploymentcenter\Modules\Bugtracker\IgnoreRules;
|
||||
use Deploymentcenter\Modules\Bugtracker\Roadmap;
|
||||
@@ -1641,6 +1643,50 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
dc_redirect('#sub-system-users');
|
||||
}
|
||||
|
||||
case 'save_webauthn_policy': {
|
||||
$policy = new HardwarePolicy($pdo);
|
||||
$wanted = (string)($_POST['policy_level'] ?? HardwarePolicy::OFF);
|
||||
$before = $policy->level();
|
||||
|
||||
if (!in_array($wanted, HardwarePolicy::LEVELS, true)) {
|
||||
dc_flash('Unbekannte Stufe.', 'danger');
|
||||
dc_redirect('#sub-system-users');
|
||||
}
|
||||
|
||||
// Die Modellliste zuerst: Sie kann unbrauchbar sein, und dann
|
||||
// soll die Stufe nicht schon umgestellt sein. Andersherum
|
||||
// haette man eine halb angewandte Regel - die Stufe neu, die
|
||||
// Liste alt -, und niemand wuesste, welcher Teil gilt.
|
||||
try {
|
||||
$policy->setAllowlist((string)($_POST['policy_aaguids'] ?? ''));
|
||||
} catch (InvalidArgumentException $e) {
|
||||
dc_flash($e->getMessage(), 'danger');
|
||||
dc_redirect('#sub-system-users');
|
||||
}
|
||||
|
||||
$blocked = $policy->blockingReason($wanted, Auth::userId());
|
||||
|
||||
if ($blocked !== null) {
|
||||
dc_flash($blocked, 'danger');
|
||||
dc_redirect('#sub-system-users');
|
||||
}
|
||||
|
||||
$policy->setLevel($wanted);
|
||||
|
||||
// Eine geaenderte Anmelderegel gehoert ins
|
||||
// Sicherheitsprotokoll, nicht nur ins Anwendungslog: Sie
|
||||
// beantwortet spaeter die Frage, seit wann ein Konto mit
|
||||
// welchem Anspruch geschuetzt war.
|
||||
SecurityLogger::log('webauthn_policy_changed', Http::clientIp(), [
|
||||
'username' => $actor,
|
||||
'from' => $before,
|
||||
'to' => $wanted,
|
||||
]);
|
||||
|
||||
dc_flash(sprintf('Hardwarekey-Regel steht jetzt auf "%s".', $wanted));
|
||||
dc_redirect('#sub-system-users');
|
||||
}
|
||||
|
||||
case 'delete_credential': {
|
||||
$userId = (int)($_POST['user_id'] ?? 0);
|
||||
$credentialId = (int)($_POST['credential_id'] ?? 0);
|
||||
@@ -2086,6 +2132,29 @@ if ($twoFactorReady) {
|
||||
}
|
||||
}
|
||||
|
||||
// Die Hardwarekey-Regel. Steht die Migration 021 noch aus, bleibt alles auf
|
||||
// den Vorgaben - "off" ist dasselbe Verhalten wie vor dieser Funktion, und
|
||||
// eine Oberflaeche, die eine Regel zum Einschalten anbietet, die die
|
||||
// Datenbank nicht tragen kann, waere ein Versprechen ohne Deckung.
|
||||
$hardwarePolicy = new HardwarePolicy($pdo);
|
||||
$policyLevel = HardwarePolicy::OFF;
|
||||
$policyAllowlist = '';
|
||||
$policyRootCount = 0;
|
||||
$myAttestedCount = 0;
|
||||
$attestationReady = false;
|
||||
|
||||
if ($twoFactorReady) {
|
||||
try {
|
||||
$policyLevel = $hardwarePolicy->level();
|
||||
$policyAllowlist = $hardwarePolicy->allowlistText();
|
||||
$policyRootCount = count($hardwarePolicy->roots());
|
||||
$myAttestedCount = $credentialRepo->countAttestedForUser(Auth::userId());
|
||||
$attestationReady = true;
|
||||
} catch (Throwable $e) {
|
||||
$attestationReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Einmalige Anzeigen, die die Sitzung von der vorigen Anfrage mitbringt.
|
||||
$freshRecoveryToken = $_SESSION['dc_fresh_recovery_token'] ?? null;
|
||||
unset($_SESSION['dc_fresh_recovery_token']);
|
||||
@@ -6713,6 +6782,7 @@ Antwort:
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bezeichnung</th>
|
||||
<th>Herkunft</th>
|
||||
<th>Registriert</th>
|
||||
<th>Zuletzt benutzt</th>
|
||||
<th>Aktion</th>
|
||||
@@ -6722,6 +6792,31 @@ Antwort:
|
||||
<?php foreach ($myCredentials as $cred): ?>
|
||||
<tr>
|
||||
<td><strong><?= e((string)$cred['label']) ?></strong></td>
|
||||
<td style="font-size:0.8rem;">
|
||||
<?php if (!empty($cred['attested'])): ?>
|
||||
<span style="color:var(--success); font-weight:600;">🔒 Hardware</span>
|
||||
<?php if (!empty($cred['attestation_root'])): ?>
|
||||
<div style="color:var(--text-muted); font-size:0.75rem;">
|
||||
<?= e((string)$cred['attestation_root']) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($cred['aaguid_text'])): ?>
|
||||
<div style="color:var(--text-muted); font-size:0.7rem; font-family:monospace;"
|
||||
title="Gerätekennung — lässt sich in die Modellliste übernehmen.">
|
||||
<?= e((string)$cred['aaguid_text']) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<span style="color:var(--text-muted);">nicht nachgewiesen</span>
|
||||
<div style="color:var(--text-muted); font-size:0.75rem;">
|
||||
<?php if (!empty($cred['attestation_fmt'])): ?>
|
||||
Format <?= e((string)$cred['attestation_fmt']) ?>
|
||||
<?php else: ?>
|
||||
vor der Prüfung aufgenommen
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td style="font-size:0.8rem; color:var(--text-muted);"><?= e((string)$cred['created_at']) ?></td>
|
||||
<td style="font-size:0.8rem; color:var(--text-muted);">
|
||||
<?= !empty($cred['last_used_at']) ? e((string)$cred['last_used_at']) : 'nie' ?>
|
||||
@@ -6764,6 +6859,106 @@ Antwort:
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($twoFactorReady): ?>
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">🛡️ Welche Schlüssel zugelassen sind</h2></div>
|
||||
|
||||
<?php if (!$attestationReady): ?>
|
||||
<div class="alert" style="background:rgba(255,180,0,0.12); border:1px solid rgba(255,180,0,0.3); color:#fde68a; font-weight:500;">
|
||||
<strong>Migration 021 fehlt.</strong> Die Herkunft eines Schlüssels lässt sich
|
||||
erst danach festhalten. Bis dahin gilt „jeder Schlüssel“ — also das Verhalten
|
||||
vor dieser Funktion. Die Migration läuft unter <em>System → 🗄️ DB Migration</em>.
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
|
||||
WebAuthn allein unterscheidet nicht zwischen einem Yubikey und einem
|
||||
Passwortverwalter — beide sind für den Browser gültige Authenticatoren, und
|
||||
<code>cross-platform</code> in den Vorgaben ist nur ein Hinweis an die
|
||||
Oberfläche, keine Prüfung. Wer <strong>ausschließlich Hardware</strong>
|
||||
zulassen will, braucht die Attestation: ein Zertifikat, das der Schlüssel ab
|
||||
Werk trägt und das auf eine Herstellerwurzel führt. Software kann sie nicht
|
||||
herleiten, weil der zugehörige private Schlüssel das Sicherheitselement nie
|
||||
verlässt.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="index.php">
|
||||
<?= $csrfField ?>
|
||||
<input type="hidden" name="action" value="save_webauthn_policy">
|
||||
<input type="hidden" name="_return" value="sub-system-users">
|
||||
|
||||
<?php
|
||||
$stufen = [
|
||||
HardwarePolicy::OFF => [
|
||||
'Jeder Authenticator',
|
||||
'Es wird keine Attestation angefordert und nichts geprüft. '
|
||||
. 'Passwortverwalter und eingebaute Passkeys sind zugelassen. '
|
||||
. 'Das Verhalten bis Fassung 2.9.1.',
|
||||
],
|
||||
HardwarePolicy::REGISTER => [
|
||||
'Neue Schlüssel nur als Hardware',
|
||||
'Eine neue Registrierung muss eine geprüfte Herstellerattestation '
|
||||
. 'vorlegen. Bereits hinterlegte Schlüssel melden sich weiter an — '
|
||||
. 'das ist die Stufe, auf der Sie Ihren Yubikey neu aufnehmen, '
|
||||
. 'ohne sich auszusperren.',
|
||||
],
|
||||
HardwarePolicy::STRICT => [
|
||||
'Auch die Anmeldung nur mit Hardware',
|
||||
'Zusätzlich werden bei der Anmeldung nur noch Schlüssel angeboten und '
|
||||
. 'akzeptiert, deren Herkunft geprüft ist. Erst diese Stufe erfüllt '
|
||||
. 'ein Protokoll, das „nur Hardware“ verlangt.',
|
||||
],
|
||||
];
|
||||
?>
|
||||
|
||||
<?php foreach ($stufen as $wert => $beschreibung): ?>
|
||||
<label style="display:block; margin-bottom:0.9rem; cursor:pointer;">
|
||||
<input type="radio" name="policy_level" value="<?= e($wert) ?>"
|
||||
<?= $policyLevel === $wert ? 'checked' : '' ?>>
|
||||
<strong style="margin-left:0.35rem;"><?= e($beschreibung[0]) ?></strong>
|
||||
<span style="display:block; margin-left:1.6rem; color:var(--text-muted); font-size:0.82rem;">
|
||||
<?= e($beschreibung[1]) ?>
|
||||
</span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if ($myAttestedCount === 0): ?>
|
||||
<div class="alert" style="background:rgba(255,180,0,0.12); border:1px solid rgba(255,180,0,0.3); color:#fde68a; font-size:0.85rem; margin:1rem 0;">
|
||||
Für Ihr Konto ist noch kein geprüfter Hardwarekey hinterlegt. Die Stufe
|
||||
<em>Auch die Anmeldung nur mit Hardware</em> lässt sich deshalb nicht
|
||||
einschalten — sie würde Ihren eigenen Zugang sperren. Stellen Sie erst auf
|
||||
<em>Neue Schlüssel nur als Hardware</em> und nehmen Sie den Schlüssel neu auf.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<label class="form-label" style="margin-top:1rem;">
|
||||
Zugelassene Modelle (optional)
|
||||
</label>
|
||||
<textarea name="policy_aaguids" class="form-input" rows="3"
|
||||
style="font-family:monospace; font-size:0.8rem;"
|
||||
placeholder="leer = jedes Gerät des Herstellers d7781e5d-e353-46aa-afe2-3ca49f13332a"><?= e($policyAllowlist) ?></textarea>
|
||||
<p style="color:var(--text-muted); font-size:0.78rem; margin:0.4rem 0 1rem;">
|
||||
Eine Gerätekennung je Zeile. <strong>Leer lassen ist der Normalfall:</strong>
|
||||
Die Wurzelprüfung beantwortet bereits „stammt von diesem Hersteller“. Die Liste
|
||||
verengt auf einzelne Modelle und hat einen Unterhaltspreis — neue
|
||||
Firmwarestände bekommen neue Kennungen, und eine ungepflegte Liste weist
|
||||
irgendwann einen echten Schlüssel ab. Die Kennung Ihrer Schlüssel steht in
|
||||
der Tabelle oben.
|
||||
</p>
|
||||
|
||||
<p style="color:var(--text-muted); font-size:0.78rem; margin-bottom:1rem;">
|
||||
Hinterlegte Herstellerwurzeln: <strong><?= (int)$policyRootCount ?></strong>
|
||||
(mitgeliefert: Yubico). Weitere Hersteller lassen sich über
|
||||
<code>security.webauthn_root_files</code> in der <code>config.php</code>
|
||||
ergänzen — bewusst dort und nicht hier, damit sich die Prüfung nicht über
|
||||
dieselbe Oberfläche aushebeln lässt, die sie schützt.
|
||||
</p>
|
||||
|
||||
<button type="submit" class="btn">Regel speichern</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">👤 Benutzerkonten</h2></div>
|
||||
|
||||
@@ -9038,7 +9233,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
button.disabled = true;
|
||||
try {
|
||||
setStatus('Anfrage wird vorbereitet …');
|
||||
const options = (await post('register_options')).publicKey;
|
||||
const vorgabe = await post('register_options');
|
||||
const options = vorgabe.publicKey;
|
||||
// Die Regel steht vor der Berührung, nicht danach. Wer erst nach
|
||||
// dem Antippen erfährt, dass nur Hardware zählt, sucht den Fehler
|
||||
// beim Schlüssel statt bei der Einstellung.
|
||||
const nurHardware = vorgabe.policy && vorgabe.policy !== 'off';
|
||||
|
||||
options.challenge = fromB64u(options.challenge);
|
||||
options.user.id = fromB64u(options.user.id);
|
||||
@@ -9046,7 +9246,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
...c, id: fromB64u(c.id)
|
||||
}));
|
||||
|
||||
setStatus('Bitte den Hardwarekey jetzt anstecken und berühren.');
|
||||
setStatus(nurHardware
|
||||
? 'Bitte den Hardwarekey jetzt anstecken und berühren. Zugelassen sind nur Geräte '
|
||||
+ 'mit Herstellernachweis — ein Passwortverwalter wird abgelehnt.'
|
||||
: 'Bitte den Hardwarekey jetzt anstecken und berühren.');
|
||||
const credential = await navigator.credentials.create({ publicKey: options });
|
||||
|
||||
setStatus('Schlüssel wird hinterlegt …');
|
||||
@@ -9054,14 +9257,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
? credential.response.getTransports()
|
||||
: null;
|
||||
|
||||
await post('register_verify', {
|
||||
const ergebnis = await post('register_verify', {
|
||||
clientDataJSON: toB64u(credential.response.clientDataJSON),
|
||||
attestationObject: toB64u(credential.response.attestationObject),
|
||||
label: (labelField.value || '').trim(),
|
||||
transports: transports
|
||||
});
|
||||
|
||||
setStatus('Registriert. Die Seite wird neu geladen …', 'ok');
|
||||
setStatus(ergebnis.attested
|
||||
? 'Registriert und als Hardware nachgewiesen (' + (ergebnis.root || 'Herstellerwurzel')
|
||||
+ '). Die Seite wird neu geladen …'
|
||||
: 'Registriert. Ein Hardwarenachweis liegt nicht vor. Die Seite wird neu geladen …', 'ok');
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
const message = err && err.name === 'NotAllowedError'
|
||||
|
||||
Reference in New Issue
Block a user