Was die Sicherheitsbegutachtung an Erkenntnis gebracht hat, galt bisher nur zur Haelfte im Dokument: Abschnitt 12 nannte die Befunde, aber drei Dinge fehlten, die im Zielprojekt genauso gelten. NEU §13 - was welche Zertifizierungsanforderung erfuellt Die Zahlen in diesem Dokument sind nicht gegriffen, sie stammen aus Anforderungen: 30 Minuten und 12 Stunden aus AAL2, zwoelf Zeichen und hoechstens zehn Fehlversuche aus PCI DSS 4.0, die Wiederanmeldung vor Aenderungen an den Anmeldemitteln aus ASVS. Wer eine Zahl aendert, soll wissen, welche Anforderung er dabei verlaesst. Mit zwei Ehrlichkeiten: Eine Begutachtung der Authentifizierungskapitel kann auch nur ueber diese eine Aussage treffen, und ISO zertifiziert das Managementsystem, nicht den Code. §13.1 nennt den Fall, in dem die Anforderungen sich widersprechen - 30 Minuten als VOLLE Neuanmeldung erzeugen genau das Verhalten, das der Standard nicht will: Der Schluessel bleibt stecken. NEU §14 - wie man belegt, was sich nicht unit-testen laesst Drei der wichtigsten Zusicherungen sind mit einem gewoehnlichen Testfall nicht pruefbar. Der negative Test muss den ABLEHNUNGSGRUND pruefen, sonst bestaetigt er irgendwann nur noch, dass irgendetwas kaputt ist. Das Zehn-Minuten-Fenster verlangt echtes Warten; es fuer den Test zu verkleinern prueft eine andere Anlage. Und der Gegenfall mit echter Hardware laesst sich gar nicht automatisieren - das ist ein einzuplanender Handgriff, keine Luecke. Samt der Falle, in die die eigene Probe zuerst gelaufen ist: zwei Zustaende, die sich gegenseitig ausschliessen, durch dieselbe Sitzung geschickt. NEU §12.8 - ein Bildschirm fuer Leerlauf und Bestaetigung Beide verlangen dasselbe. Zwei Stellen, die Passwoerter pruefen, laufen auseinander. Der Leerlaufzustand laesst sich willentlich herbeifuehren, und die ganze Wirkung haengt dann an einer Zeile in isLoggedIn() - jede Stelle, die sie fragt, bekommt ohne eigenes Zutun die richtige Antwort. GESCHAERFT §12.6 Die Ankerdiagnose muss VERGLEICHEN, nicht nur nachsehen, ob ein Wert gesetzt ist: Ein Tippfehler weist den Schluessel genauso ab wie ein fehlender Wert. Dass dafuer der Host-Header dient, ist unbedenklich - es ist eine Diagnose, keine Entscheidung. ERGAENZT §12.4 Die Rollout-Falle der Sitzungsgeneration: Sitzungen aus der Zeit vor der Migration muessen weitergelten, sonst wirft die Einfuehrung jeden Angemeldeten hinaus. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
860 lines
36 KiB
Markdown
860 lines
36 KiB
Markdown
# Hardwarekey als zweiter Faktor — Konzept und Übernahme
|
|
|
|
**Stand: Deploymentcenter 2.9.3, 21. September 2026**
|
|
|
|
Dieses Dokument beschreibt die Hardwarekey-Anmeldung so, wie sie im
|
|
Deploymentcenter läuft, und zwar mit dem Ziel, sie in einem anderen Projekt
|
|
**gleichwertig** nachzubauen. Es ist bewusst konzeptlastig: Der PHP-Code ist
|
|
die Referenz, nicht der Kern. Der Kern sind die Entscheidungen.
|
|
|
|
Es deckt zwei Ebenen ab, und die zweite ist die, die man zuerst übersieht:
|
|
|
|
1. **Die Zeremonie** (§2–§7): die Dinge, die *wie* eine Prüfung aussehen und
|
|
keine sind, und was stattdessen trägt.
|
|
2. **Die Wege daneben** (§12): ein zweiter Anmeldepfad, der den Faktor nicht
|
|
kannte; eine Prüfung, die bei Datenbankfehlern stillschweigend abschaltete;
|
|
eine übernommene Sitzung, die die Anmeldemittel ändern durfte.
|
|
|
|
Dazu §13 (welche Entscheidung welche Zertifizierungsanforderung erfüllt) und
|
|
§14 (wie man Eigenschaften belegt, die sich nicht unit-testen lassen).
|
|
|
|
---
|
|
|
|
## 1. Was das Verfahren leistet — und was nicht
|
|
|
|
**Leistet:** Nach dem Passwort verlangt die Anmeldung einen Gegenstand, der
|
|
physisch vorliegen muss. Es ist beweisbar, dass der private Schlüssel in
|
|
einem Sicherheitselement entstanden ist und es nie verlassen hat. Die
|
|
Anmeldung ist an die Adresse gebunden (RP-ID), also phishing-resistent: Eine
|
|
nachgebaute Seite bekommt keine gültige Signatur, weil der Browser die echte
|
|
Herkunft in die signierten Daten schreibt.
|
|
|
|
**Leistet nicht:**
|
|
|
|
- **Keine Prüfung bei der Anmeldung.** Attestation gibt es im Standard nur
|
|
bei der *Registrierung*. Die Aussage lautet: „Dieser Schlüssel ist
|
|
nachweislich in Hardware entstanden" — und sie trägt danach, weil der
|
|
private Teil diese Hardware nicht verlassen kann. Bei jeder späteren
|
|
Anmeldung wird nur noch die Signatur gegen den gespeicherten öffentlichen
|
|
Schlüssel geprüft.
|
|
- **Kein Schutz gegen einen kompromittierten Client.** Wer den Browser
|
|
kontrolliert, kann eine echte Zeremonie gegen einen echten Schlüssel
|
|
auslösen. Dagegen hilft Attestation nicht und soll es nicht.
|
|
- **Keine Aussage über den Menschen.** `userVerification` steht hier auf
|
|
`discouraged`, es wird also keine PIN und kein Fingerabdruck verlangt. Wer
|
|
den Schlüssel in der Hand hat, kommt hinein. Siehe §8.
|
|
|
|
---
|
|
|
|
## 2. Die drei Fallen
|
|
|
|
Das ist der wichtigste Abschnitt. Alle drei waren in der ersten Fassung
|
|
(2.9.0) vorhanden und wurden erst im Betrieb als wirkungslos erkannt.
|
|
|
|
### 2.1 `authenticatorAttachment: "cross-platform"` ist keine Prüfung
|
|
|
|
```js
|
|
authenticatorSelection: { authenticatorAttachment: 'cross-platform' }
|
|
```
|
|
|
|
Liest sich wie „nur angesteckte Hardware". Steuert aber nur, was der Browser
|
|
dem Benutzer **anbietet**. Ein Passwortverwalter meldet sich ebenfalls als
|
|
`cross-platform` und kommt anstandslos durch. Die Angabe darf drinbleiben —
|
|
sie verbessert die Auswahl —, aber sie sichert nichts zu.
|
|
|
|
### 2.2 `response.authenticatorAttachment` ist wertlos
|
|
|
|
Das Feld setzt der **Client**. Der Client ist genau die Instanz, gegen die
|
|
serverseitig nichts zu beweisen ist. Wer darauf eine Regel stützt, hat eine
|
|
Regel, die der Angreifer selbst ausfüllt.
|
|
|
|
### 2.3 Ein `x5c` allein beweist nichts
|
|
|
|
Der verführerischste Fehler. „Es ist ein Zertifikat dabei, also ist es
|
|
Hardware" — nein. Jeder Authenticator kann sich selbst eines ausstellen.
|
|
|
|
Dieser Fall wurde gegen den fertigen Server gefahren, mit:
|
|
|
|
- eigenem EC-Schlüsselpaar,
|
|
- selbst ausgestelltem Zertifikat, Betreff `CN=Yubico U2F EE Serial
|
|
99999999, O=Yubico AB, OU=Authenticator Attestation`,
|
|
- der AAGUID eines echten YubiKey 5 NFC — in `authData` **und** in der
|
|
Zertifikatserweiterung `1.3.6.1.4.1.45724.1.1.4`,
|
|
- einer kryptografisch einwandfreien ES256-Signatur über
|
|
`authData || SHA256(clientDataJSON)`.
|
|
|
|
Antwort: *„Die Zertifikatskette endet nicht bei einer hinterlegten
|
|
Herstellerwurzel."* Abgelehnt an der **Kette**, nicht an der Signatur. Genau
|
|
das ist die Probe, die eine Umsetzung bestehen muss. Kommt die Ablehnung
|
|
wegen der Signatur, hat der Test sich selbst falsch gebaut und beweist
|
|
nichts.
|
|
|
|
> **Die einzige Eigenschaft, die Software nicht herstellen kann, ist eine
|
|
> Kette bis zu einer Wurzel, deren privaten Schlüssel der Hersteller hat.**
|
|
> Alles andere ist abschreibbar.
|
|
|
|
### 2.4 Und die Wege NEBEN der Zeremonie
|
|
|
|
Die drei Fallen oben sind Fallen *innerhalb* der Zeremonie. Eine
|
|
Sicherheitsbegutachtung am 21.09.2026 fand die schwerere Klasse: Die Zeremonie
|
|
war korrekt, der zweite Faktor trotzdem umgehbar — **daneben**. Ausführlich in
|
|
§12; in einem Satz:
|
|
|
|
> Ein zweiter Faktor ist nur so stark wie der schwächste Weg zum Konto,
|
|
> und Sitzungen, die aus ihm entstehen, erben seinen Schutz nicht.
|
|
|
|
Wer §5 umsetzt und §12 überspringt, hat eine einwandfreie Zeremonie mit einer
|
|
Tür daneben.
|
|
|
|
### 2.5 Und die AAGUID?
|
|
|
|
Eine AAGUID ist eine **Behauptung im Datenstrom**. Als alleiniges Kriterium
|
|
wertlos. Sinnvoll erst *zusätzlich* zur Wurzelprüfung — und dann muss die
|
|
AAGUID aus `authData` gegen die im Zertifikat gegengeprüft werden, sonst
|
|
filtert man wieder nach einer Behauptung.
|
|
|
|
**Empfehlung: Modellliste leer lassen.** Hersteller vergeben neuen
|
|
Firmwareständen neue AAGUIDs. Eine gepflegte Liste ist eine Liste, die
|
|
irgendwann einen echten Schlüssel abweist.
|
|
|
|
---
|
|
|
|
## 3. Datenmodell
|
|
|
|
```sql
|
|
CREATE TABLE user_credentials (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
user_id INT NOT NULL,
|
|
credential_id VARBINARY(255) NOT NULL, -- roh vom Authenticator
|
|
public_key TEXT NOT NULL, -- PEM, nicht COSE (siehe unten)
|
|
alg INT NOT NULL DEFAULT -7, -- -7 = ES256, -257 = RS256
|
|
sign_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
|
aaguid VARBINARY(16) NULL,
|
|
attested TINYINT(1) NOT NULL DEFAULT 0, -- <- einzige Entscheidungsspalte
|
|
attestation_fmt VARCHAR(32) NULL,
|
|
attestation_ca VARCHAR(190) NULL,
|
|
attestation_root VARCHAR(190) NULL,
|
|
transports VARCHAR(128) NULL,
|
|
label VARCHAR(64) NOT NULL DEFAULT 'Hardwarekey',
|
|
created_at DATETIME NOT NULL,
|
|
last_used_at DATETIME NULL,
|
|
UNIQUE KEY ux_credential_id (credential_id),
|
|
KEY ix_credential_user (user_id)
|
|
);
|
|
```
|
|
|
|
Vier Entscheidungen, die man sonst zweimal trifft:
|
|
|
|
| Feld | Warum so |
|
|
|---|---|
|
|
| `public_key` als **PEM** | Geprüft wird mit `openssl_verify()`, das ein PEM will. Die Wandlung COSE→PEM einmal bei der Aufnahme zu bezahlen ist billiger als bei jeder Anmeldung. |
|
|
| `alg` **gespeichert** | Ließe sich aus dem PEM herleiten. Die Prüfung soll aber nicht davon abhängen, dass das Herleiten richtig geraten hat. |
|
|
| `credential_id` **UNIQUE global** | Die Kennung ist geräteweit eindeutig. Ohne UNIQUE landet derselbe Schlüssel an zwei Konten. |
|
|
| `attested` als **einzige** Entscheidungsspalte | Die drei Textspalten kommen aus einem Zertifikat, also von außen. Sie taugen zur Anzeige, nicht als Vergleichsgrundlage im Code. Das Bit hat der Server selbst gesetzt. |
|
|
|
|
Dazu am Benutzer: `recovery_hash`, `recovery_created_at`, `recovery_used_at`
|
|
für den Notfalltoken (§7).
|
|
|
|
---
|
|
|
|
## 4. Ablauf
|
|
|
|
### Registrierung (verlangt bestehende Anmeldung + CSRF)
|
|
|
|
```
|
|
Browser Server
|
|
|-- POST ?action=register_options ------------>|
|
|
| erzeugt 32 Byte challenge,
|
|
| legt sie in die Sitzung
|
|
|<-- publicKey{ challenge, rp, user, |
|
|
| pubKeyCredParams, attestation, |
|
|
| excludeCredentials } |
|
|
| |
|
|
| navigator.credentials.create() |
|
|
| |
|
|
|-- POST ?action=register_verify ------------->|
|
|
| { clientDataJSON, attestationObject } |
|
|
| 1. clientData prüfen
|
|
| 2. authData zerlegen
|
|
| 3. Attestation prüfen <- §5
|
|
| 4. Regel anwenden <- §6
|
|
|<-- { attested, root, aaguid } |
|
|
```
|
|
|
|
### Anmeldung (zweite Stufe, noch niemand angemeldet)
|
|
|
|
Der Zwischenzustand ist wichtig: Nach richtigem Passwort steht in der
|
|
Sitzung nur `pending_user_id` — `isLoggedIn()` bleibt **false**. Erst der
|
|
zweite Faktor macht daraus eine Anmeldung. Wer hier eine „halbe" Anmeldung
|
|
baut, hat die Sicherheitslücke schon eingebaut.
|
|
|
|
```php
|
|
const PENDING_TIMEOUT = 300; // Zwischenzustand verfällt nach 5 Minuten
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Der Kern: Attestationsprüfung
|
|
|
|
Reihenfolge ist nicht beliebig. **Erst die Signatur, dann die Kette** — nur
|
|
so lässt sich später unterscheiden, ob ein Schlüssel kaputt oder bloß nicht
|
|
zugelassen ist.
|
|
|
|
```php
|
|
public static function inspect(
|
|
array $attestationObject, // CBOR-dekodiert
|
|
string $authDataRaw,
|
|
string $clientDataJson,
|
|
?string $aaguidFromAuthData,
|
|
array $trustedRoots // PEM je Wurzel
|
|
): array {
|
|
$format = $attestationObject['fmt'] ?? 'unbekannt';
|
|
$stmt = $attestationObject['attStmt'] ?? [];
|
|
|
|
// "none": Passwortverwalter, eingebaute Passkeys. Keine Behauptung.
|
|
if ($format === 'none' || $stmt === []) {
|
|
return ['attested' => false,
|
|
'reason' => 'Der Authenticator hat keine Attestation mitgeschickt.'];
|
|
}
|
|
|
|
$chain = $stmt['x5c'] ?? null;
|
|
|
|
// "self attestation": beweist, dass der Schlüssel zu sich selbst passt.
|
|
// Über Hardware sagt das nichts.
|
|
if (!is_array($chain) || $chain === []) {
|
|
return ['attested' => false,
|
|
'reason' => 'Die Attestation ist selbst unterschrieben.'];
|
|
}
|
|
|
|
$leafPem = self::derToPem($chain[0]);
|
|
$parsed = openssl_x509_parse($leafPem);
|
|
|
|
self::checkValidity($parsed); // abgelaufen?
|
|
|
|
$clientDataHash = hash('sha256', $clientDataJson, true);
|
|
|
|
if ($format === 'packed') { // CTAP2, Normalfall
|
|
self::verifyPacked($leafPem, $stmt['sig'],
|
|
$authDataRaw . $clientDataHash, $stmt);
|
|
self::checkAaguidExtension($parsed, $aaguidFromAuthData);
|
|
} elseif ($format === 'fido-u2f') { // CTAP1, ältere Keys
|
|
self::verifyU2f($leafPem, $stmt['sig'], $authDataRaw, $clientDataHash);
|
|
} else {
|
|
// tpm, android-key, apple: nicht umgesetzt. Ablehnen ist ehrlicher
|
|
// als durchwinken — ein ungeprüftes Format als "geprüft" zu führen
|
|
// wäre genau der Fehler, den diese Klasse behebt.
|
|
throw new \RuntimeException("Format \"$format\" wird hier nicht geprüft.");
|
|
}
|
|
|
|
$root = self::chainVerifies($chain, $trustedRoots); // <- die Aussage
|
|
if ($root === null) {
|
|
throw new \RuntimeException(
|
|
'Die Zertifikatskette endet nicht bei einer hinterlegten Herstellerwurzel.');
|
|
}
|
|
|
|
return ['attested' => true, 'root' => $root, 'format' => $format];
|
|
}
|
|
```
|
|
|
|
### 5.1 Kettenprüfung — gegen *eigene* Wurzeln
|
|
|
|
```php
|
|
private static function chainVerifies(array $derChain, array $trustedRoots): ?string
|
|
{
|
|
if ($trustedRoots === []) {
|
|
throw new \RuntimeException('Keine Herstellerwurzeln hinterlegt.');
|
|
}
|
|
|
|
$intermediates = array_map([self::class, 'derToPem'], array_slice($derChain, 1));
|
|
$current = self::derToPem($derChain[0]);
|
|
|
|
for ($step = 0; $step < 6; $step++) {
|
|
foreach ($trustedRoots as $rootPem) {
|
|
if (self::signedBy($current, $rootPem)) {
|
|
return self::describe(openssl_x509_parse($rootPem));
|
|
}
|
|
}
|
|
$next = null;
|
|
foreach ($intermediates as $i => $kandidat) {
|
|
if (self::signedBy($current, $kandidat)) {
|
|
$next = $kandidat; unset($intermediates[$i]); break;
|
|
}
|
|
}
|
|
if ($next === null) { return null; }
|
|
self::checkValidity(openssl_x509_parse($next));
|
|
$current = $next;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static function signedBy(string $certPem, string $issuerPem): bool
|
|
{
|
|
$key = openssl_pkey_get_public($issuerPem);
|
|
return $key !== false && openssl_x509_verify($certPem, $key) === 1;
|
|
}
|
|
```
|
|
|
|
> **Nicht `openssl_x509_checkpurpose()` benutzen.** Dessen Wurzelspeicher ist
|
|
> der des Betriebssystems, und dort steht die Web-PKI — also genau die
|
|
> falsche Menge. Eine Attestationskette soll bei *einem* Hersteller enden
|
|
> und nirgends sonst.
|
|
|
|
### 5.2 `fido-u2f` — anderes signiertes Material
|
|
|
|
Ältere Schlüssel über CTAP1 signieren nicht `authData`, sondern:
|
|
|
|
```
|
|
0x00 | rpIdHash | clientDataHash | credentialId | 0x04 | x | y
|
|
```
|
|
|
|
Erbe aus der Zeit vor WebAuthn. Wer das Format weglässt, weist ältere
|
|
Hardware ab. U2F kennt nur ES256, und die AAGUID ist dort immer Null — eine
|
|
Modellliste greift für diese Geräte also grundsätzlich nicht.
|
|
|
|
### 5.3 Klonerkennung
|
|
|
|
```php
|
|
if (!($storedSignCount === 0 && $neuerZaehler === 0)
|
|
&& $neuerZaehler <= $storedSignCount) {
|
|
throw new \RuntimeException('Zähler nicht gestiegen — möglicherweise ein Duplikat.');
|
|
}
|
|
```
|
|
|
|
Die Ausnahme ist notwendig: Ein Teil der Hardware führt den Zähler gar nicht
|
|
und meldet konstant 0. „Zählt nicht" darf nicht wie „geklont" aussehen.
|
|
|
|
---
|
|
|
|
## 6. Die Regel — drei Stufen, nicht ein Schalter
|
|
|
|
| Stufe | Aufnahme | Anmeldung | `attestation` in den Vorgaben |
|
|
|---|---|---|---|
|
|
| `off` | jeder | alle | `none` |
|
|
| `register` | nur nachgewiesen | alle | `direct` |
|
|
| `strict` | nur nachgewiesen | nur nachgewiesene | `direct` |
|
|
|
|
**Warum die Mittelstufe zwingend ist:** Bei `attestation: "none"` ersetzt der
|
|
**Browser** die AAGUID durch 16 Nullbytes, bevor der Server sie sieht. Alle
|
|
vor der Umstellung registrierten Schlüssel haben daher *keine* Herkunft —
|
|
auch echte Yubikeys. Ein einzelner Schalter sperrt in dem Moment, in dem man
|
|
ihn umlegt, den eigenen Zugang aus.
|
|
|
|
**Der Übergang nach `strict` muss bewacht sein:**
|
|
|
|
```php
|
|
public function blockingReason(string $desiredLevel, int $actingUserId): ?string
|
|
{
|
|
if ($desiredLevel !== self::STRICT) { return null; }
|
|
|
|
$stmt = $this->db->prepare(
|
|
'SELECT COUNT(*) FROM user_credentials WHERE user_id = :u AND attested = 1');
|
|
$stmt->execute([':u' => $actingUserId]);
|
|
if ((int)$stmt->fetchColumn() > 0) { return null; }
|
|
|
|
return 'Kein nachgewiesener Schlüssel am eigenen Konto. '
|
|
. '"strict" würde die eigene Anmeldung sperren.';
|
|
}
|
|
```
|
|
|
|
> Eine Einstellung, die sich selbst aussperrt, ist keine Einstellung,
|
|
> sondern eine Falle.
|
|
|
|
**Und die Regel zweimal prüfen.** `allowCredentials` ist eine Vorgabe an den
|
|
Browser, keine Zusage des Clients:
|
|
|
|
```php
|
|
// login_options: filtert die Auswahl
|
|
$ids = $repo->rawIdsForUser($userId, $policy->appliesToLogin());
|
|
|
|
// login_verify: entscheidet wirklich
|
|
if ($policy->appliesToLogin() && (int)$credential['attested'] !== 1) {
|
|
Auth::recordSecondFactorFailure($db, 'not_attested');
|
|
Http::fail(403, 'not_attested', '...');
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Ersatzweg: der Notfalltoken
|
|
|
|
Ohne ihn ist ein verlorener Schlüssel ein verlorenes Konto.
|
|
|
|
- Nur der **Hash** in der Datenbank, Klartext genau einmal bei der Erzeugung
|
|
sichtbar.
|
|
- **Einmalgebrauch**, Hash wird beim Verbrauch geleert.
|
|
- `recovery_used_at` bleibt stehen: Ohne diese Spur sieht „keiner
|
|
hinterlegt" genauso aus wie „es gab nie einen", während es in Wahrheit
|
|
heißt: *jemand hat ihn benutzt*.
|
|
- Alphabet ohne `0/O` und `1/I`, Gruppen zu vier Zeichen — er wird
|
|
abgeschrieben und vorgelesen.
|
|
- **Läuft als gewöhnliches Formular, nicht über JavaScript.** Ein Ersatzweg
|
|
mit denselben Voraussetzungen wie der Hauptweg ist keiner.
|
|
|
|
Der Notfalltoken gilt **unabhängig von der Regel**. Das ist Absicht: Er ist
|
|
der Weg zurück aus einer Fehlkonfiguration.
|
|
|
|
---
|
|
|
|
## 8. Bewusst offene Punkte
|
|
|
|
| Punkt | Stand | Wenn das Zielprojekt mehr braucht |
|
|
|---|---|---|
|
|
| `userVerification` | `discouraged` — keine PIN | Auf `required` stellen; verlangt dann PIN/Biometrie am Schlüssel und prüft das UV-Bit in `authData`. |
|
|
| FIDO Metadata Service | nicht angebunden | Kanonischer Weg zu „jede zertifizierte Hardware". Signierter JWT-Abruf mit Auffrischung — eine laufende Außenabhängigkeit in der Anmeldestrecke. |
|
|
| `tpm` / `apple` | abgelehnt | Windows Hello und Secure Enclave sind hardwaregestützt, sitzen aber im selben Gerät wie der erste Faktor. |
|
|
| Enterprise Attestation | nein | Liefert eine geräteindividuelle Kennung. Braucht Browser-Richtlinien auf verwalteten Rechnern. |
|
|
|
|
---
|
|
|
|
## 9. Portierung in ein anderes Projekt
|
|
|
|
### 9.1 Was sprachunabhängig ist
|
|
|
|
Alles in §2 bis §7. Die Fallen, die Reihenfolge der Prüfungen, das
|
|
Datenmodell, die drei Stufen, der Notfalltoken.
|
|
|
|
### 9.2 Was zu ersetzen ist
|
|
|
|
| PHP | Entsprechung .NET | Entsprechung Node |
|
|
|---|---|---|
|
|
| `openssl_x509_parse` | `X509Certificate2` | `crypto.X509Certificate` |
|
|
| `openssl_x509_verify($c,$k)` | `X509Certificate2.Verify()` bzw. Signaturprüfung über `X509Chain` mit eigenem `CustomTrustStore` | `cert.verify(publicKey)` |
|
|
| `openssl_verify` | `ECDsa.VerifyData` / `RSA.VerifyData` | `crypto.verify` |
|
|
| eigener CBOR-Leser | `PeterO.Cbor` | `cbor` |
|
|
|
|
> **.NET-Hinweis:** Für die Kettenprüfung `X509ChainPolicy.TrustMode =
|
|
> CustomRootTrust` setzen und die Herstellerwurzeln in
|
|
> `CustomTrustStore` legen. Ohne das prüft .NET gegen den
|
|
> Betriebssystemspeicher — derselbe Fehler wie
|
|
> `openssl_x509_checkpurpose()`.
|
|
|
|
### 9.3 Wurzelzertifikate
|
|
|
|
Yubico veröffentlicht sie unter
|
|
<https://developers.yubico.com/PKI/yubico-ca-certs.txt>. Für WebAuthn sind
|
|
**drei** relevant:
|
|
|
|
- Yubico U2F Root CA Serial 457200631 (2014) — YubiKey 4 und 5
|
|
- Yubico FIDO Root CA Serial 450203556 (2024)
|
|
- Yubico Attestation Root 1 (2024-12)
|
|
|
|
Die dort ebenfalls veröffentlichten Wurzeln für **PIV** und **OpenPGP**
|
|
gehören *nicht* dazu — sie signieren anderes und vergrößerten nur die Menge
|
|
des Akzeptierten.
|
|
|
|
Andere Hersteller (SoloKeys, Nitrokey, Feitian, Token2) brauchen ihre
|
|
eigenen Wurzeln. Fehlen sie, wird echte Hardware abgewiesen — das ist eine
|
|
Konfigurationslücke, kein Urteil über das Gerät.
|
|
|
|
### 9.4 RP-ID
|
|
|
|
Ein registrierter Schlüssel gilt **nur** für die Kennung, unter der er
|
|
aufgenommen wurde. Test- und Produktivinstanz brauchen je eine eigene
|
|
Registrierung. Das ist kein Mangel, sondern der Kern des Verfahrens — genau
|
|
deshalb läuft ein Anmeldeversuch auf einer nachgebauten Seite ins Leere.
|
|
|
|
---
|
|
|
|
## 10. Prüfliste für die Übernahme
|
|
|
|
Eine Umsetzung gilt erst als fertig, wenn **alle** Punkte belegt sind:
|
|
|
|
- [ ] Ein echter Hardwarekey wird aufgenommen und zeigt die erreichte Wurzel
|
|
- [ ] Ein Passwortverwalter-Passkey (`fmt: none`) wird abgewiesen
|
|
- [ ] **Ein selbst ausgestelltes Zertifikat mit gültiger Signatur und
|
|
abgeschriebener AAGUID wird abgewiesen — an der KETTE, nicht an der
|
|
Signatur** *(§2.3; ohne diesen Nachweis ist nichts belegt)*
|
|
- [ ] Zwischenzustand: `isLoggedIn()` bleibt nach richtigem Passwort false
|
|
- [ ] Zwischenzustand verfällt nach 5 Minuten
|
|
- [ ] Zähler-Rückschritt wird erkannt, beide-Null bleibt erlaubt
|
|
- [ ] Notfalltoken funktioniert ohne JavaScript und ist danach verbraucht
|
|
- [ ] `strict` lässt sich nicht einschalten, solange kein nachgewiesener
|
|
Schlüssel am eigenen Konto hängt
|
|
- [ ] Ein Konto zu löschen entfernt seine Credential-Zeilen *(sonst ist der
|
|
Schlüssel wegen UNIQUE dauerhaft verbrannt)*
|
|
- [ ] Fehlermeldungen kommen beim Benutzer **lesbar** an
|
|
|
|
Und die Wege daneben (§12) — ohne diese ist die Zeremonie eine Tür neben
|
|
einer offenen:
|
|
|
|
- [ ] **Jeder** Anmeldepfad prüft den zweiten Faktor. Zählen Sie sie auf; was
|
|
Sie nicht vollständig aufschreiben können, ist der Befund
|
|
- [ ] Kein `catch (\Throwable)` in einem Authentifizierungspfad, der auf
|
|
„kein Faktor" oder „nicht gesperrt" zurückfällt
|
|
- [ ] Änderungen an den Anmeldemitteln verlangen eine frische
|
|
Authentifizierung
|
|
- [ ] Ein Passwortwechsel beendet die übrigen Sitzungen des Kontos
|
|
- [ ] Leerlauf sperrt (ein Faktor genügt zum Öffnen), Absolutgrenze beendet
|
|
- [ ] `rpId`/`origin` stammen aus der Konfiguration, nicht aus dem
|
|
Host-Header — und eine Anzeige sagt vorher, ob sie gesetzt sind
|
|
- [ ] Der Notfalltoken altert; das Alter wird **nach** dem Hashvergleich
|
|
geprüft
|
|
- [ ] Anmeldefehler sind ununterscheidbar (falscher Name, falsches Passwort,
|
|
gesperrt, deaktiviert)
|
|
- [ ] Eine Diagnose meldet **vor** dem Anmelden, ob der Anker gesetzt ist
|
|
*und ob er zur tatsächlichen Adresse passt* (§12.6)
|
|
- [ ] Sitzungen aus der Zeit vor der Generations-Migration gelten weiter
|
|
(§12.4) — sonst wirft die Einführung jeden Angemeldeten hinaus
|
|
- [ ] Die negativen Tests prüfen den **Ablehnungsgrund**, nicht nur die
|
|
Ablehnung (§14.1)
|
|
|
|
Der letzte Punkt steht nicht aus Höflichkeit dort: Genau hier hatte diese
|
|
Umsetzung einen Fehler. Die API verpackt Fehler als
|
|
`{ error: { code, message } }`, das JavaScript las `data.message ||
|
|
data.error` — und zeigte `[object Object]` an, ausgerechnet dort, wo die
|
|
Begründung der Ablehnung stehen sollte.
|
|
|
|
---
|
|
|
|
## 11. Referenzdateien im Deploymentcenter
|
|
|
|
| Datei | Inhalt |
|
|
|---|---|
|
|
| `src/Core/Webauthn/Attestation.php` | Attestationsprüfung, Kette, AAGUID-Gegenprobe |
|
|
| `src/Core/Webauthn/HardwarePolicy.php` | Die drei Stufen, Modellliste, Bewachung |
|
|
| `src/Core/Webauthn/WebauthnServer.php` | Die beiden Zeremonien |
|
|
| `src/Core/Webauthn/CoseKey.php` | COSE → DER/PEM, von Hand |
|
|
| `src/Core/Webauthn/Cbor.php` | CBOR-Leser, nur so groß wie nötig |
|
|
| `src/Core/Webauthn/CredentialRepo.php` | Schlüssel und Notfalltoken |
|
|
| `src/Core/Webauthn/roots/yubico.pem` | Die drei Wurzeln |
|
|
| `public/api/auth/v1/index.php` | Die vier Aktionen |
|
|
| `public/login.php` | Zweite Stufe, Notfalltoken **und** Sperrbildschirm (§12.8) |
|
|
| `public/api/health.php` | Ankerdiagnose `checks.webauthn_anchor` (§12.6) |
|
|
| `sql/migrations/019_*.sql`, `021_*.sql` | Datenmodell des Schlüssels |
|
|
| `sql/migrations/022_*.sql` | `session_epoch` (§12.4) |
|
|
| `tests/e2e/suite_hardware.py` | Gruppen `hardware` und `haertung`, ohne Krypto-Bibliothek |
|
|
| `docs/UPGRADE.md` §17–§19 | Einführung, Verschärfung, Härtung im Betrieb |
|
|
|
|
---
|
|
|
|
## 12. Die Wege neben der Zeremonie
|
|
|
|
Alles hier stammt aus einer Begutachtung des fertigen Verfahrens. Die
|
|
Kryptographie musste dafür nicht angefasst werden — sie war in Ordnung. Jeder
|
|
Punkt ist unabhängig von der Sprache und gilt im Zielprojekt genauso.
|
|
|
|
### 12.1 Jeder Anmeldeweg muss den Faktor kennen
|
|
|
|
Das Deploymentcenter hat neben dem WebUI eine zweite Anmeldung für
|
|
Erstinstallationen. Sie prüfte ein TOTP-Feld, das **kein** Anmeldeweg
|
|
auswertet — und nicht die Tabelle, in der der tatsächlich umgesetzte zweite
|
|
Faktor liegt. Wer ein Administratorpasswort hatte, bekam dort ohne
|
|
Hardwarekey ein gültiges Token.
|
|
|
|
```php
|
|
if (!empty($user['totp_secret']) || Auth::secondFactorRequired($db, (int)$user['id'])) {
|
|
Http::fail(403, 'second_factor_required', '...');
|
|
}
|
|
if (Auth::roleOf($user) !== 'installer') {
|
|
Http::fail(403, 'installer_account_required', '...');
|
|
}
|
|
```
|
|
|
|
**Prüffrage fürs Zielprojekt:** Zählen Sie *alle* Stellen auf, an denen ein
|
|
Passwort gegen ein Konto geprüft wird. Jede einzelne muss den zweiten Faktor
|
|
kennen oder das Konto ablehnen. Eine Liste, die Sie nicht vollständig
|
|
aufschreiben können, ist der Befund.
|
|
|
|
**Und die Trennung symmetrisch:** Der Docblock behauptete seit jeher, dieser
|
|
Weg sei „für Installationskonten gedacht" — erzwungen hat das nichts. Der
|
|
umgekehrte Fall war dagegen geprüft. Eine Trennung, die nur in eine Richtung
|
|
wirkt, ist eine Notiz, keine Trennung.
|
|
|
|
### 12.2 Authentifizierung muss geschlossen scheitern
|
|
|
|
```php
|
|
} catch (\PDOException $e) {
|
|
if ($e->getCode() === '42S02' || $e->getCode() === '42S22') {
|
|
return false; // Migration steht aus - bekannt, harmlos
|
|
}
|
|
throw $e; // alles andere: abbrechen
|
|
}
|
|
```
|
|
|
|
Vorher stand dort `catch (\Throwable)` mit `return false`. Begründet war das
|
|
mit einer fehlenden Tabelle vor der Migration — gefangen wurden aber auch
|
|
Verbindungsabbrüche, Lock-Timeouts, „too many connections" und entzogene
|
|
Rechte. Jeder davon schaltete still für **alle** Konten auf Passwort-allein
|
|
zurück, und dieselbe Bauart hob bei `lockoutSeconds()` gleichzeitig den
|
|
Brute-Force-Schutz auf.
|
|
|
|
**Die Regel:** In einem Authentifizierungspfad darf ein `catch` nur die
|
|
Fehler schlucken, die Sie *benennen* können. Lieber keine Anmeldung als eine
|
|
ohne zweiten Faktor.
|
|
|
|
### 12.3 Die Sitzung erbt den Schutz nicht
|
|
|
|
Ein Hardwarekey schützt die **Anmeldung**. Er schützt nicht die Sitzung, die
|
|
daraus entsteht. Solange eine übernommene Sitzung die Anmeldemittel ändern
|
|
darf, ist er gegen genau den Fall wirkungslos, gegen den er gedacht ist:
|
|
|
|
- eigenen Schlüssel registrieren → dauerhafter Zugang, fällt niemandem auf
|
|
- Notfalltoken ausstellen → Ersatzweg in fremder Hand
|
|
- Regel abschalten → zweiter Faktor anlagenweit weg
|
|
|
|
Diese Aktionen brauchen eine Authentifizierung aus den letzten Minuten
|
|
(„sudo mode"). Im Deploymentcenter sind das zehn Minuten und **ein**
|
|
Bildschirm — derselbe, den auch der Leerlauf benutzt. Zwei Formulare, die
|
|
beide ein Passwort prüfen, laufen mit der Zeit auseinander.
|
|
|
|
### 12.4 Ein Passwortwechsel muss fremde Sitzungen beenden
|
|
|
|
Sonst lässt er genau den drin, dessentwegen man ihn vornimmt. PHP führt
|
|
keinen Index „alle Sitzungen des Kontos X"; eine Zählergeneration löst es mit
|
|
einer Spalte:
|
|
|
|
```sql
|
|
ALTER TABLE users ADD COLUMN session_epoch INT NOT NULL DEFAULT 0;
|
|
```
|
|
|
|
Die Sitzung merkt sich den Stand beim Anmelden, jede Anfrage vergleicht, der
|
|
Wechsel erhöht. Die eigene Sitzung übernimmt den neuen Stand — wer sein
|
|
Passwort ändert, soll sich nicht selbst hinauswerfen. Ein **Zähler**, keine
|
|
Uhr: kein Zeitzonen- und kein Gleichstandsproblem.
|
|
|
|
**Die Rollout-Falle:** Sitzungen, die vor der Migration entstanden sind,
|
|
tragen den Wert gar nicht. Wer sie als ungültig behandelt, wirft im Moment
|
|
der Einführung jeden Angemeldeten hinaus — und eine Sicherheitsverbesserung,
|
|
die das tut, wird beim nächsten Mal abgeschaltet. Fehlt der Wert, gilt die
|
|
Sitzung weiter:
|
|
|
|
```php
|
|
if (!isset($_SESSION['dc_session_epoch'])) {
|
|
return true; // aus der Zeit vor der Migration
|
|
}
|
|
```
|
|
|
|
Die Lücke schließt sich von selbst: Jede neue Anmeldung trägt den Stand ein.
|
|
|
|
### 12.5 Leerlauf: sperren, nicht beenden
|
|
|
|
AAL2 verlangt erneute Authentifizierung nach 30 Minuten Untätigkeit und lässt
|
|
zur Reaktivierung **einen** Faktor genügen. Wer stattdessen die volle
|
|
Anmeldung erzwingt, erzieht dazu, den Schlüssel steckend liegen zu lassen —
|
|
und dann ist der zweite Faktor keiner mehr. Eine Regel, die zur Umgehung
|
|
erzieht, ist schlechter als eine mildere, die eingehalten wird.
|
|
|
|
Eine gesperrte Sitzung darf sich außerdem **nicht selbst verlängern**, sonst
|
|
hält ein offener Tab mit Hintergrundabrufen die Sperre ewig auf.
|
|
|
|
### 12.6 Der Anker darf nicht vom Aufrufer kommen
|
|
|
|
`rpId` und `expectedOrigin` aus dem Host-Header abzuleiten heißt, den
|
|
Maßstab der Prüfung vom Geprüften setzen zu lassen. Fest konfigurieren, und
|
|
bei fehlender Konfiguration **werfen** statt raten.
|
|
|
|
Damit tauschen Sie aber eine stille Schwäche gegen eine stille Aussperrung:
|
|
Ohne den Wert nimmt die Anlage keinen Hardwarekey mehr an, und gemerkt wird
|
|
das beim nächsten Anmeldeversuch. Es braucht also eine Diagnose, die das
|
|
**vorher** sichtbar macht — im Deploymentcenter unter
|
|
`/api/health` → `checks.webauthn_anchor`.
|
|
|
|
**Und diese Diagnose muss vergleichen, nicht nur nachsehen.** Die erste
|
|
Fassung meldete, *ob* ein Wert gesetzt ist. Das beantwortet die Frage nicht:
|
|
Ein Tippfehler in der Adresse weist den Schlüssel genauso ab wie ein
|
|
fehlender Wert, weil der Browser den echten Ursprung in `clientDataJSON`
|
|
einträgt und der zu `expectedOrigin` passen muss.
|
|
|
|
```php
|
|
$erwarteterHost = parse_url($appUrl, PHP_URL_HOST);
|
|
$tatsaechlicherHost = explode(':', $_SERVER['HTTP_HOST'] ?? '')[0];
|
|
|
|
$checks['webauthn_anchor'] = [
|
|
'ok' => $gesetzt && $passt,
|
|
'configured' => $gesetzt,
|
|
'matches_request' => $passt,
|
|
];
|
|
```
|
|
|
|
Dass hier der Host-Header herangezogen wird, ist unbedenklich: Es ist eine
|
|
**Diagnose, keine Entscheidung**. Wer ihn fälscht, erzeugt eine falsche
|
|
Warnung und sonst nichts — während die Zeremonie ihn unverändert ignoriert.
|
|
Die Unterscheidung lohnt sich zu merken: Derselbe Wert darf an der einen
|
|
Stelle verboten und an der anderen nützlich sein, solange klar ist, welche
|
|
von beiden etwas entscheidet.
|
|
|
|
### 12.7 Kleinigkeiten mit Hebelwirkung
|
|
|
|
- **Fehlermeldungen am Login vereinheitlichen.** Gesperrt wird nur, was
|
|
existiert; eine eigene Meldung „Konto gesperrt" hebt jede sorgfältige
|
|
Laufzeitangleichung wieder auf. Die Wartezeit darf darin stehen, der Grund
|
|
nicht. Die IP-Sperre darf benannt werden — sie sagt nichts über ein Konto.
|
|
- **Drosselung auf den Anmeldepfaden.** Kontosperren greifen erst nach N
|
|
Versuchen; bis dahin darf sonst beliebig schnell geraten werden.
|
|
- **`session.use_strict_mode`.** PHP-Vorgabe ist *aus* — der Server übernimmt
|
|
jede vom Client erfundene Sitzungskennung.
|
|
- **CSRF-Token bei der Anmeldung verwerfen.** Es liegt in der Session und
|
|
überlebt `session_regenerate_id()` sonst.
|
|
- **Notfalltoken altern lassen.** Ein Zettel im Schrank altert; ein Token
|
|
ohne Ablauf nicht. Alter **nach** dem Hashvergleich prüfen, sonst verrät die
|
|
Antwortzeit, ob einer existiert.
|
|
- **Sperren auch auf der zweiten Stufe.** Sonst ist der Zwischenzustand
|
|
zwischen Passwort und Faktor die Stelle, an der unbegrenzt geraten werden
|
|
darf — also die schwächste, obwohl sie die stärkere sein soll.
|
|
|
|
### 12.8 Ein Bildschirm für Leerlauf und Bestätigung
|
|
|
|
§12.3 und §12.5 verlangen dasselbe: „bitte noch einmal das Passwort". Es
|
|
liegt nahe, dafür zwei Dinge zu bauen — einen Sperrbildschirm für den
|
|
Leerlauf und ein Bestätigungsformular für heikle Aktionen. Tun Sie es nicht.
|
|
Zwei Stellen, die beide Passwörter prüfen, laufen mit der Zeit auseinander,
|
|
und beim Prüfen von Passwörtern ist das die teuerste Art von Duplikat.
|
|
|
|
Der Trick ist, den Leerlauf-Zustand *willentlich* herbeizuführen:
|
|
|
|
```php
|
|
// Bei Leerlauf: sperren statt vernichten
|
|
if (($now - $lastSeen) > self::IDLE_TIMEOUT) {
|
|
$_SESSION['dc_locked'] = true;
|
|
return;
|
|
}
|
|
|
|
// Vor einer heiklen Aktion: denselben Zustand herstellen
|
|
public static function lockForReauth(): void
|
|
{
|
|
$_SESSION['dc_locked'] = true;
|
|
unset($_SESSION['dc_reauth_at']);
|
|
}
|
|
```
|
|
|
|
Die ganze Wirkung hängt dann an einer Zeile in `isLoggedIn()`:
|
|
|
|
```php
|
|
if (!empty($_SESSION['dc_locked'])) {
|
|
return false; // eine gesperrte Sitzung ist keine Anmeldung
|
|
}
|
|
```
|
|
|
|
Jede Stelle, die bisher `isLoggedIn()` gefragt hat — WebUI, API, Endpunkte —
|
|
bekommt damit ohne eigenes Zutun die richtige Antwort. Sie müssen nichts
|
|
aufzählen, und deshalb können Sie auch nichts vergessen.
|
|
|
|
Zwei Feinheiten, die sonst beißen:
|
|
|
|
- **Die Weiterleitung muss unterscheiden.** Eine gesperrte Sitzung gehört auf
|
|
den Sperrbildschirm (Passwort genügt), eine fehlende auf die Anmeldung
|
|
(Passwort *und* Faktor). Derselbe Ort für beides wäre entweder zu streng
|
|
oder zu lasch.
|
|
- **Die Formulardaten gehen verloren.** Nach dem Entsperren landet man wieder
|
|
auf der Seite, nicht in der abgebrochenen Aktion. Sagen Sie das in der
|
|
Meldung, sonst sucht man nach einer Wirkung, die nie eingetreten ist.
|
|
|
|
|
|
---
|
|
|
|
## 13. Was welche Zertifizierungsanforderung erfüllt
|
|
|
|
Nützlich, wenn das Zielprojekt ein Sicherheitsprotokoll hat: Die Zahlen in
|
|
diesem Dokument sind nicht gegriffen, sie stammen aus Anforderungen. Wer sie
|
|
ändert, sollte wissen, welche davon er verlässt.
|
|
|
|
| Entscheidung | Wert hier | Woher |
|
|
|---|---|---|
|
|
| Zweiter Faktor kryptographisch, phishing-resistent | WebAuthn | NIST SP 800-63B, AAL2 (Phishing-Resistenz verlangt erst AAL3 — WebAuthn liefert sie ohnehin) |
|
|
| Erneute Authentifizierung bei Untätigkeit | 30 Min | AAL2; **ein** Faktor genügt zur Reaktivierung |
|
|
| Absolute Sitzungsgrenze | 12 Std | AAL2 |
|
|
| Mindestlänge Passwort | 12 Zeichen | PCI DSS 4.0, 8.3.6 |
|
|
| Kontosperre nach N Fehlversuchen | 5 | PCI DSS 4.0, 8.3.4 verlangt höchstens 10 |
|
|
| MFA auf **allen** Zugangswegen | §12.1 | PCI DSS 4.0, 8.4.2 — der Befund, der die Setup-API betraf |
|
|
| Erneute Authentifizierung vor Änderung der Anmeldemittel | 10 Min | OWASP ASVS, Kapitel Authentifizierung |
|
|
| Sitzungen bei Passwortwechsel beenden | §12.4 | OWASP ASVS, Kapitel Sitzungsverwaltung |
|
|
| HSTS, TLS-Zwang, Sicherheits-Header | §12.7 | OWASP ASVS, Kapitel Konfiguration |
|
|
| Protokollierung sicherheitsrelevanter Ereignisse | SecurityLogger | ISO 27001:2022 A.8.15 |
|
|
| Authentisierungsinformation, sichere Authentisierung | §12.1–§12.4 | ISO 27001:2022 A.5.17, A.8.5 |
|
|
| Identitäts- und Berechtigungsmanagement | Rollentrennung §12.1 | BSI IT-Grundschutz ORP.4, APP.3.1 |
|
|
|
|
**Zwei Ehrlichkeiten dazu:**
|
|
|
|
- Eine Begutachtung, die nur die Authentifizierungskapitel betrachtet, kann
|
|
auch nur über diese eine Aussage treffen. „Die genannten Blocker sind
|
|
geschlossen" ist belegbar; „ASVS L2 bestanden" wäre es nicht.
|
|
- ISO 27001 zertifiziert das Managementsystem, nicht den Code. Was hier steht,
|
|
ist Nachweismaterial für einen Auditor — kein Zertifikat.
|
|
|
|
### 13.1 Wo die Anforderungen sich widersprechen
|
|
|
|
Das ist der interessante Teil. AAL2 verlangt Reauthentifizierung alle 30
|
|
Minuten. Nimmt man das als „volle Anmeldung mit Hardwarekey", entsteht ein
|
|
Verhalten, das der Standard gerade nicht will: Der Schlüssel bleibt stecken,
|
|
weil ihn jemand achtmal am Tag sucht. Derselbe Standard lässt deshalb **einen**
|
|
Faktor zur Reaktivierung genügen.
|
|
|
|
> Eine Regel, die zur Umgehung erzieht, ist schlechter als eine mildere, die
|
|
> eingehalten wird.
|
|
|
|
Wer im Zielprojekt eine Zahl verschärft, sollte diese Frage mitstellen:
|
|
Welches Verhalten erzeugt sie bei dem Menschen, der sie täglich einhalten
|
|
muss?
|
|
|
|
---
|
|
|
|
## 14. Wie man belegt, was sich nicht unit-testen lässt
|
|
|
|
Drei der wichtigsten Zusicherungen in diesem Dokument lassen sich nicht mit
|
|
einem gewöhnlichen Testfall prüfen. Sie sind trotzdem belegbar — nur anders.
|
|
|
|
### 14.1 Der Test muss aus dem richtigen Grund scheitern
|
|
|
|
Die Probe aus §2.3 (selbst ausgestelltes Zertifikat) ist wertlos, wenn die
|
|
Ablehnung wegen der **Signatur** kommt: Dann hat der Test sich selbst falsch
|
|
gebaut und über die Kette nichts gezeigt. Prüfen Sie also nicht nur *dass*
|
|
abgelehnt wurde, sondern **womit**:
|
|
|
|
```python
|
|
if 'signatur' in meldung.lower():
|
|
return 'UNKLAR: an der Signatur gescheitert - beweist nichts ueber die Kette'
|
|
if 'wurzel' in meldung.lower() or 'kette' in meldung.lower():
|
|
return 'BESTANDEN'
|
|
```
|
|
|
|
Das gilt allgemein: Ein negativer Sicherheitstest, der den Ablehnungsgrund
|
|
nicht prüft, bestätigt irgendwann nur noch, dass irgendetwas kaputt ist.
|
|
|
|
### 14.2 Manche Eigenschaften verlangen echtes Warten
|
|
|
|
Ob eine **veraltete** Sitzung die Anmeldemittel noch ändern kann, hängt am
|
|
Zehn-Minuten-Fenster. Es für den Test zu verkleinern liegt nahe — und prüft
|
|
dann eine andere Anlage als die, die später läuft. Im Deploymentcenter
|
|
wartet ein Werkzeug außerhalb der Suite die vollen elf Minuten ab und prüft
|
|
danach:
|
|
|
|
```
|
|
A) Formularweg -> Sperrbildschirm
|
|
B) Zustand danach -> unverändert (die Aktion hatte keine Wirkung)
|
|
C) JSON-Weg -> 403 reauth_required
|
|
D) Sperrbildschirm -> wird angezeigt
|
|
E) Passwort -> öffnet wieder
|
|
```
|
|
|
|
Punkt B ist der eigentliche Beweis. „Wurde umgeleitet" heißt noch nicht
|
|
„hatte keine Wirkung" — eine Umleitung nach getaner Arbeit sieht genauso aus.
|
|
|
|
**Eine Falle aus eigener Erfahrung:** Beim ersten Anlauf schickte die Probe
|
|
beide Wege durch dieselbe Sitzung. Schritt A hatte sie bereits gesperrt, also
|
|
meldete der JSON-Weg folgerichtig „nicht angemeldet" statt
|
|
„reauth_required" — ein Fehler der Probe, nicht des Codes. Zwei Zustände, die
|
|
sich gegenseitig ausschließen, brauchen zwei Sitzungen.
|
|
|
|
### 14.3 Was sich gar nicht nachstellen lässt
|
|
|
|
Der Gegenfall zu §2.3 — ein echter Yubikey, der eine gültige Kette vorlegt —
|
|
ist von einem Testrechner aus nicht erzeugbar; dafür bräuchte man den privaten
|
|
Wurzelschlüssel des Herstellers, und genau darauf beruht die ganze Aussage.
|
|
Diese Hälfte belegt nur echte Hardware. Planen Sie den Handgriff ein, statt
|
|
ihn wegzuautomatisieren.
|
|
|
|
### 14.4 Was in die automatische Reihe gehört
|
|
|
|
Alles, was ohne Kryptographie und ohne Warten auskommt — und das ist mehr,
|
|
als es zunächst scheint. Bei einer Registrierung mit `fmt: "none"` prüft der
|
|
Server **keine Signatur**; es genügt ein fest eingebauter, gültiger
|
|
Kurvenpunkt. Damit lässt sich der entscheidende Vergleich ohne jede
|
|
Abhängigkeit fahren:
|
|
|
|
> Dieselben Bytes eines Authenticators ohne Herkunftsnachweis — einmal mit
|
|
> und einmal ohne Regel.
|
|
|
|
Ebenso automatisierbar: dass die Anmeldung die Existenz eines Kontos nicht
|
|
verrät, dass HSTS und CSP ankommen, dass die Diagnose aus §12.6 grün meldet,
|
|
und dass ein Konto der falschen Rolle am zweiten Anmeldepfad abgewiesen wird.
|