Zwei Befunde aus der Abnahme auf der Testinstanz.
1. Die Ablehnung eines Softwarekeys erschien als "[object Object]".
Die API verpackt Fehler als { error: { code, message } }; das
JavaScript las "data.message || data.error" und griff damit auf das
OBJEKT zu - ausgerechnet an der Stelle, an der die Begruendung stehen
sollte, warum ein Schluessel abgelehnt wurde. Eine Regel, die ihren
Grund nicht nennt, ist in der Bedienung dasselbe wie ein Defekt.
Derselbe Fehler steckte in login.php. Dort waere er schwerer wiegend:
An der zweiten Stufe ist diese Meldung der einzige Hinweis, warum es
nicht weitergeht. Beide lesen jetzt beide Formen - die verschachtelte
und die flache, die weiterhin vorkommt.
2. Die Textbox fuer die Modellliste stand neben statt unter ihrer
Beschriftung und behielt ihre Eigengroesse. Der umschliessende
form-group fehlte; genau der setzt im Stylesheet display:flex mit
column und laesst das Feld die Breite fuellen. Alle anderen
Textboxen der Oberflaeche haben ihn.
Zusaetzlich docs/HARDWAREKEY.md: Konzept, Beispielcode und Prueflíste
zur gleichwertigen Uebernahme in ein anderes Projekt. Der Schwerpunkt
liegt auf den drei Dingen, die wie eine Pruefung aussehen und keine
sind - cross-platform, response.authenticatorAttachment und ein blosses
x5c -, samt dem Gegentest, ohne den nichts belegt ist.
Kein Eingriff in src/, sql/ oder public/api/ - der Kern ist unveraendert
gegenueber dem Lauf mit 184 von 184 gruenen Testfaellen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
477 lines
19 KiB
Markdown
477 lines
19 KiB
Markdown
# Hardwarekey als zweiter Faktor — Konzept und Übernahme
|
|
|
|
**Stand: Deploymentcenter 2.9.2, 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 — und
|
|
besonders die drei Dinge, die *wie* eine Prüfung aussehen und keine sind.
|
|
|
|
---
|
|
|
|
## 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 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
|
|
|
|
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 |
|
|
| `sql/migrations/019_*.sql`, `021_*.sql` | Datenmodell |
|
|
| `tests/e2e/suite_hardware.py` | Fünf Fälle, ohne Krypto-Bibliothek |
|
|
| `docs/UPGRADE.md` §17, §18 | Einführung und Verschärfung im Betrieb |
|