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
+111
@@ -674,3 +674,114 @@ alten Namens werden dabei aufgeräumt.
|
||||
- [ ] Roadmap → 🧭 Tafel zeigt Spalten, Spuren und Kärtchen
|
||||
- [ ] Ein Klick auf ein Kärtchen öffnet das Panel, Kommentieren funktioniert
|
||||
- [ ] `python tests/e2e/run.py` gegen die Testinstanz: alles grün
|
||||
|
||||
---
|
||||
|
||||
## 18. Umstellung auf 2.9.2 — nur echte Hardware als zweiter Faktor
|
||||
|
||||
### 18.1 Worum es geht
|
||||
|
||||
Seit 2.9.0 verlangt die Anmeldung einen Hardwarekey. Was sie *nicht* verlangte,
|
||||
war, dass es wirklich Hardware ist: Ein Passkey aus einem Passwortverwalter
|
||||
(1Password, Bitwarden, iCloud, Google) ließ sich ebenso registrieren wie ein
|
||||
Yubikey. Für das Deploymentcenter selbst ist das hinnehmbar. Wo ein
|
||||
Sicherheitsprotokoll ausdrücklich Hardware fordert, ist es der Unterschied
|
||||
zwischen einer Regel und einem Wunsch.
|
||||
|
||||
Zwei Dinge, die *wie* eine Prüfung aussehen und keine sind — beide waren in
|
||||
2.9.0 bereits gesetzt:
|
||||
|
||||
- `authenticatorAttachment: "cross-platform"` steuert, was der Browser
|
||||
**anbietet**. Ein Passwortverwalter meldet sich ebenfalls als
|
||||
cross-platform.
|
||||
- `response.authenticatorAttachment` setzt der **Client**, und der Client ist
|
||||
genau die Instanz, gegen die sich hier nichts beweisen lässt.
|
||||
|
||||
Was trägt, ist die Attestation: Der Authenticator unterschreibt bei der
|
||||
Aufnahme mit einem Schlüssel, der ab Werk in seinem Sicherheitselement liegt
|
||||
und es nie verlässt. Das zugehörige Zertifikat muss auf eine hinterlegte
|
||||
Herstellerwurzel ketten.
|
||||
|
||||
**Ein Zertifikat allein genügt nicht.** Jeder kann sich eines ausstellen, es
|
||||
„Yubico U2F EE“ nennen, die AAGUID eines echten YubiKey 5 eintragen und sauber
|
||||
unterschreiben. Genau dieser Fall wurde nachgebaut und gegen den Server
|
||||
gefahren: Er wird abgewiesen — an der **Kette**, nicht an der Signatur. Was
|
||||
Software nicht herstellen kann, ist eine Kette, die bei Yubicos Wurzel endet.
|
||||
|
||||
### 18.2 Migration
|
||||
|
||||
```bash
|
||||
php cli/migrate.php # oder System → 🗄️ DB Migration im WebUI
|
||||
```
|
||||
|
||||
Migration 021 ergänzt `dc_user_credentials` um `attested`, `attestation_fmt`,
|
||||
`attestation_ca` und `attestation_root`. Sie setzt **keine** Regel — ohne
|
||||
Umstellen bleibt das Verhalten von 2.9.1.
|
||||
|
||||
### 18.3 Die drei Stufen
|
||||
|
||||
| Stufe | Aufnahme neuer Schlüssel | Anmeldung mit vorhandenen |
|
||||
|---|---|---|
|
||||
| `off` | jeder Authenticator | alle |
|
||||
| `register` | nur mit Herstellernachweis | alle |
|
||||
| `strict` | nur mit Herstellernachweis | nur nachgewiesene |
|
||||
|
||||
Einzustellen unter **System → 👤 Benutzer → 🛡️ Welche Schlüssel zugelassen
|
||||
sind**.
|
||||
|
||||
### 18.4 Der Weg nach `strict` — in dieser Reihenfolge
|
||||
|
||||
> **Alle vor 2.9.2 registrierten Schlüssel gelten als nicht nachgewiesen —
|
||||
> auch echte Yubikeys.** Das ist keine Vorsicht, sondern eine Tatsache des
|
||||
> Standards: Bei `attestation: "none"` ersetzt der **Browser** die
|
||||
> Gerätekennung durch Nullbytes, bevor der Server sie zu sehen bekommt. Es gab
|
||||
> also nie eine Herkunft, nicht einmal eine ungeprüfte.
|
||||
|
||||
1. Stufe auf **`register`** stellen.
|
||||
2. Den Hardwarekey **einmal neu aufnehmen**. In der Liste muss danach
|
||||
*🔒 Hardware* stehen, mit der erreichten Wurzel.
|
||||
3. Den alten, nicht nachgewiesenen Eintrag entfernen.
|
||||
4. Erst jetzt auf **`strict`**.
|
||||
|
||||
Schritt 4 wird abgelehnt, solange das eigene Konto keinen nachgewiesenen
|
||||
Schlüssel hat — eine Einstellung, die sich selbst aussperrt, ist keine
|
||||
Einstellung, sondern eine Falle.
|
||||
|
||||
### 18.5 Wenn doch etwas klemmt
|
||||
|
||||
- **Der Browser fragt nach Marke und Modell.** Das ist der sichtbare Preis von
|
||||
`attestation: "direct"` und kein Fehler. Wer ablehnt, liefert keine
|
||||
Attestation und wird abgewiesen.
|
||||
- **Ausgesperrt.** Der Notfalltoken funktioniert unabhängig von der Regel.
|
||||
Danach unter System → Benutzer die Stufe zurückstellen.
|
||||
- **Ganz ohne WebUI:**
|
||||
```sql
|
||||
DELETE FROM dc_settings WHERE skey = 'webauthn_hardware_policy';
|
||||
```
|
||||
Fehlt der Eintrag, gilt wieder `off`.
|
||||
|
||||
### 18.6 Andere Hersteller als Yubico
|
||||
|
||||
Mitgeliefert sind die drei FIDO-Wurzeln von Yubico
|
||||
(`src/Core/Webauthn/roots/yubico.pem`). Weitere Wurzeln in der `config.php`:
|
||||
|
||||
```php
|
||||
'security' => [
|
||||
'webauthn_root_files' => ['/etc/dc/solokeys.pem'],
|
||||
],
|
||||
```
|
||||
|
||||
Bewusst dort und nicht im WebUI: Eine Vertrauenswurzel über dieselbe
|
||||
Oberfläche austauschbar zu machen, die sie schützt, hieße die Prüfung dort
|
||||
aushebelbar zu machen.
|
||||
|
||||
### 18.7 Prüfen, ob alles läuft
|
||||
|
||||
- [ ] Migration 021 ist durch (Spalte `attested` in `dc_user_credentials`)
|
||||
- [ ] Stufe `register`: Der Yubikey wird aufgenommen und zeigt *🔒 Hardware*
|
||||
- [ ] Stufe `register`: Ein Passwortverwalter wird mit einer Meldung abgewiesen,
|
||||
die den Grund nennt
|
||||
- [ ] `strict` lässt sich **nicht** einschalten, solange kein nachgewiesener
|
||||
Schlüssel am eigenen Konto hängt
|
||||
- [ ] Nach `strict`: Anmeldung mit dem Yubikey funktioniert weiter
|
||||
- [ ] `python tests/e2e/run.py --gruppe hardware`: alles grün
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
-- Migration 021: Herkunft eines Hardwarekeys festhalten
|
||||
--
|
||||
-- Additive Migration. Der Migrator toleriert 1050/1060/1061/1062/1091.
|
||||
--
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Warum das nicht schon in 019 stand
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 019 hat die Attestation bewusst nicht geprueft und deshalb auch nichts
|
||||
-- darueber gespeichert: Wer seinen eigenen Schluessel an seinem eigenen Server
|
||||
-- registriert, muss sich nicht ausweisen lassen. Die Spalte aaguid war dabei
|
||||
-- schon angelegt - und blieb in der Praxis immer leer. Das ist kein Fehler
|
||||
-- der Umsetzung, sondern Absicht des Standards: Fordert eine Seite
|
||||
-- attestation "none" an, ersetzt der BROWSER die Geraetekennung durch
|
||||
-- sechzehn Nullbytes, bevor der Server sie zu sehen bekommt. Ohne
|
||||
-- angeforderte Attestation gibt es also grundsaetzlich keine Herkunft, nicht
|
||||
-- einmal eine ungepruefte.
|
||||
--
|
||||
-- Gebraucht wird sie, seit die Frage anders lautet: Ein Passwortverwalter
|
||||
-- liess sich als zweiter Faktor registrieren. Fuer das Deploymentcenter
|
||||
-- selbst ist das hinnehmbar, fuer ein Protokoll, das ausdruecklich Hardware
|
||||
-- verlangt, nicht.
|
||||
--
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- attested ist die einzige Spalte, an der eine Entscheidung haengt
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Die drei uebrigen sind Anzeige und Nachvollziehbarkeit. Die Trennung ist
|
||||
-- beabsichtigt: attestation_ca und attestation_root kommen aus einem
|
||||
-- Zertifikat, das der Authenticator geschickt hat - also von aussen. Sie
|
||||
-- eignen sich, um einem Menschen zu zeigen, was da angekommen ist, aber nicht
|
||||
-- als Grundlage eines Vergleichs im Code. Was die Pruefung ergeben hat, steht
|
||||
-- in genau einem Bit, und das hat der Server selbst gesetzt.
|
||||
--
|
||||
-- Vorgabe 0: Alles, was vor dieser Migration registriert wurde, ist NICHT
|
||||
-- attestiert - und zwar unabhaengig davon, ob es ein Yubikey war. Das ist die
|
||||
-- ehrliche Vorgabe. Ein "war bestimmt Hardware" waere eine Behauptung ueber
|
||||
-- etwas, das nie geprueft wurde, und genau solche Behauptungen soll diese
|
||||
-- Migration abschaffen. Wer die Stufe "strict" nutzen will, nimmt seinen
|
||||
-- Schluessel einmal neu auf; das ist der Preis dafuer, dass die Spalte etwas
|
||||
-- bedeutet.
|
||||
ALTER TABLE dc_user_credentials
|
||||
ADD COLUMN attested TINYINT(1) NOT NULL DEFAULT 0 AFTER aaguid;
|
||||
|
||||
-- Das Attestationsformat, so wie der Authenticator es genannt hat:
|
||||
-- "packed" (CTAP2, der Normalfall beim YubiKey 5), "fido-u2f" (CTAP1),
|
||||
-- "none" (Passwortverwalter und eingebaute Passkeys).
|
||||
ALTER TABLE dc_user_credentials
|
||||
ADD COLUMN attestation_fmt VARCHAR(32) NULL AFTER attested;
|
||||
|
||||
-- Der Name aus dem Blattzertifikat - bei Yubico etwa
|
||||
-- "Yubico U2F EE Serial 1234567890".
|
||||
ALTER TABLE dc_user_credentials
|
||||
ADD COLUMN attestation_ca VARCHAR(190) NULL AFTER attestation_fmt;
|
||||
|
||||
-- Die Wurzel, bei der die Kette geendet hat. Sie steht hier und nicht nur im
|
||||
-- Protokoll, weil sie die Frage "warum gilt dieser Schluessel als Hardware"
|
||||
-- ohne Blick in die Logdatei beantwortet.
|
||||
ALTER TABLE dc_user_credentials
|
||||
ADD COLUMN attestation_root VARCHAR(190) NULL AFTER attestation_ca;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Die Regel selbst steht in dc_settings, nicht hier
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Eingetragen wird sie erst beim ersten Speichern in der Oberflaeche. Ein
|
||||
-- INSERT waere hier moeglich, wuerde aber eine Entscheidung vorwegnehmen:
|
||||
-- Eine Migration, die eine Sicherheitsstufe setzt, aendert im Vorbeigehen das
|
||||
-- Verhalten einer laufenden Anlage. Fehlt der Eintrag, liest
|
||||
-- HardwarePolicy::level() die Vorgabe "off" - also genau das Verhalten von
|
||||
-- vorher. Umgestellt wird, wenn jemand umstellt.
|
||||
@@ -0,0 +1,501 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Core\Webauthn;
|
||||
|
||||
/**
|
||||
* Prueft die Attestation eines Authenticators - also die Frage, ob ein
|
||||
* Schluessel wirklich in einem Stueck Hardware entstanden ist.
|
||||
*
|
||||
* WARUM ES DIESE KLASSE GIBT
|
||||
*
|
||||
* Die erste Fassung des Hardwarekeys (2.9.0) hat die Attestation bewusst
|
||||
* nicht geprueft; der Kommentar in WebauthnServer begruendete das damit, dass
|
||||
* hier der Betreiber seinen eigenen Schluessel an seinem eigenen Server
|
||||
* registriert. Fuer das Deploymentcenter stimmt das weiterhin. Im Betrieb kam
|
||||
* aber heraus, was diese Entscheidung sonst noch bedeutet: Ein Passwort-
|
||||
* verwalter (1Password) liess sich genauso anstandslos registrieren wie ein
|
||||
* Yubikey. Fuer ein Sicherheitsprotokoll, das "nur Hardware" verlangt, ist
|
||||
* das der Unterschied zwischen Regel und Wunsch.
|
||||
*
|
||||
* WAS NICHT GEREICHT HAETTE
|
||||
*
|
||||
* In den Registrierungsvorgaben stand von Anfang an
|
||||
* authenticatorAttachment = "cross-platform". Das sieht aus wie eine
|
||||
* Beschraenkung auf angesteckte Hardware, ist aber keine: Die Angabe steuert,
|
||||
* was der Browser dem Benutzer anbietet, und ein Passwortverwalter meldet
|
||||
* sich ebenfalls als "cross-platform" an. Sie ist ein Hinweis an die
|
||||
* Oberflaeche, keine Aussage, die der Server nachpruefen koennte.
|
||||
*
|
||||
* Ebenso wenig traegt response.authenticatorAttachment aus dem Browser: Das
|
||||
* ist ein Feld, das der Client setzt, und der Client ist genau die Instanz,
|
||||
* gegen die wir hier nichts beweisen koennen.
|
||||
*
|
||||
* WAS TRAEGT
|
||||
*
|
||||
* Basic Attestation. Der Authenticator unterschreibt bei der Registrierung
|
||||
* mit einem Schluessel, der ab Werk in seinem Sicherheitselement liegt und
|
||||
* ihn nicht verlaesst. Das zugehoerige Zertifikat kettet auf eine Wurzel des
|
||||
* Herstellers. Wer diese Kette faelschen will, braucht den privaten
|
||||
* Wurzelschluessel von Yubico - keine Software kann sie herleiten.
|
||||
*
|
||||
* Und deshalb reicht die Kette allein nicht, und x5c allein erst recht nicht:
|
||||
* Ein beliebiger Authenticator kann sich selbst ein Zertifikat ausstellen und
|
||||
* damit sauber unterschreiben. Ohne die Pruefung gegen eine Wurzel, der wir
|
||||
* vorher vertraut haben, beweist ein x5c nichts. Genau das ist der Grund,
|
||||
* warum diese Klasse eine Datei mit Wurzelzertifikaten braucht und nicht mit
|
||||
* "ist ein Zertifikat dabei?" auskommt.
|
||||
*
|
||||
* WO DIE GRENZE LIEGT
|
||||
*
|
||||
* Geprueft wird bei der REGISTRIERUNG. Bei einer spaeteren Anmeldung liefert
|
||||
* der Authenticator keine Attestation mehr - der Standard sieht das nicht
|
||||
* vor. Die Aussage lautet deshalb: "Dieser Schluessel ist nachweislich in
|
||||
* Hardware entstanden", und sie traegt danach, weil der private Teil diese
|
||||
* Hardware nicht verlassen kann. Wer das Ergebnis spaeter verwenden will,
|
||||
* liest die gespeicherte Spalte attested - nicht ein neues Zertifikat.
|
||||
*/
|
||||
final class Attestation
|
||||
{
|
||||
/** OID der FIDO-Erweiterung, die die AAGUID im Zertifikat wiederholt. */
|
||||
private const OID_AAGUID = '1.3.6.1.4.1.45724.1.1.4';
|
||||
|
||||
/** Mehr Zwischenzertifikate hat keine reale Kette. Schutz vor Schleifen. */
|
||||
private const MAX_CHAIN_DEPTH = 6;
|
||||
|
||||
/**
|
||||
* Das Ergebnis einer Attestationspruefung.
|
||||
*
|
||||
* "attested" ist die einzige Angabe, an der eine Entscheidung haengen
|
||||
* darf. Alles andere ist Anzeige und Protokoll.
|
||||
*
|
||||
* @return array{attested:bool,format:string,aaguid:?string,subject:?string,root:?string,reason:?string}
|
||||
*/
|
||||
public static function inspect(
|
||||
array $attestationObject,
|
||||
string $authDataRaw,
|
||||
string $clientDataJson,
|
||||
?string $aaguidFromAuthData,
|
||||
array $trustedRoots
|
||||
): array {
|
||||
$format = is_string($attestationObject['fmt'] ?? null) ? $attestationObject['fmt'] : 'unbekannt';
|
||||
$stmt = is_array($attestationObject['attStmt'] ?? null) ? $attestationObject['attStmt'] : [];
|
||||
|
||||
$result = [
|
||||
'attested' => false,
|
||||
'format' => $format,
|
||||
'aaguid' => $aaguidFromAuthData,
|
||||
'subject' => null,
|
||||
'root' => null,
|
||||
'reason' => null,
|
||||
];
|
||||
|
||||
// "none" ist kein Fehler, sondern eine Auskunft: Der Authenticator hat
|
||||
// nichts ueber sich behauptet. Passwortverwalter und die in Betriebs-
|
||||
// systeme eingebauten Passkeys landen hier - sie haben kein
|
||||
// Herstellerzertifikat, weil es kein Geraet gibt, das sie ausweisen
|
||||
// koennte.
|
||||
if ($format === 'none' || $stmt === []) {
|
||||
$result['reason'] = 'Der Authenticator hat keine Attestation mitgeschickt.';
|
||||
return $result;
|
||||
}
|
||||
|
||||
$chain = $stmt['x5c'] ?? null;
|
||||
|
||||
if (!is_array($chain) || $chain === [] || !is_string($chain[0])) {
|
||||
// "self attestation": mit dem eigenen Schluessel unterschrieben.
|
||||
// Beweist, dass der Schluessel zu sich selbst passt - und sonst
|
||||
// nichts. Insbesondere nichts ueber Hardware.
|
||||
$result['reason'] = 'Die Attestation ist selbst unterschrieben und nennt keinen Hersteller.';
|
||||
return $result;
|
||||
}
|
||||
|
||||
try {
|
||||
$leafPem = self::derToPem((string)$chain[0]);
|
||||
|
||||
$parsed = openssl_x509_parse($leafPem);
|
||||
if (!is_array($parsed)) {
|
||||
throw new \RuntimeException('Das Attestationszertifikat ist unlesbar.');
|
||||
}
|
||||
|
||||
$result['subject'] = self::describe($parsed);
|
||||
|
||||
self::checkValidity($parsed);
|
||||
|
||||
// Die Signatur selbst. Unterschrieben wird ueber die
|
||||
// Authenticator-Daten und den Hash der Clientdaten - bei "packed"
|
||||
// aneinandergehaengt, bei "fido-u2f" in einer eigenen Form.
|
||||
$signature = is_string($stmt['sig'] ?? null) ? $stmt['sig'] : '';
|
||||
if ($signature === '') {
|
||||
throw new \RuntimeException('Die Attestation enthaelt keine Signatur.');
|
||||
}
|
||||
|
||||
$clientDataHash = hash('sha256', $clientDataJson, true);
|
||||
|
||||
if ($format === 'packed') {
|
||||
self::verifyPacked($leafPem, $signature, $authDataRaw . $clientDataHash, $stmt);
|
||||
self::checkAaguidExtension($parsed, $aaguidFromAuthData);
|
||||
} elseif ($format === 'fido-u2f') {
|
||||
self::verifyU2f($leafPem, $signature, $authDataRaw, $clientDataHash);
|
||||
} else {
|
||||
// "tpm", "android-key", "apple" und die uebrigen Formate sind
|
||||
// nicht umgesetzt. Sie abzulehnen ist ehrlicher, als sie
|
||||
// durchzuwinken: Ein nicht geprueftes Format als "geprueft" zu
|
||||
// fuehren waere genau der Fehler, den diese Klasse behebt.
|
||||
throw new \RuntimeException(sprintf(
|
||||
'Das Attestationsformat "%s" wird hier nicht geprueft.',
|
||||
$format
|
||||
));
|
||||
}
|
||||
|
||||
$root = self::chainVerifies($chain, $trustedRoots);
|
||||
|
||||
if ($root === null) {
|
||||
throw new \RuntimeException(
|
||||
'Die Zertifikatskette endet nicht bei einer hinterlegten Herstellerwurzel.'
|
||||
);
|
||||
}
|
||||
|
||||
$result['attested'] = true;
|
||||
$result['root'] = $root;
|
||||
|
||||
return $result;
|
||||
} catch (\Throwable $e) {
|
||||
$result['reason'] = $e->getMessage();
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Die beiden geprueften Formate
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* "packed" - das Format, das ein YubiKey 5 ueber CTAP2 liefert.
|
||||
*/
|
||||
private static function verifyPacked(string $leafPem, string $signature, string $signed, array $stmt): void
|
||||
{
|
||||
$alg = isset($stmt['alg']) ? (int)$stmt['alg'] : CoseKey::ES256;
|
||||
|
||||
$key = openssl_pkey_get_public($leafPem);
|
||||
if ($key === false) {
|
||||
throw new \RuntimeException('Der oeffentliche Schluessel des Zertifikats ist unlesbar.');
|
||||
}
|
||||
|
||||
if (openssl_verify($signed, $signature, $key, CoseKey::digestFor($alg)) !== 1) {
|
||||
throw new \RuntimeException('Die Attestationssignatur stimmt nicht.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "fido-u2f" - der aeltere Weg, den ein Schluessel ueber CTAP1 geht.
|
||||
*
|
||||
* Hier wird nicht ueber authData unterschrieben, sondern ueber eine eigens
|
||||
* zusammengesetzte Folge. Das ist kein Schoenheitsfehler des Standards,
|
||||
* sondern Erbe: Das Format stammt aus der Zeit vor WebAuthn, und die
|
||||
* Signatur musste zu dem passen, was U2F-Geraete schon unterschrieben.
|
||||
*
|
||||
* 0x00 | rpIdHash | clientDataHash | credentialId | 0x04 | x | y
|
||||
*/
|
||||
private static function verifyU2f(
|
||||
string $leafPem,
|
||||
string $signature,
|
||||
string $authDataRaw,
|
||||
string $clientDataHash
|
||||
): void {
|
||||
if (strlen($authDataRaw) < 55) {
|
||||
throw new \RuntimeException('Die Authenticator-Daten sind fuer fido-u2f zu kurz.');
|
||||
}
|
||||
|
||||
$rpIdHash = substr($authDataRaw, 0, 32);
|
||||
|
||||
$lengthBytes = unpack('nlen', substr($authDataRaw, 53, 2));
|
||||
$credentialLength = is_array($lengthBytes) ? (int)$lengthBytes['len'] : 0;
|
||||
|
||||
if ($credentialLength < 1 || strlen($authDataRaw) < 55 + $credentialLength) {
|
||||
throw new \RuntimeException('Die Laengenangabe der Schluesselkennung ist unbrauchbar.');
|
||||
}
|
||||
|
||||
$credentialId = substr($authDataRaw, 55, $credentialLength);
|
||||
|
||||
$offset = 55 + $credentialLength;
|
||||
$cose = Cbor::decode($authDataRaw, $offset);
|
||||
|
||||
if (!is_array($cose) || !is_string($cose[-2] ?? null) || !is_string($cose[-3] ?? null)) {
|
||||
throw new \RuntimeException('fido-u2f erwartet einen EC-Schluessel mit x und y.');
|
||||
}
|
||||
|
||||
$x = str_pad($cose[-2], 32, "\x00", STR_PAD_LEFT);
|
||||
$y = str_pad($cose[-3], 32, "\x00", STR_PAD_LEFT);
|
||||
|
||||
$signed = "\x00" . $rpIdHash . $clientDataHash . $credentialId . "\x04" . $x . $y;
|
||||
|
||||
$key = openssl_pkey_get_public($leafPem);
|
||||
if ($key === false) {
|
||||
throw new \RuntimeException('Der oeffentliche Schluessel des Zertifikats ist unlesbar.');
|
||||
}
|
||||
|
||||
// U2F kennt nur ES256; ein anderes Verfahren gibt es in diesem Format
|
||||
// nicht, deshalb steht der Digest hier fest.
|
||||
if (openssl_verify($signed, $signature, $key, OPENSSL_ALGO_SHA256) !== 1) {
|
||||
throw new \RuntimeException('Die U2F-Attestationssignatur stimmt nicht.');
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Die Kette
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Laeuft vom Blatt ueber die mitgelieferten Zwischenzertifikate zu einer
|
||||
* der hinterlegten Wurzeln.
|
||||
*
|
||||
* Bewusst von Hand und nicht ueber openssl_x509_checkpurpose(): Dessen
|
||||
* Wurzelspeicher ist der des Betriebssystems, und dort stehen die
|
||||
* Zertifizierungsstellen des Web-PKI - also genau die falsche Menge.
|
||||
* Eine Attestationskette soll bei Yubico enden und nirgends sonst.
|
||||
*
|
||||
* @param list<mixed> $derChain x5c aus dem Attestationsobjekt
|
||||
* @param list<string> $trustedRoots PEM der akzeptierten Wurzeln
|
||||
* @return string|null Name der erreichten Wurzel, oder null
|
||||
*/
|
||||
private static function chainVerifies(array $derChain, array $trustedRoots): ?string
|
||||
{
|
||||
if ($trustedRoots === []) {
|
||||
throw new \RuntimeException(
|
||||
'Es sind keine Herstellerwurzeln hinterlegt - eine Kette laesst sich nicht pruefen.'
|
||||
);
|
||||
}
|
||||
|
||||
$intermediates = [];
|
||||
foreach (array_slice($derChain, 1) as $der) {
|
||||
if (is_string($der)) {
|
||||
$intermediates[] = self::derToPem($der);
|
||||
}
|
||||
}
|
||||
|
||||
$current = self::derToPem((string)$derChain[0]);
|
||||
|
||||
for ($step = 0; $step < self::MAX_CHAIN_DEPTH; $step++) {
|
||||
// Zuerst die Wurzeln: Endet die Kette hier, sind wir fertig. Das
|
||||
// Blatt kann direkt von der Wurzel unterschrieben sein - bei
|
||||
// Yubico ist genau das der Normalfall.
|
||||
foreach ($trustedRoots as $rootPem) {
|
||||
if (self::signedBy($current, $rootPem)) {
|
||||
$parsed = openssl_x509_parse($rootPem);
|
||||
return is_array($parsed) ? self::describe($parsed) : 'unbenannte Wurzel';
|
||||
}
|
||||
}
|
||||
|
||||
$next = null;
|
||||
foreach ($intermediates as $index => $candidate) {
|
||||
if (self::signedBy($current, $candidate)) {
|
||||
$next = $candidate;
|
||||
unset($intermediates[$index]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($next === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parsedNext = openssl_x509_parse($next);
|
||||
if (is_array($parsedNext)) {
|
||||
self::checkValidity($parsedNext);
|
||||
}
|
||||
|
||||
$current = $next;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function signedBy(string $certPem, string $issuerPem): bool
|
||||
{
|
||||
$issuerKey = openssl_pkey_get_public($issuerPem);
|
||||
if ($issuerKey === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return openssl_x509_verify($certPem, $issuerKey) === 1;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Pruefungen am einzelnen Zertifikat
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Ein abgelaufenes Zertifikat wird abgelehnt - mit eigener Meldung.
|
||||
*
|
||||
* Die eigene Meldung ist der Punkt: "abgelaufen" und "falsche Wurzel"
|
||||
* fuehren beide zur Ablehnung, verlangen aber voellig verschiedene
|
||||
* Reaktionen. Eine gemeinsame Meldung waere bei der Fehlersuche wertlos.
|
||||
*/
|
||||
private static function checkValidity(array $parsed): void
|
||||
{
|
||||
$now = time();
|
||||
$from = isset($parsed['validFrom_time_t']) ? (int)$parsed['validFrom_time_t'] : 0;
|
||||
$to = isset($parsed['validTo_time_t']) ? (int)$parsed['validTo_time_t'] : 0;
|
||||
|
||||
if ($from > 0 && $now < $from) {
|
||||
throw new \RuntimeException(sprintf(
|
||||
'Das Attestationszertifikat gilt erst ab %s.',
|
||||
gmdate('Y-m-d', $from)
|
||||
));
|
||||
}
|
||||
|
||||
if ($to > 0 && $now > $to) {
|
||||
throw new \RuntimeException(sprintf(
|
||||
'Das Attestationszertifikat ist am %s abgelaufen.',
|
||||
gmdate('Y-m-d', $to)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die AAGUID im Zertifikat muss zu der in den Authenticator-Daten passen.
|
||||
*
|
||||
* Ohne diese Pruefung waere eine Wurzelpruefung zwar bestanden, die
|
||||
* Modellangabe aber frei erfunden: Ein Geraet koennte mit seinem echten
|
||||
* Zertifikat unterschreiben und daneben die AAGUID eines anderen Modells
|
||||
* behaupten. Wer spaeter nach AAGUID filtert, filterte dann nach einer
|
||||
* Behauptung statt nach einer Eigenschaft.
|
||||
*
|
||||
* Die Erweiterung ist optional - fehlt sie, wird nichts behauptet und
|
||||
* also auch nichts widerlegt.
|
||||
*/
|
||||
private static function checkAaguidExtension(array $parsed, ?string $expected): void
|
||||
{
|
||||
$extensions = is_array($parsed['extensions'] ?? null) ? $parsed['extensions'] : [];
|
||||
|
||||
$raw = null;
|
||||
foreach ($extensions as $name => $value) {
|
||||
if ((string)$name === self::OID_AAGUID && is_string($value)) {
|
||||
$raw = $value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($raw === null || $expected === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Die Erweiterung ist ein OCTET STRING in einem OCTET STRING. Statt
|
||||
// den Rahmen zu zerlegen, wird darin nach den 16 Byte gesucht - das
|
||||
// Ergebnis ist dasselbe und haengt nicht davon ab, wie viele Huellen
|
||||
// eine Fassung des Standards vorsieht.
|
||||
if (strpos($raw, $expected) === false) {
|
||||
throw new \RuntimeException(
|
||||
'Die Geraetekennung im Zertifikat weicht von der in den Authenticator-Daten ab.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Wurzeln laden
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Liest die Wurzeln aus den mitgelieferten und den zusaetzlich
|
||||
* konfigurierten Dateien.
|
||||
*
|
||||
* @param list<string> $paths
|
||||
* @return list<string> PEM je Zertifikat
|
||||
*/
|
||||
public static function loadRoots(array $paths): array
|
||||
{
|
||||
$roots = [];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = (string)file_get_contents($path);
|
||||
|
||||
if (preg_match_all(
|
||||
'/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/s',
|
||||
$content,
|
||||
$matches
|
||||
)) {
|
||||
foreach ($matches[0] as $pem) {
|
||||
$roots[] = $pem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $roots;
|
||||
}
|
||||
|
||||
/** Die mitgelieferte Wurzeldatei. */
|
||||
public static function bundledRootFile(): string
|
||||
{
|
||||
return __DIR__ . '/roots/yubico.pem';
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Kleinkram
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private static function derToPem(string $der): string
|
||||
{
|
||||
if ($der === '') {
|
||||
throw new \RuntimeException('Ein Zertifikat der Kette ist leer.');
|
||||
}
|
||||
|
||||
return "-----BEGIN CERTIFICATE-----\n"
|
||||
. chunk_split(base64_encode($der), 64, "\n")
|
||||
. "-----END CERTIFICATE-----\n";
|
||||
}
|
||||
|
||||
/** Ein lesbarer Name fuer die Anzeige - der CN, sonst der ganze Betreff. */
|
||||
private static function describe(array $parsed): string
|
||||
{
|
||||
$subject = is_array($parsed['subject'] ?? null) ? $parsed['subject'] : [];
|
||||
|
||||
if (is_string($subject['CN'] ?? null) && $subject['CN'] !== '') {
|
||||
return mb_substr($subject['CN'], 0, 190);
|
||||
}
|
||||
|
||||
if (is_string($parsed['name'] ?? null)) {
|
||||
return mb_substr($parsed['name'], 0, 190);
|
||||
}
|
||||
|
||||
return 'ohne Namen';
|
||||
}
|
||||
|
||||
/** Die AAGUID als lesbare Kennung, so wie Hersteller sie angeben. */
|
||||
public static function formatAaguid(?string $raw): ?string
|
||||
{
|
||||
if ($raw === null || strlen($raw) !== 16) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hex = bin2hex($raw);
|
||||
|
||||
return sprintf(
|
||||
'%s-%s-%s-%s-%s',
|
||||
substr($hex, 0, 8),
|
||||
substr($hex, 8, 4),
|
||||
substr($hex, 12, 4),
|
||||
substr($hex, 16, 4),
|
||||
substr($hex, 20, 12)
|
||||
);
|
||||
}
|
||||
|
||||
/** Die Umkehrung - aus der lesbaren Kennung die 16 Byte. */
|
||||
public static function parseAaguid(string $formatted): ?string
|
||||
{
|
||||
$hex = preg_replace('/[^0-9a-f]/i', '', $formatted);
|
||||
|
||||
if (!is_string($hex) || strlen($hex) !== 32) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = hex2bin(strtolower($hex));
|
||||
|
||||
return $raw === false ? null : $raw;
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,13 @@ final class CredentialRepo
|
||||
*/
|
||||
public function forUser(int $userId): array
|
||||
{
|
||||
// SELECT * und nicht die Spalten einzeln: Vor Migration 021 gibt es
|
||||
// die Attestationsspalten noch nicht, und eine Abfrage, die sie
|
||||
// namentlich verlangt, bricht dann ab. Die Oberflaeche meldete
|
||||
// daraufhin "Migration 019 fehlt" - eine falsche Diagnose, die beim
|
||||
// Suchen mehr kostet als die paar ungenutzten Spalten hier.
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT id, credential_id, label, sign_count, transports, created_at, last_used_at
|
||||
FROM dc_user_credentials
|
||||
SELECT * FROM dc_user_credentials
|
||||
WHERE user_id = :u
|
||||
ORDER BY created_at ASC
|
||||
');
|
||||
@@ -48,6 +52,18 @@ final class CredentialRepo
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$row['credential_b64'] = WebauthnServer::b64uEncode((string)$row['credential_id']);
|
||||
// Die Geraetekennung wird hier lesbar gemacht und nicht erst in
|
||||
// der Ausgabe: Sie soll sich aus der Oberflaeche in die
|
||||
// Modellliste kopieren lassen, und dafuer muss sie ueberall
|
||||
// gleich geschrieben sein.
|
||||
$row['aaguid_text'] = Attestation::formatAaguid(
|
||||
($row['aaguid'] ?? null) === null ? null : (string)$row['aaguid']
|
||||
);
|
||||
$row['attested'] = (int)($row['attested'] ?? 0) === 1;
|
||||
$row['attestation_fmt'] = $row['attestation_fmt'] ?? null;
|
||||
$row['attestation_ca'] = $row['attestation_ca'] ?? null;
|
||||
$row['attestation_root'] = $row['attestation_root'] ?? null;
|
||||
unset($row['aaguid'], $row['public_key']);
|
||||
// Die rohe Kennung hat in der Oberflaeche nichts zu suchen - sie
|
||||
// ist binaer und wuerde jede HTML-Ausgabe verunstalten.
|
||||
unset($row['credential_id']);
|
||||
@@ -60,11 +76,23 @@ final class CredentialRepo
|
||||
/**
|
||||
* Die rohen Kennungen eines Kontos, fuer allowCredentials.
|
||||
*
|
||||
* $attestedOnly ist der Hebel, mit dem die Stufe "strict" wirkt - und sie
|
||||
* wirkt hier und nicht erst beim Pruefen der Signatur. Der Unterschied ist
|
||||
* nicht bloss technisch: Ein Schluessel, der gar nicht erst in
|
||||
* allowCredentials steht, wird vom Browser nicht angeboten. Der Benutzer
|
||||
* sieht "dieser Schluessel passt hier nicht" statt erst nach dem
|
||||
* Beruehren eine Ablehnung zu kassieren.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function rawIdsForUser(int $userId): array
|
||||
public function rawIdsForUser(int $userId, bool $attestedOnly = false): array
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT credential_id FROM dc_user_credentials WHERE user_id = :u');
|
||||
$sql = 'SELECT credential_id FROM dc_user_credentials WHERE user_id = :u';
|
||||
if ($attestedOnly) {
|
||||
$sql .= ' AND attested = 1';
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([':u' => $userId]);
|
||||
|
||||
return array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN));
|
||||
@@ -78,6 +106,17 @@ final class CredentialRepo
|
||||
return (int)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/** Wie viele davon sich als Hardware ausgewiesen haben. */
|
||||
public function countAttestedForUser(int $userId): int
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'SELECT COUNT(*) FROM dc_user_credentials WHERE user_id = :u AND attested = 1'
|
||||
);
|
||||
$stmt->execute([':u' => $userId]);
|
||||
|
||||
return (int)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht einen Schluessel - und zwar am Konto, nicht nur an der Kennung.
|
||||
*
|
||||
@@ -112,13 +151,18 @@ final class CredentialRepo
|
||||
int $signCount,
|
||||
?string $aaguid,
|
||||
string $label,
|
||||
?string $transports
|
||||
?string $transports,
|
||||
array $attestation = []
|
||||
): void {
|
||||
$attested = ($attestation['attested'] ?? false) === true;
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO dc_user_credentials
|
||||
(user_id, credential_id, public_key, alg, sign_count, aaguid, transports, label, created_at)
|
||||
(user_id, credential_id, public_key, alg, sign_count, aaguid, attested,
|
||||
attestation_fmt, attestation_ca, attestation_root, transports, label, created_at)
|
||||
VALUES
|
||||
(:u, :c, :pk, :alg, :sc, :aaguid, :transports, :label, UTC_TIMESTAMP())
|
||||
(:u, :c, :pk, :alg, :sc, :aaguid, :attested,
|
||||
:fmt, :ca, :root, :transports, :label, UTC_TIMESTAMP())
|
||||
');
|
||||
$stmt->bindValue(':u', $userId, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':c', $credentialId, PDO::PARAM_LOB);
|
||||
@@ -126,11 +170,28 @@ final class CredentialRepo
|
||||
$stmt->bindValue(':alg', $alg, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':sc', $signCount, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':aaguid', $aaguid, $aaguid === null ? PDO::PARAM_NULL : PDO::PARAM_LOB);
|
||||
$stmt->bindValue(':attested', $attested ? 1 : 0, PDO::PARAM_INT);
|
||||
|
||||
// Die drei Textspalten stammen aus einem Zertifikat, das von aussen
|
||||
// kam. Sie werden deshalb gekuerzt gespeichert: Die Spaltenbreite ist
|
||||
// die Grenze, und was darueber hinausgeht, gehoert nicht in die
|
||||
// Datenbank, sondern hat dort nie etwas verloren gehabt.
|
||||
foreach ([':fmt' => 'format', ':ca' => 'subject', ':root' => 'root'] as $bind => $field) {
|
||||
$value = $attestation[$field] ?? null;
|
||||
$value = is_string($value) && $value !== '' ? mb_substr($value, 0, 180) : null;
|
||||
$stmt->bindValue($bind, $value, $value === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
|
||||
}
|
||||
|
||||
$stmt->bindValue(':transports', $transports, $transports === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
|
||||
$stmt->bindValue(':label', $label);
|
||||
$stmt->execute();
|
||||
|
||||
Logger::info('Hardwarekey registriert', ['user_id' => $userId, 'label' => $label]);
|
||||
Logger::info('Hardwarekey registriert', [
|
||||
'user_id' => $userId,
|
||||
'label' => $label,
|
||||
'attested' => $attested,
|
||||
'root' => $attestation['root'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function touch(int $credentialRowId, int $signCount): void
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Core\Webauthn;
|
||||
|
||||
use Deploymentcenter\Core\Config;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Die Regel, welche Authenticatoren ueberhaupt zugelassen sind.
|
||||
*
|
||||
* DREI STUFEN, UND WARUM NICHT ZWEI
|
||||
*
|
||||
* Ein einzelner Schalter "nur Hardware" waere die naheliegende Loesung und
|
||||
* haette eine Falle: Alle bereits registrierten Schluessel sind ohne
|
||||
* Attestation aufgenommen worden - auch ein echter Yubikey. Ein Schalter, der
|
||||
* sofort auch die Anmeldung einschraenkt, sperrt in dem Moment, in dem man ihn
|
||||
* umlegt, den eigenen Zugang aus. Zurueck ginge es nur ueber den
|
||||
* Notfalltoken.
|
||||
*
|
||||
* Deshalb drei Stufen, und die mittlere ist die wichtige:
|
||||
*
|
||||
* off Wie vor 2.9.2. Es wird keine Attestation angefordert und
|
||||
* nichts geprueft. Jeder Passkey darf sich registrieren.
|
||||
*
|
||||
* register Neue Registrierungen muessen eine gepruefte Hardware-
|
||||
* Attestation vorlegen. Bereits vorhandene Schluessel melden
|
||||
* sich weiter an. Das ist der Zustand, in dem man den Yubikey
|
||||
* neu aufnimmt, ohne sich auszusperren.
|
||||
*
|
||||
* strict Zusaetzlich duerfen sich nur noch geprueft in Hardware
|
||||
* entstandene Schluessel anmelden. Erst diese Stufe erfuellt ein
|
||||
* Protokoll, das "nur Hardware" verlangt.
|
||||
*
|
||||
* Der Uebergang nach "strict" ist bewusst bewacht (siehe blockingReason()):
|
||||
* Wer keinen attestierten Schluessel hat, kann die Stufe nicht einschalten.
|
||||
* Eine Einstellung, die sich selbst aussperrt, ist keine Einstellung, sondern
|
||||
* eine Falle.
|
||||
*
|
||||
* WAS DIE AAGUID-LISTE SOLL - UND WAS NICHT
|
||||
*
|
||||
* Die Wurzelpruefung beantwortet "stammt das von Yubico". Die AAGUID-Liste
|
||||
* verengt auf einzelne Modelle. Sie ist optional und leer voreingestellt,
|
||||
* denn sie hat einen Unterhaltspreis: Yubico weist neuen Firmwarestaenden
|
||||
* neue AAGUIDs zu, und eine gepflegte Liste ist damit eine Liste, die
|
||||
* irgendwann einen echten Schluessel abweist. Wer sie fuellt, sollte wissen,
|
||||
* warum ihm das Modell und nicht der Hersteller wichtig ist.
|
||||
*/
|
||||
final class HardwarePolicy
|
||||
{
|
||||
public const OFF = 'off';
|
||||
public const REGISTER = 'register';
|
||||
public const STRICT = 'strict';
|
||||
|
||||
public const LEVELS = [self::OFF, self::REGISTER, self::STRICT];
|
||||
|
||||
/** Schluessel in dc_settings. */
|
||||
private const KEY_LEVEL = 'webauthn_hardware_policy';
|
||||
private const KEY_ALLOWLIST = 'webauthn_aaguid_allowlist';
|
||||
|
||||
public function __construct(private PDO $db)
|
||||
{
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Zustand
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public function level(): string
|
||||
{
|
||||
$stored = $this->setting(self::KEY_LEVEL);
|
||||
|
||||
return in_array($stored, self::LEVELS, true) ? $stored : self::OFF;
|
||||
}
|
||||
|
||||
public function isOff(): bool
|
||||
{
|
||||
return $this->level() === self::OFF;
|
||||
}
|
||||
|
||||
/** Gilt die Regel auch fuer die Anmeldung, nicht nur fuer die Aufnahme? */
|
||||
public function appliesToLogin(): bool
|
||||
{
|
||||
return $this->level() === self::STRICT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Was in den Registrierungsvorgaben als attestation steht.
|
||||
*
|
||||
* "direct" laesst den Browser das Herstellerzertifikat durchreichen.
|
||||
* Manche Browser fragen den Benutzer dabei einmal, ob die Seite Marke und
|
||||
* Modell des Schluessels erfahren darf - das ist der sichtbare Preis
|
||||
* dieser Funktion und kein Fehler.
|
||||
*/
|
||||
public function conveyance(): string
|
||||
{
|
||||
return $this->isOff() ? 'none' : 'direct';
|
||||
}
|
||||
|
||||
/**
|
||||
* Die zugelassenen Geraetekennungen, roh zu je 16 Byte.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function allowedAaguids(): array
|
||||
{
|
||||
$raw = $this->setting(self::KEY_ALLOWLIST) ?? '';
|
||||
|
||||
$out = [];
|
||||
foreach (preg_split('/[\s,;]+/', $raw) ?: [] as $entry) {
|
||||
if (trim($entry) === '') {
|
||||
continue;
|
||||
}
|
||||
$parsed = Attestation::parseAaguid($entry);
|
||||
if ($parsed !== null) {
|
||||
$out[] = $parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Wurzeln, gegen die geprueft wird.
|
||||
*
|
||||
* Mitgeliefert wird Yubico. Wer andere Hardware zulassen will, nennt in
|
||||
* der Konfiguration weitere Dateien - security.webauthn_root_files. Das
|
||||
* steht bewusst in der Konfiguration und nicht in der Datenbank: Eine
|
||||
* Vertrauenswurzel ueber die Weboberflaeche austauschbar zu machen hiesse,
|
||||
* die Pruefung ueber dieselbe Oberflaeche aushebelbar zu machen, die sie
|
||||
* schuetzen soll.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function roots(): array
|
||||
{
|
||||
$paths = [Attestation::bundledRootFile()];
|
||||
|
||||
$extra = Config::get('security.webauthn_root_files', []);
|
||||
if (is_string($extra) && $extra !== '') {
|
||||
$extra = [$extra];
|
||||
}
|
||||
if (is_array($extra)) {
|
||||
foreach ($extra as $path) {
|
||||
if (is_string($path) && $path !== '') {
|
||||
$paths[] = $path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Attestation::loadRoots($paths);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Entscheiden
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Darf ein Schluessel mit diesem Pruefergebnis aufgenommen werden?
|
||||
*
|
||||
* @param array{attested:bool,format:string,aaguid:?string,subject:?string,root:?string,reason:?string} $inspection
|
||||
* @return array{ok:bool,reason:?string}
|
||||
*/
|
||||
public function acceptsRegistration(array $inspection): array
|
||||
{
|
||||
if ($this->isOff()) {
|
||||
return ['ok' => true, 'reason' => null];
|
||||
}
|
||||
|
||||
if ($inspection['attested'] !== true) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'reason' => sprintf(
|
||||
'Dieser Authenticator weist sich nicht als Hardware aus (%s). '
|
||||
. 'Zugelassen sind nur Schluessel mit einer geprueften Herstellerattestation.',
|
||||
$inspection['reason'] ?? 'kein Grund angegeben'
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
$allowed = $this->allowedAaguids();
|
||||
|
||||
if ($allowed === []) {
|
||||
return ['ok' => true, 'reason' => null];
|
||||
}
|
||||
|
||||
$aaguid = $inspection['aaguid'];
|
||||
|
||||
if ($aaguid === null) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'reason' => 'Der Authenticator nennt keine Geraetekennung, '
|
||||
. 'es ist aber eine Modellliste hinterlegt.',
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($allowed as $candidate) {
|
||||
if (hash_equals($candidate, $aaguid)) {
|
||||
return ['ok' => true, 'reason' => null];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'reason' => sprintf(
|
||||
'Das Modell %s steht nicht auf der Liste der zugelassenen Geraete.',
|
||||
Attestation::formatAaguid($aaguid) ?? 'unbekannt'
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Umschalten
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Warum die gewuenschte Stufe gerade nicht eingeschaltet werden kann.
|
||||
*
|
||||
* Nur "strict" ist bewacht, und nur gegen den einen Fall, der wirklich
|
||||
* aussperrt: kein einziger attestierter Schluessel am eigenen Konto. Dass
|
||||
* andere Konten darunter fallen koennten, ist Absicht und keine
|
||||
* Nachlaessigkeit - wer die Regel setzt, setzt sie fuer die Anlage, und
|
||||
* ein Konto ohne passenden Schluessel hat weiterhin den Notfalltoken.
|
||||
*/
|
||||
public function blockingReason(string $desiredLevel, int $actingUserId): ?string
|
||||
{
|
||||
if ($desiredLevel !== self::STRICT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT COUNT(*) FROM dc_user_credentials
|
||||
WHERE user_id = :u AND attested = 1
|
||||
');
|
||||
$stmt->execute([':u' => $actingUserId]);
|
||||
|
||||
if ((int)$stmt->fetchColumn() > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'Fuer das eigene Konto ist kein geprueft in Hardware entstandener Schluessel '
|
||||
. 'hinterlegt. Die Stufe "strict" wuerde die eigene Anmeldung sperren. '
|
||||
. 'Zuerst auf "register" stellen und den Hardwarekey neu aufnehmen.';
|
||||
}
|
||||
|
||||
public function setLevel(string $level): void
|
||||
{
|
||||
if (!in_array($level, self::LEVELS, true)) {
|
||||
throw new \InvalidArgumentException(sprintf('Unbekannte Stufe "%s".', $level));
|
||||
}
|
||||
|
||||
$this->store(self::KEY_LEVEL, $level);
|
||||
}
|
||||
|
||||
/** Nimmt die Liste so entgegen, wie sie im Formular steht. */
|
||||
public function setAllowlist(string $raw): void
|
||||
{
|
||||
$clean = [];
|
||||
|
||||
foreach (preg_split('/[\s,;]+/', $raw) ?: [] as $entry) {
|
||||
if (trim($entry) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsed = Attestation::parseAaguid($entry);
|
||||
if ($parsed === null) {
|
||||
throw new \InvalidArgumentException(sprintf(
|
||||
'"%s" ist keine Geraetekennung. Erwartet werden 32 Hexziffern, '
|
||||
. 'ueblicherweise als 8-4-4-4-12 geschrieben.',
|
||||
mb_substr(trim($entry), 0, 60)
|
||||
));
|
||||
}
|
||||
|
||||
$formatted = Attestation::formatAaguid($parsed);
|
||||
if ($formatted !== null && !in_array($formatted, $clean, true)) {
|
||||
$clean[] = $formatted;
|
||||
}
|
||||
}
|
||||
|
||||
$this->store(self::KEY_ALLOWLIST, implode("\n", $clean));
|
||||
}
|
||||
|
||||
/** Die Liste, wie sie im Formular stehen soll. */
|
||||
public function allowlistText(): string
|
||||
{
|
||||
return $this->setting(self::KEY_ALLOWLIST) ?? '';
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// dc_settings
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private function setting(string $key): ?string
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT svalue FROM dc_settings WHERE skey = :k LIMIT 1');
|
||||
$stmt->execute([':k' => $key]);
|
||||
|
||||
$value = $stmt->fetchColumn();
|
||||
|
||||
return is_string($value) ? $value : null;
|
||||
}
|
||||
|
||||
private function store(string $key, string $value): void
|
||||
{
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO dc_settings (skey, svalue) VALUES (:k, :v)
|
||||
ON DUPLICATE KEY UPDATE svalue = VALUES(svalue)
|
||||
');
|
||||
$stmt->execute([':k' => $key, ':v' => $value]);
|
||||
}
|
||||
}
|
||||
@@ -16,14 +16,17 @@ use Deploymentcenter\Core\Http;
|
||||
* global (und damit fuer parallele Anmeldungen falsch) oder sie braeuchte
|
||||
* eine eigene Tabelle fuer etwas, das ohnehin nur zwei Minuten lebt.
|
||||
*
|
||||
* Zur Attestation: Geprueft wird sie nicht ("none"). Attestation beantwortet
|
||||
* die Frage "stammt dieser Schluessel wirklich von Hersteller X" - das ist
|
||||
* die Frage eines Unternehmens, das Geraete vorschreibt. Hier registriert der
|
||||
* Betreiber seinen eigenen Schluessel an seinem eigenen Server; die Frage
|
||||
* stellt sich nicht, und ihre Beantwortung braeuchte eine gepflegte Liste von
|
||||
* Herstellerzertifikaten. Was zaehlt - dass spaetere Anmeldungen mit
|
||||
* demselben privaten Schluessel unterschrieben sind - haengt an der
|
||||
* Attestation nicht.
|
||||
* Zur Attestation: Ob sie geprueft wird, entscheidet seit 2.9.2 die
|
||||
* HardwarePolicy, nicht mehr diese Klasse. Sie liefert hier die Vorgabe
|
||||
* ("none" oder "direct") und bekommt das Pruefergebnis zurueck.
|
||||
*
|
||||
* Bis 2.9.1 stand an dieser Stelle die Begruendung, warum nicht geprueft
|
||||
* wird: Der Betreiber registriere seinen eigenen Schluessel an seinem eigenen
|
||||
* Server, und die Frage nach dem Hersteller stelle sich nicht. Das galt fuer
|
||||
* das Deploymentcenter und gilt dort weiter - es uebersah nur, dass aus
|
||||
* "wird nicht geprueft" in der Praxis "jeder Passwortverwalter darf" wird.
|
||||
* Wer diese Unterscheidung braucht, schaltet die Regel ein; wer nicht,
|
||||
* bleibt bei "off" und bekommt das Verhalten von vorher.
|
||||
*/
|
||||
final class WebauthnServer
|
||||
{
|
||||
@@ -85,7 +88,8 @@ final class WebauthnServer
|
||||
string $challenge,
|
||||
int $userId,
|
||||
string $username,
|
||||
array $existingCredentialIds
|
||||
array $existingCredentialIds,
|
||||
string $attestationConveyance = 'none'
|
||||
): array {
|
||||
$exclude = [];
|
||||
foreach ($existingCredentialIds as $id) {
|
||||
@@ -114,7 +118,7 @@ final class WebauthnServer
|
||||
],
|
||||
'pubKeyCredParams' => $params,
|
||||
'timeout' => self::CHALLENGE_TTL * 1000,
|
||||
'attestation' => 'none',
|
||||
'attestation' => $attestationConveyance,
|
||||
'excludeCredentials' => $exclude,
|
||||
'authenticatorSelection' => [
|
||||
// "cross-platform": gemeint ist ein Stueck Hardware zum
|
||||
@@ -122,6 +126,12 @@ final class WebauthnServer
|
||||
// dem man gerade sitzt. Wer Windows Hello registriert, hat
|
||||
// einen zweiten Faktor, der mit dem ersten im selben Geraet
|
||||
// sitzt.
|
||||
//
|
||||
// Diese Angabe steuert, was der Browser ANBIETET. Sie ist
|
||||
// keine Pruefung: Ein Passwortverwalter meldet sich ebenfalls
|
||||
// als "cross-platform" und kommt damit durch. Wer Hardware
|
||||
// erzwingen will, braucht die Attestation - siehe
|
||||
// HardwarePolicy.
|
||||
'authenticatorAttachment' => 'cross-platform',
|
||||
'userVerification' => 'discouraged',
|
||||
'residentKey' => 'discouraged',
|
||||
@@ -132,12 +142,21 @@ final class WebauthnServer
|
||||
/**
|
||||
* Prueft die Antwort des Browsers auf create() und liefert, was gespeichert wird.
|
||||
*
|
||||
* @return array{credential_id:string,public_key:string,sign_count:int,aaguid:?string,alg:int}
|
||||
* Die Attestation wird immer UNTERSUCHT, aber nie hier entschieden. Diese
|
||||
* Trennung ist der Grund, warum das Ergebnis als eigenes Feld zurueckkommt
|
||||
* und nicht als Ausnahme: Ob ein nicht attestierter Schluessel abgelehnt
|
||||
* gehoert, ist eine Frage der Betriebsregel und nicht der Zeremonie. Steht
|
||||
* die Regel auf "off", ist dasselbe Ergebnis eine blosse Auskunft, die in
|
||||
* der Oberflaeche angezeigt wird.
|
||||
*
|
||||
* @param list<string> $trustedRoots PEM der akzeptierten Herstellerwurzeln
|
||||
* @return array{credential_id:string,public_key:string,sign_count:int,aaguid:?string,alg:int,attestation:array<string,mixed>}
|
||||
*/
|
||||
public static function verifyRegistration(
|
||||
string $clientDataJson,
|
||||
string $attestationObject,
|
||||
string $expectedChallenge
|
||||
string $expectedChallenge,
|
||||
array $trustedRoots = []
|
||||
): array {
|
||||
self::checkClientData($clientDataJson, 'webauthn.create', $expectedChallenge);
|
||||
|
||||
@@ -154,12 +173,21 @@ final class WebauthnServer
|
||||
|
||||
$key = CoseKey::toPem($authData['cose']);
|
||||
|
||||
$inspection = Attestation::inspect(
|
||||
$attestation,
|
||||
(string)$attestation['authData'],
|
||||
$clientDataJson,
|
||||
$authData['aaguid'],
|
||||
$trustedRoots
|
||||
);
|
||||
|
||||
return [
|
||||
'credential_id' => $authData['credential_id'],
|
||||
'public_key' => $key['pem'],
|
||||
'sign_count' => $authData['sign_count'],
|
||||
'aaguid' => $authData['aaguid'],
|
||||
'alg' => $key['alg'],
|
||||
'attestation' => $inspection,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Wurzelzertifikate der Yubico-Attestation (FIDO/WebAuthn).
|
||||
#
|
||||
# Herkunft: https://developers.yubico.com/PKI/yubico-ca-certs.txt
|
||||
# Abgerufen am 2026-09-21.
|
||||
#
|
||||
# Warum diese drei und nicht alle fuenf: Yubico veroeffentlicht unter derselben
|
||||
# Adresse auch die Wurzeln fuer PIV und OpenPGP. Die signieren andere Dinge -
|
||||
# Smartcard-Schluessel und OpenPGP-Schluessel - und haben in einer
|
||||
# WebAuthn-Attestationskette nichts zu suchen. Wer sie hier mit aufnimmt,
|
||||
# vergroessert die Menge des Akzeptierten, ohne die Frage zu beantworten, um
|
||||
# die es geht: Hat ein FIDO-Authenticator diesen Schluessel erzeugt?
|
||||
#
|
||||
# Die drei stehen nebeneinander, weil Yubico die Wurzel im Laufe der Zeit
|
||||
# gewechselt hat. Ein YubiKey 5 aus 2019 kettet auf die U2F-Wurzel von 2014,
|
||||
# ein Geraet aus 2025 auf eine der neueren. Keine davon darf wegfallen,
|
||||
# solange noch Geraete im Umlauf sind, die darauf zeigen.
|
||||
|
||||
# --- Yubico U2F Root CA Serial 457200631 (2014) -----------------------------
|
||||
# Die Wurzel der YubiKey-4- und -5-Serie.
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDHjCCAgagAwIBAgIEG0BT9zANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZ
|
||||
dWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAw
|
||||
MDBaGA8yMDUwMDkwNDAwMDAwMFowLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290
|
||||
IENBIFNlcmlhbCA0NTcyMDA2MzEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
|
||||
AoIBAQC/jwYuhBVlqaiYWEMsrWFisgJ+PtM91eSrpI4TK7U53mwCIawSDHy8vUmk
|
||||
5N2KAj9abvT9NP5SMS1hQi3usxoYGonXQgfO6ZXyUA9a+KAkqdFnBnlyugSeCOep
|
||||
8EdZFfsaRFtMjkwz5Gcz2Py4vIYvCdMHPtwaz0bVuzneueIEz6TnQjE63Rdt2zbw
|
||||
nebwTG5ZybeWSwbzy+BJ34ZHcUhPAY89yJQXuE0IzMZFcEBbPNRbWECRKgjq//qT
|
||||
9nmDOFVlSRCt2wiqPSzluwn+v+suQEBsUjTGMEd25tKXXTkNW21wIWbxeSyUoTXw
|
||||
LvGS6xlwQSgNpk2qXYwf8iXg7VWZAgMBAAGjQjBAMB0GA1UdDgQWBBQgIvz0bNGJ
|
||||
hjgpToksyKpP9xv9oDAPBgNVHRMECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAN
|
||||
BgkqhkiG9w0BAQsFAAOCAQEAjvjuOMDSa+JXFCLyBKsycXtBVZsJ4Ue3LbaEsPY4
|
||||
MYN/hIQ5ZM5p7EjfcnMG4CtYkNsfNHc0AhBLdq45rnT87q/6O3vUEtNMafbhU6kt
|
||||
hX7Y+9XFN9NpmYxr+ekVY5xOxi8h9JDIgoMP4VB1uS0aunL1IGqrNooL9mmFnL2k
|
||||
LVVee6/VR6C5+KSTCMCWppMuJIZII2v9o4dkoZ8Y7QRjQlLfYzd3qGtKbw7xaF1U
|
||||
sG/5xUb/Btwb2X2g4InpiB/yt/3CpQXpiWX/K4mBvUKiGn05ZsqeY1gx4g0xLBqc
|
||||
U9psmyPzK+Vsgw2jeRQ5JlKDyqE0hebfC1tvFu0CCrJFcw==
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# --- Yubico FIDO Root CA Serial 450203556 (2024) ----------------------------
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDMzCCAhugAwIBAgIUSOEjTf//yqRfPW7Qq8qtIyCrAg8wDQYJKoZIhvcNAQEL
|
||||
BQAwLzEtMCsGA1UEAwwkWXViaWNvIEZJRE8gUm9vdCBDQSBTZXJpYWwgNDUwMjAz
|
||||
NTU2MCAXDTI0MDUwMTAwMDAwMFoYDzIwNjAwNDMwMDAwMDAwWjAvMS0wKwYDVQQD
|
||||
DCRZdWJpY28gRklETyBSb290IENBIFNlcmlhbCA0NTAyMDM1NTYwggEiMA0GCSqG
|
||||
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCdvl27w2gu1fPXeEFbIdqx0BalvVDVWrQP
|
||||
J7HqviuEtZHlxSLxSFtcXpTolvLvof8f4tMerQTkVGzcmYzm1EBT4IJuMmoEqfkE
|
||||
EhWpsADMFrjZkqlZY9EqxQzLoVEEonE5oGxSdVCxCcLIackpyR/CCXvj1Bt/hTgE
|
||||
9hTlF4pRqxMkx3plF7y8dDZlRHWs7vbnhmBCGeI0ZPEQ6nl2mCg2r74adF2u6K9r
|
||||
rLfhBC3QLE8EPrgqUsI+hkuq2tK4M2SMOp8uUVVkqUeu3h0kr3WVI0W02pkgrOgi
|
||||
FKLFNkSrbYhdjMBDj5izmqfc9xJRKoDX612qd8ZGVHpT5AYFX+1hAgMBAAGjRTBD
|
||||
MB0GA1UdDgQWBBTZyU5DiQ/a2UEgE7qBK0zhIsRNRjASBgNVHRMBAf8ECDAGAQH/
|
||||
AgEAMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAQEAXvnB4SLuUJfY
|
||||
MSVGAhssL/SmWli3FSccgxydvKlACcidIIWKQqa3q/QSUEQzC9DgEfMgr7iC1BkT
|
||||
ZbILboV6UZ5knNsvjEZWuMeogJ8tgZs1hVvKwZizwJ+mEcmsjhIrBYuoL1T6yrOJ
|
||||
vKFg1jv+Cy4ZwA9Bpk/V3UOir1VyK8dCtyHu6vfosotAdYx8FAuR243gRTMV6Jx8
|
||||
Jdig2JDIAQMlzVeDpSUHX/K2HXRHxHwfgjbgUjjBu/72r8OfehyhzHXI3K8CFFdf
|
||||
lO+8nEOJK3y8F1ivgS5uN/8SmcYw/STQYwhrxPuwz3nP8baMum4BB2nnYmpB60sX
|
||||
3bl5k8QUSw==
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# --- Yubico Attestation Root 1 (2024-12) ------------------------------------
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDPjCCAiagAwIBAgIUXzeiEDJEOTt14F5n0o6Zf/bBwiUwDQYJKoZIhvcNAQEN
|
||||
BQAwJDEiMCAGA1UEAwwZWXViaWNvIEF0dGVzdGF0aW9uIFJvb3QgMTAgFw0yNDEy
|
||||
MDEwMDAwMDBaGA85OTk5MTIzMTIzNTk1OVowJDEiMCAGA1UEAwwZWXViaWNvIEF0
|
||||
dGVzdGF0aW9uIFJvb3QgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
|
||||
AMZ6/TxM8rIT+EaoPvG81ontMOo/2mQ2RBwJHS0QZcxVaNXvl12LUhBZ5LmiBScI
|
||||
Zd1Rnx1od585h+/dhK7hEm7JAALkKKts1fO53KGNLZujz5h3wGncr4hyKF0G74b/
|
||||
U3K9hE5mGND6zqYchCRAHfrYMYRDF4YL0X4D5nGdxvppAy6nkEmtWmMnwO3i0TAu
|
||||
csrbE485HvGM4r0VpgVdJpvgQjiTJCTIq+D35hwtT8QDIv+nGvpcyi5wcIfCkzyC
|
||||
imJukhYy6KoqNMKQEdpNiSOvWyDMTMt1bwCvEzpw91u+msUt4rj0efnO9s0ZOwdw
|
||||
MRDnH4xgUl5ZLwrrPkfC1/0CAwEAAaNmMGQwHQYDVR0OBBYEFNLu71oijTptXCOX
|
||||
PfKF1SbxJXuSMB8GA1UdIwQYMBaAFNLu71oijTptXCOXPfKF1SbxJXuSMBIGA1Ud
|
||||
EwEB/wQIMAYBAf8CAQMwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBDQUAA4IB
|
||||
AQC3IW/sgB9pZ8apJNjxuGoX+FkILks0wMNrdXL/coUvsrhzsvl6mePMrbGJByJ1
|
||||
XnquB5sgcRENFxdQFma3mio8Upf1owM1ZreXrJ0mADG2BplqbJnxiyYa+R11reIF
|
||||
TWeIhMNcZKsDZrFAyPuFjCWSQvJmNWe9mFRYFgNhXJKkXIb5H1XgEDlwiedYRM7V
|
||||
olBNlld6pRFKlX8ust6OTMOeADl2xNF0m1LThSdeuXvDyC1g9+ILfz3S6OIYgc3i
|
||||
roRcFD354g7rKfu67qFAw9gC4yi0xBTPrY95rh4/HqaUYCA/L8ldRk6H7Xk35D+W
|
||||
Vpmq2Sh/xT5HiFuhf4wJb0bK
|
||||
-----END CERTIFICATE-----
|
||||
@@ -29,6 +29,7 @@ Gruppen:
|
||||
update Releases veroeffentlichen und finden
|
||||
setup Erstinstallation
|
||||
supervisor Befunde
|
||||
hardware Welche Authenticatoren zugelassen sind
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -45,6 +46,7 @@ import suite_kern # noqa: E402,F401
|
||||
import suite_bugtracker # noqa: E402,F401
|
||||
import suite_dienste # noqa: E402,F401
|
||||
import suite_tafel # noqa: E402,F401
|
||||
import suite_hardware # noqa: E402,F401
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Die Regel, welche Authenticatoren zugelassen sind.
|
||||
|
||||
WAS HIER GEPRUEFT WIRD - UND WARUM GERADE DAS
|
||||
|
||||
Der Anlass war ein Befund aus dem Betrieb: Ein Passwortverwalter liess sich
|
||||
als zweiter Faktor registrieren, obwohl in den Registrierungsvorgaben
|
||||
authenticatorAttachment = "cross-platform" stand. Die Angabe steuert nur, was
|
||||
der Browser anbietet; sie ist keine Pruefung. Was traegt, ist die Attestation.
|
||||
|
||||
Der entscheidende Fall laesst sich ohne Hardware nachstellen: Ein
|
||||
Authenticator, der "fmt": "none" schickt - also genau das, was ein
|
||||
Passwortverwalter oder ein in das Betriebssystem eingebauter Passkey liefert -
|
||||
muss unter der Regel abgewiesen werden und ohne sie durchkommen. Dazu braucht
|
||||
es keine Kryptografie: Bei einer Registrierung mit "none" prueft der Server
|
||||
keine Signatur, er liest den oeffentlichen Schluessel und legt ihn ab. Deshalb
|
||||
kommt diese Reihe mit der Standardbibliothek aus.
|
||||
|
||||
WAS HIER NICHT GEPRUEFT WIRD
|
||||
|
||||
Der Gegenfall - ein echter Yubikey, der eine gueltige Kette vorlegt - laesst
|
||||
sich von hier aus nicht erzeugen; dafuer braeuchte man Yubicos privaten
|
||||
Wurzelschluessel, und genau darauf beruht die ganze Aussage. Ebenso fehlt der
|
||||
Fall "packed mit selbst ausgestelltem Zertifikat", der belegt, dass ein x5c
|
||||
allein nicht genuegt: Er verlangt eine echte Unterschrift ueber eine
|
||||
Herausforderung, die erst zur Laufzeit feststeht, und damit eine
|
||||
Krypto-Bibliothek. Beides laeuft in einem eigenen Werkzeug ausserhalb dieser
|
||||
Suite.
|
||||
|
||||
Die Reihe raeumt am Ende auf und stellt die Regel auf "off" zurueck. Ohne das
|
||||
liefe die naechste Reihe gegen eine Anlage, die Hardware verlangt - und
|
||||
scheiterte an etwas, das sie gar nicht prueft.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import http.cookiejar
|
||||
import json
|
||||
import re
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from harness import test, erwarte, ueberspringen, Fehlschlag
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ein oeffentlicher P-256-Punkt, fest eingebaut
|
||||
# ---------------------------------------------------------------------------
|
||||
# Warum fest und nicht erzeugt: Ein Punkt muss auf der Kurve liegen, sonst
|
||||
# lehnt der Server schon beim Umwandeln nach PEM ab - und der Testfall
|
||||
# scheiterte dann aus dem falschen Grund. Einen gueltigen Punkt zu WUERFELN
|
||||
# verlangt dieselbe Bibliothek, die diese Suite nicht haben will. Also steht
|
||||
# hier einer, der nachweislich auf P-256 liegt.
|
||||
#
|
||||
# Der zugehoerige private Schluessel ist bewusst nirgends abgelegt. Er wird
|
||||
# nicht gebraucht: Bei einer Registrierung mit "none" unterschreibt niemand.
|
||||
# Ein Schluessel, den man nicht braucht, ist ein Schluessel, der nicht
|
||||
# verloren gehen kann.
|
||||
PUBKEY_X = bytes.fromhex('6ee18ffb6fb32a97ac63039c118a9d507fb4624761df549a57b0f97457f85ca8')
|
||||
PUBKEY_Y = bytes.fromhex('6291e3318ceadce692fd39d1822ffef9255f5eeb916b634694548c5d452b03cd')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CBOR-Schreiber, gerade gross genug fuer ein Attestationsobjekt
|
||||
# ---------------------------------------------------------------------------
|
||||
def _kopf(major, wert):
|
||||
if wert < 24:
|
||||
return bytes([(major << 5) | wert])
|
||||
if wert < 0x100:
|
||||
return bytes([(major << 5) | 24, wert])
|
||||
if wert < 0x10000:
|
||||
return bytes([(major << 5) | 25]) + wert.to_bytes(2, 'big')
|
||||
return bytes([(major << 5) | 26]) + wert.to_bytes(4, 'big')
|
||||
|
||||
|
||||
def _cbor(wert):
|
||||
if isinstance(wert, bytes):
|
||||
return _kopf(2, len(wert)) + wert
|
||||
if isinstance(wert, str):
|
||||
roh = wert.encode('utf-8')
|
||||
return _kopf(3, len(roh)) + roh
|
||||
if isinstance(wert, bool):
|
||||
return bytes([0xF5 if wert else 0xF4])
|
||||
if isinstance(wert, int):
|
||||
if wert >= 0:
|
||||
return _kopf(0, wert)
|
||||
return _kopf(1, -wert - 1)
|
||||
if isinstance(wert, list):
|
||||
return _kopf(4, len(wert)) + b''.join(_cbor(x) for x in wert)
|
||||
if isinstance(wert, dict):
|
||||
# Die Reihenfolge bleibt, wie sie eingetragen wurde. Der Server liest
|
||||
# die Abbildung als Ganzes; eine kanonische Sortierung braucht er
|
||||
# nicht, und sie nachzubilden waere Aufwand ohne Wirkung.
|
||||
out = _kopf(5, len(wert))
|
||||
for k, v in wert.items():
|
||||
out += _cbor(k) + _cbor(v)
|
||||
return out
|
||||
raise TypeError('CBOR: %r' % type(wert))
|
||||
|
||||
|
||||
def _b64u(roh):
|
||||
return base64.urlsafe_b64encode(roh).decode('ascii').rstrip('=')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ein Sitzungsclient - die Suite selbst arbeitet mit Token, hier braucht es
|
||||
# ein Cookie
|
||||
# ---------------------------------------------------------------------------
|
||||
class Sitzung:
|
||||
def __init__(self, cfg):
|
||||
self.basis = cfg['base_url'].rstrip('/')
|
||||
self.basic = cfg.get('basic_auth') or {}
|
||||
jar = http.cookiejar.CookieJar()
|
||||
self.opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPCookieProcessor(jar),
|
||||
urllib.request.HTTPSHandler(context=ssl.create_default_context()),
|
||||
)
|
||||
self.csrf = None
|
||||
|
||||
def _kopf(self, extra=None):
|
||||
k = {'User-Agent': 'dc-e2e/1.0', 'Accept': 'text/html,application/json'}
|
||||
if self.basic.get('user'):
|
||||
roh = ('%s:%s' % (self.basic['user'], self.basic['pass'])).encode()
|
||||
k['Authorization'] = 'Basic ' + base64.b64encode(roh).decode('ascii')
|
||||
k.update(extra or {})
|
||||
return k
|
||||
|
||||
def hole(self, pfad):
|
||||
req = urllib.request.Request(self.basis + pfad, headers=self._kopf())
|
||||
try:
|
||||
with self.opener.open(req, timeout=45) as r:
|
||||
text = r.read().decode('utf-8', 'replace')
|
||||
except urllib.error.HTTPError as e:
|
||||
text = e.read().decode('utf-8', 'replace')
|
||||
treffer = re.search(r'name="csrf_token"\s+value="([^"]+)"', text)
|
||||
if treffer:
|
||||
self.csrf = treffer.group(1)
|
||||
return text
|
||||
|
||||
def formular(self, pfad, felder):
|
||||
daten = dict(felder)
|
||||
if self.csrf:
|
||||
daten['csrf_token'] = self.csrf
|
||||
req = urllib.request.Request(
|
||||
self.basis + pfad,
|
||||
data=urllib.parse.urlencode(daten).encode(),
|
||||
headers=self._kopf({'Content-Type': 'application/x-www-form-urlencoded'}),
|
||||
method='POST')
|
||||
try:
|
||||
with self.opener.open(req, timeout=45) as r:
|
||||
return r.status, r.read().decode('utf-8', 'replace')
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode('utf-8', 'replace')
|
||||
|
||||
def json_post(self, pfad, rumpf):
|
||||
req = urllib.request.Request(
|
||||
self.basis + pfad,
|
||||
data=json.dumps(rumpf).encode(),
|
||||
headers=self._kopf({'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': self.csrf or ''}),
|
||||
method='POST')
|
||||
try:
|
||||
with self.opener.open(req, timeout=45) as r:
|
||||
return r.status, json.loads(r.read().decode('utf-8', 'replace'))
|
||||
except urllib.error.HTTPError as e:
|
||||
roh = e.read().decode('utf-8', 'replace')
|
||||
try:
|
||||
return e.code, json.loads(roh)
|
||||
except ValueError:
|
||||
return e.code, {'roh': roh[:400]}
|
||||
|
||||
def anmelden(self, benutzer, passwort):
|
||||
self.hole('/login.php')
|
||||
status, _ = self.formular('/login.php', {
|
||||
'username': benutzer,
|
||||
'password': passwort,
|
||||
})
|
||||
# Nach der Anmeldung steht das Token der Sitzung in index.php - das
|
||||
# aus dem Anmeldeformular gilt fuer die alte Sitzungskennung nicht
|
||||
# mehr.
|
||||
self.hole('/index.php')
|
||||
return status
|
||||
|
||||
|
||||
def _schluessel_loeschen(ctx):
|
||||
"""
|
||||
Raeumt die Hardwarekeys des Testkontos ab.
|
||||
|
||||
Notwendig, weil diese Reihe selbst einen registriert - und ein Konto mit
|
||||
zweitem Faktor laesst sich mit dem Passwort allein nicht mehr anmelden.
|
||||
Ohne dieses Aufraeumen liefe der zweite Lauf gegen den Zwischenzustand
|
||||
und meldete "Anmeldung erforderlich", was nach einem Fehler der Anwendung
|
||||
aussieht und keiner ist.
|
||||
"""
|
||||
benutzer = (ctx.cfg.get('admin') or {}).get('user') or 'admin'
|
||||
try:
|
||||
ctx.testctl('clear_credentials', username=benutzer)
|
||||
except Exception:
|
||||
# Aeltere Testinstanz ohne diese Aktion. Dann bleibt der Lauf auf das
|
||||
# Zuruecksetzen angewiesen - gemeldet wird es beim Anmelden.
|
||||
pass
|
||||
|
||||
|
||||
def _sitzung(ctx):
|
||||
cfg = ctx.cfg
|
||||
if not (cfg.get('admin') or {}).get('user'):
|
||||
ueberspringen('In der Konfiguration steht kein Administratorkonto.')
|
||||
|
||||
_schluessel_loeschen(ctx)
|
||||
|
||||
s = Sitzung(cfg)
|
||||
s.anmelden(cfg['admin']['user'], cfg['admin']['pass'])
|
||||
|
||||
if s.csrf is None:
|
||||
ueberspringen('Anmeldung am WebUI nicht moeglich - kein CSRF-Token erhalten.')
|
||||
|
||||
return s
|
||||
|
||||
|
||||
def _regel_setzen(s, stufe, aaguids=''):
|
||||
return s.formular('/index.php', {
|
||||
'action': 'save_webauthn_policy',
|
||||
'_return': 'sub-system-users',
|
||||
'policy_level': stufe,
|
||||
'policy_aaguids': aaguids,
|
||||
})
|
||||
|
||||
|
||||
def _registrieren(s, bezeichnung):
|
||||
"""Eine Registrierung mit fmt "none" - wie ein Passwortverwalter sie liefert."""
|
||||
status, vorgabe = s.json_post('/api/auth/v1/?action=register_options', {})
|
||||
if status != 200:
|
||||
raise Fehlschlag('register_options antwortete %d: %s' % (status, vorgabe))
|
||||
|
||||
pk = vorgabe['publicKey']
|
||||
herausforderung = pk['challenge']
|
||||
rp_id = pk['rp']['id']
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
kennung = os.urandom(32)
|
||||
|
||||
cose = {1: 2, 3: -7, -1: 1, -2: PUBKEY_X, -3: PUBKEY_Y}
|
||||
|
||||
auth = hashlib.sha256(rp_id.encode()).digest()
|
||||
auth += bytes([0x41]) # UP | AT
|
||||
auth += (0).to_bytes(4, 'big') # Zaehler
|
||||
auth += b'\x00' * 16 # aaguid: bei "none" immer null
|
||||
auth += len(kennung).to_bytes(2, 'big')
|
||||
auth += kennung
|
||||
auth += _cbor(cose)
|
||||
|
||||
client = json.dumps({
|
||||
'type': 'webauthn.create',
|
||||
'challenge': herausforderung,
|
||||
'origin': s.basis,
|
||||
'crossOrigin': False,
|
||||
}).encode()
|
||||
|
||||
attestation = _cbor({'fmt': 'none', 'attStmt': {}, 'authData': auth})
|
||||
|
||||
return s.json_post('/api/auth/v1/?action=register_verify', {
|
||||
'clientDataJSON': _b64u(client),
|
||||
'attestationObject': _b64u(attestation),
|
||||
'label': bezeichnung,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Die Faelle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@test('hardware', 'Ohne Regel wird ein Software-Authenticator aufgenommen')
|
||||
def t_ohne_regel(ctx):
|
||||
"""
|
||||
Der Ausgangszustand. Er ist zugleich der Befund, um den es geht: Ein
|
||||
Authenticator ohne jeden Herkunftsnachweis kommt durch.
|
||||
"""
|
||||
s = _sitzung(ctx)
|
||||
ctx['hw_sitzung'] = s
|
||||
|
||||
status, _ = _regel_setzen(s, 'off')
|
||||
erwarte(status in (200, 302, 303), 'Regel liess sich nicht auf "off" stellen (%s).' % status)
|
||||
|
||||
status, antwort = _registrieren(s, 'Testschluessel ohne Nachweis')
|
||||
|
||||
erwarte(status == 200,
|
||||
'Ohne Regel muss die Aufnahme gelingen, war %d: %s' % (status, antwort))
|
||||
erwarte(antwort.get('attested') is False,
|
||||
'Ein Schluessel ohne Attestation darf nicht als Hardware gelten: %s' % antwort)
|
||||
|
||||
|
||||
@test('hardware', 'Mit Regel wird derselbe Authenticator abgewiesen')
|
||||
def t_mit_regel(ctx):
|
||||
"""
|
||||
Derselbe Vorgang, dieselben Bytes - nur die Regel steht anders. Genau
|
||||
diese Gegenueberstellung ist die Aussage der Funktion.
|
||||
"""
|
||||
s = ctx.get('hw_sitzung') or _sitzung(ctx)
|
||||
|
||||
status, _ = _regel_setzen(s, 'register')
|
||||
erwarte(status in (200, 302, 303), 'Regel liess sich nicht auf "register" stellen (%s).' % status)
|
||||
|
||||
status, antwort = _registrieren(s, 'Testschluessel unter Regel')
|
||||
|
||||
erwarte(status == 403,
|
||||
'Unter der Regel muss die Aufnahme scheitern, war %d: %s' % (status, antwort))
|
||||
|
||||
# Die Fehlerhuelle der API traegt den Code eine Ebene tiefer. Hier beide
|
||||
# Formen zu lesen ist keine Nachlaessigkeit: Der Testfall soll den
|
||||
# Zustand der Anwendung melden und nicht die Form ihrer Antwort.
|
||||
huelle = antwort.get('error')
|
||||
code = huelle.get('code') if isinstance(huelle, dict) else huelle
|
||||
erwarte(code == 'policy_rejected',
|
||||
'Erwartet wurde policy_rejected, kam: %s' % antwort)
|
||||
|
||||
# Die Meldung muss den Grund nennen. Ein blosses "abgelehnt" liesse
|
||||
# offen, ob der Schluessel kaputt ist oder die Regel greift - und das ist
|
||||
# der Unterschied zwischen "anderen Schluessel holen" und "Einstellung
|
||||
# pruefen".
|
||||
text = (huelle.get('message') if isinstance(huelle, dict) else '') or ''
|
||||
erwarte('attestation' in text.lower() or 'hardware' in text.lower(),
|
||||
'Die Ablehnung nennt den Grund nicht: %s' % text)
|
||||
|
||||
|
||||
@test('hardware', 'Die strenge Stufe sperrt sich nicht selbst aus')
|
||||
def t_strict_bewacht(ctx):
|
||||
"""
|
||||
Nach den beiden Faellen oben hat das Konto keinen attestierten Schluessel.
|
||||
"strict" wuerde die eigene Anmeldung sperren - und muss deshalb abgelehnt
|
||||
werden, statt sie zu sperren.
|
||||
"""
|
||||
s = ctx.get('hw_sitzung') or _sitzung(ctx)
|
||||
|
||||
status, _ = _regel_setzen(s, 'strict')
|
||||
erwarte(status in (200, 302, 303), 'Unerwarteter Status beim Umschalten (%s).' % status)
|
||||
|
||||
# Die Ablehnung zeigt sich daran, dass die Stufe NICHT uebernommen wurde.
|
||||
# Das ist die Pruefung, auf die es ankommt: Eine Meldung ohne Wirkung
|
||||
# waere schlimmer als gar keine.
|
||||
seite = s.hole('/index.php')
|
||||
treffer = re.search(
|
||||
r'name="policy_level"\s+value="strict"[^>]*\schecked', seite)
|
||||
erwarte(treffer is None,
|
||||
'Die Stufe "strict" wurde uebernommen, obwohl kein attestierter '
|
||||
'Schluessel vorliegt - das sperrt den eigenen Zugang aus.')
|
||||
|
||||
|
||||
@test('hardware', 'Eine unbrauchbare Modellliste wird abgewiesen')
|
||||
def t_allowlist_pruefung(ctx):
|
||||
"""
|
||||
Eine Geraetekennung, die keine ist, darf nicht stillschweigend als leere
|
||||
Liste durchgehen - das hiesse "alles erlaubt", wo jemand "nur dieses
|
||||
Modell" gemeint hat.
|
||||
"""
|
||||
s = ctx.get('hw_sitzung') or _sitzung(ctx)
|
||||
|
||||
status, _ = _regel_setzen(s, 'register', aaguids='kein-gueltiges-geraet')
|
||||
erwarte(status in (200, 302, 303), 'Unerwarteter Status (%s).' % status)
|
||||
|
||||
seite = s.hole('/index.php')
|
||||
erwarte('kein-gueltiges-geraet' not in seite,
|
||||
'Die unbrauchbare Angabe wurde gespeichert.')
|
||||
|
||||
|
||||
@test('hardware', 'Aufraeumen: Regel steht wieder auf "off"')
|
||||
def t_aufraeumen(ctx):
|
||||
"""
|
||||
Kein Test im eigentlichen Sinn, sondern die Rueckgabe eines geliehenen
|
||||
Zustands. Bliebe die Regel stehen, liefe die naechste Reihe gegen eine
|
||||
Anlage, die Hardware verlangt.
|
||||
"""
|
||||
s = ctx.get('hw_sitzung') or _sitzung(ctx)
|
||||
|
||||
status, _ = _regel_setzen(s, 'off')
|
||||
erwarte(status in (200, 302, 303), 'Regel liess sich nicht zuruecksetzen (%s).' % status)
|
||||
|
||||
seite = s.hole('/index.php')
|
||||
treffer = re.search(r'name="policy_level"\s+value="off"[^>]*\schecked', seite)
|
||||
erwarte(treffer is not None, 'Die Regel steht nicht wieder auf "off".')
|
||||
|
||||
# Der in dieser Reihe angelegte Schluessel muss ebenfalls weg. Bliebe er
|
||||
# stehen, verlangte das Testkonto beim naechsten Lauf einen zweiten
|
||||
# Faktor, den niemand vorzeigen kann.
|
||||
_schluessel_loeschen(ctx)
|
||||
@@ -176,6 +176,17 @@ switch ($action) {
|
||||
// Vor Migration 019 gibt es die Tabelle nicht.
|
||||
}
|
||||
|
||||
// Die Hardwarekey-Regel gehoert ebenfalls zurueckgesetzt. Sie steht
|
||||
// in dc_settings und ueberlebt das Loeschen der Konten sonst - ein
|
||||
// Lauf, der auf "strict" endet, liesse den naechsten an einer Regel
|
||||
// scheitern, die er gar nicht prueft. Ausgetragen statt auf "off"
|
||||
// gesetzt: Fehlt der Eintrag, liest HardwarePolicy die Vorgabe, und
|
||||
// das ist genau derselbe Zustand wie bei einer frischen Anlage.
|
||||
$db->prepare("
|
||||
DELETE FROM dc_settings
|
||||
WHERE skey IN ('webauthn_hardware_policy', 'webauthn_aaguid_allowlist')
|
||||
")->execute();
|
||||
|
||||
$db->prepare('DELETE FROM dc_users WHERE username = :u')->execute([':u' => $adminUser]);
|
||||
$db->prepare('
|
||||
INSERT INTO dc_users (username, password_hash, created_at)
|
||||
@@ -381,10 +392,43 @@ switch ($action) {
|
||||
Http::ok(['unlocked' => $username, 'affected' => $stmt->rowCount()]);
|
||||
break;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Hardwarekeys eines Kontos entfernen, ohne die Datenbank zu leeren.
|
||||
//
|
||||
// Warum eigens und nicht ueber "reset": Ein registrierter zweiter Faktor
|
||||
// macht das Konto fuer einen automatisierten Lauf unbenutzbar - Passwort
|
||||
// allein fuehrt nur noch in den Zwischenzustand. Ein Testlauf, der einen
|
||||
// Schluessel aufnimmt, sperrt damit den naechsten aus. Der ganze
|
||||
// Datenbestand muss dafuer aber nicht weichen; "reset" wuerde Roadmap,
|
||||
// Meldungen und Lizenzen gleich mit loeschen, und das ist ein hoher Preis
|
||||
// fuer das Aufraeumen einer einzigen Tabelle.
|
||||
//
|
||||
// Nebenbei ist es der Weg zurueck, wenn man sich auf der Testinstanz mit
|
||||
// einer schaerferen Regel selbst ausgesperrt hat. Auf der Produktion gibt
|
||||
// es diese Datei nicht - dort ist der Notfalltoken der Weg.
|
||||
case 'clear_credentials': {
|
||||
$username = (string)(Http::str('username') ?? 'admin');
|
||||
|
||||
try {
|
||||
$stmt = $db->prepare('
|
||||
DELETE FROM dc_user_credentials
|
||||
WHERE user_id IN (SELECT id FROM dc_users WHERE username = :u)
|
||||
');
|
||||
$stmt->execute([':u' => $username]);
|
||||
$entfernt = $stmt->rowCount();
|
||||
} catch (Throwable $e) {
|
||||
Http::fail(500, 'no_table', 'dc_user_credentials fehlt - Migration 019 steht aus.');
|
||||
}
|
||||
|
||||
Http::ok(['username' => $username, 'removed' => $entfernt]);
|
||||
break;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
default:
|
||||
Http::fail(400, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [
|
||||
'actions' => ['status', 'wipe', 'migrate', 'reset', 'workbench_token',
|
||||
'update_master_token', 'genkey', 'log', 'clearlog', 'unlock_user'],
|
||||
'update_master_token', 'genkey', 'log', 'clearlog', 'unlock_user',
|
||||
'clear_credentials'],
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user