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:
Deploymentcenter Bot
2026-09-21 10:44:19 +02:00
co-authored by Claude Opus 5
parent 8f514eb17a
commit 073d52ab7d
14 changed files with 1978 additions and 33 deletions
+210 -4
View File
@@ -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&#10;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'