Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Controllers\VaultApiController;
use OpenNIT\Core\Database;
use OpenNIT\Vault\VaultCrypto;
use OpenNIT\Vault\VaultPasskeys;
/**
* Hält fest, was der Server als Passkey-Authenticator zusichert: gültige
* WebAuthn-Strukturen (COSE-Schlüssel, Authenticator-Daten, Attestierung),
* prüfbare Signaturen, Schlüsselmaterial nur verschlüsselt in der Ablage –
* und die Adress- und Ordnerregeln der Erweiterungs-Schnittstelle.
*/
return static function (): void {
/** Datenbank-Doppel, das Schreibvorgänge festhält und Leseabfragen beantwortet. */
$dbDoppel = static function (): Database {
return new class extends Database {
/** @var array<int,array{table:string,data:array}> */
public array $inserts = [];
public array $updates = [];
public function __construct()
{
parent::__construct([]);
}
public function insert(string $table, array $data): int
{
$this->inserts[] = ['table' => $table, 'data' => $data];
return count($this->inserts);
}
public function update(string $table, array $data, string $where = '', array $params = []): int
{
$this->updates[] = ['table' => $table, 'data' => $data, 'where' => $where, 'params' => $params];
return 1;
}
};
};
$key = random_bytes(32);
$rpId = 'Example.ORG';
// ----------------------------------------------------------------
// Kodierung
// ----------------------------------------------------------------
test('CBOR-Grundtypen entsprechen den Referenzwerten der RFC 8949', function () {
assertSame("\x01", VaultPasskeys::cborInt(1));
assertSame("\x18\x64", VaultPasskeys::cborInt(100));
assertSame("\x19\x03\xe8", VaultPasskeys::cborInt(1000));
assertSame("\x20", VaultPasskeys::cborInt(-1));
assertSame("\x26", VaultPasskeys::cborInt(-7));
assertSame("\x63fmt", VaultPasskeys::cborText('fmt'));
assertSame("\x44\x01\x02\x03\x04", VaultPasskeys::cborBytes("\x01\x02\x03\x04"));
assertSame("\xa0", VaultPasskeys::cborMap([]));
assertSame("\x58\x20" . str_repeat('x', 32), VaultPasskeys::cborBytes(str_repeat('x', 32)));
});
test('Der COSE-Schlüssel hat die feste EC2/P-256/ES256-Struktur und lässt sich zurücklesen', function () {
$x = random_bytes(32);
$y = random_bytes(32);
$cose = VaultPasskeys::coseEcKey($x, $y);
assertSame(77, strlen($cose));
assertSame("\xa5\x01\x02\x03\x26\x20\x01\x21\x58\x20", substr($cose, 0, 10));
assertSame([$x, $y], VaultPasskeys::coseToPoint($cose));
});
test('Die Authenticator-Daten beginnen mit dem rpIdHash der kleingeschriebenen Domain, Flags und Zähler 0', function () use ($rpId) {
$hdr = VaultPasskeys::authDataHeader($rpId, 0x1d);
assertSame(37, strlen($hdr));
assertSame(hash('sha256', 'example.org', true), substr($hdr, 0, 32));
assertSame("\x1d", $hdr[32]);
assertSame("\0\0\0\0", substr($hdr, 33, 4));
});
test('Base64URL ist ohne Padding und in beide Richtungen verlustfrei', function () {
$raw = random_bytes(45);
$enc = VaultPasskeys::base64url($raw);
assertTrue(preg_match('/^[A-Za-z0-9_-]+$/', $enc) === 1);
assertSame($raw, VaultPasskeys::base64urlDecode($enc));
assertSame($raw, VaultPasskeys::base64urlDecode(base64_encode($raw)));
assertSame(null, VaultPasskeys::base64urlDecode('nicht*base64'));
});
// ----------------------------------------------------------------
// Anlegen
// ----------------------------------------------------------------
test('Anlegen liefert eine Registrierungsantwort ohne Attestierung und legt nur Chiffrate ab', function () use ($dbDoppel, $key, $rpId) {
$db = $dbDoppel();
$pk = new VaultPasskeys($db);
$res = $pk->create(42, $key, [
'rp_id' => $rpId, 'rp_name' => 'Example', 'user_handle' => 'uh-123',
'user_name' => 'max@example.org', 'user_display' => 'Max',
], 7);
// Authenticator-Daten: Kopf + AAGUID (16 Nullen) + Länge + Credential-ID + COSE
$auth = $res['auth_data'];
assertSame("\x5d", $auth[32]); // UP|UV|BE|BS|AT
assertSame(str_repeat("\0", 16), substr($auth, 37, 16));
$credLen = unpack('n', substr($auth, 53, 2))[1];
assertSame(32, $credLen);
assertSame($res['credential_id'], VaultPasskeys::base64url(substr($auth, 55, 32)));
assertSame(77, strlen(substr($auth, 87)));
// Attestierungsobjekt: {"fmt":"none","attStmt":{},"authData":<auth>}
$erwartet = "\xa3" . "\x63fmt" . "\x64none" . "\x67attStmt" . "\xa0" . "\x68authData" . VaultPasskeys::cborBytes($auth);
assertSame($erwartet, $res['attestation_object']);
// Ablage
assertSame(1, count($db->inserts));
$row = $db->inserts[0]['data'];
assertSame('vault_passkeys', $db->inserts[0]['table']);
assertSame('example.org', $row['rp_id']);
assertSame(42, $row['entry_id']);
assertSame(-7, $row['alg']);
assertSame(substr($auth, 87), $row['public_key']);
foreach (['privkey', 'user_handle', 'user_name', 'user_display'] as $f) {
assertTrue(isset($row[$f . '_enc'], $row[$f . '_nonce'], $row[$f . '_tag']), $f . ' verschlüsselt');
assertFalse(str_contains((string)$row[$f . '_enc'], 'PRIVATE KEY'));
}
assertSame('uh-123', VaultCrypto::decrypt($row['user_handle_enc'], $row['user_handle_nonce'], $row['user_handle_tag'], $key));
assertTrue(str_contains(VaultCrypto::decrypt($row['privkey_enc'], $row['privkey_nonce'], $row['privkey_tag'], $key), 'PRIVATE KEY'));
});
// ----------------------------------------------------------------
// Anmelden
// ----------------------------------------------------------------
test('Die Signatur einer Anmeldung ist mit dem abgelegten öffentlichen Schlüssel prüfbar', function () use ($dbDoppel, $key, $rpId) {
$db = $dbDoppel();
$pk = new VaultPasskeys($db);
$res = $pk->create(42, $key, ['rp_id' => $rpId, 'user_handle' => 'uh-1'], 7);
$row = $db->inserts[0]['data'] + ['id' => 5];
$clientDataHash = hash('sha256', '{"type":"webauthn.get"}', true);
$out = $pk->assert($row, $key, $clientDataHash);
assertSame("\x1d", $out['auth_data'][32]); // UP|UV|BE|BS ohne AT
assertSame(37, strlen($out['auth_data']));
assertSame('uh-1', $out['user_handle']);
assertSame($res['credential_id'], $out['credential_id']);
[$x, $y] = VaultPasskeys::coseToPoint($row['public_key']);
$pub = openssl_pkey_get_public(VaultPasskeys::publicKeyPem($x, $y));
assertTrue($pub !== false, 'öffentlicher Schlüssel lesbar');
assertSame(1, openssl_verify($out['auth_data'] . $clientDataHash, $out['signature'], $pub, OPENSSL_ALGO_SHA256));
assertSame(0, openssl_verify($out['auth_data'] . random_bytes(32), $out['signature'], $pub, OPENSSL_ALGO_SHA256));
// Verwendung wird vermerkt
assertSame('vault_passkeys', $db->updates[0]['table']);
assertTrue(isset($db->updates[0]['data']['last_used_at']));
});
test('Ein falscher Kontext-Schlüssel oder ein falsch langer Hash führt zu einer Ausnahme', function () use ($dbDoppel, $key, $rpId) {
$db = $dbDoppel();
$pk = new VaultPasskeys($db);
$pk->create(42, $key, ['rp_id' => $rpId, 'user_handle' => 'uh-1'], 7);
$row = $db->inserts[0]['data'] + ['id' => 5];
$fehler = 0;
try { $pk->assert($row, random_bytes(32), hash('sha256', 'x', true)); } catch (\RuntimeException) { $fehler++; }
try { $pk->assert($row, $key, 'zu kurz'); } catch (\RuntimeException) { $fehler++; }
assertSame(2, $fehler);
});
// ----------------------------------------------------------------
// Umschlüsselung beim Kontextwechsel
// ----------------------------------------------------------------
test('Der Kontextwechsel eines Eintrags schlüsselt auch seine Passkeys um', function () {
$quelle = (string)file_get_contents(dirname(__DIR__) . '/src/Vault/VaultManager.php');
foreach (['privkey', 'user_handle', 'user_name', 'user_display'] as $f) {
assertTrue(str_contains($quelle, "['vault_passkeys', '{$f}']"), 'Re-Key für ' . $f);
}
});
// ----------------------------------------------------------------
// Erweiterungs-Schnittstelle: Adressen und Ordner
// ----------------------------------------------------------------
$api = (new ReflectionClass(VaultApiController::class))->newInstanceWithoutConstructor();
test('Mehrere Adressen werden zeilenweise bereinigt, Leerzeilen und Dubletten fallen weg', function () use ($api) {
assertSame("https://a.example\nhttps://b.example", callPrivate($api, 'normalizeUrls', [" https://a.example \r\n\n https://b.example\nhttps://a.example\n"]));
assertSame('', callPrivate($api, 'normalizeUrls', ["\n \n"]));
});
test('Das Favicon folgt der ersten Adresse, auch ohne Schema', function () use ($api) {
assertSame('a.example', callPrivate($api, 'faviconDomainOf', ["https://a.example/login\nhttps://b.example"]));
assertSame('b.example', callPrivate($api, 'faviconDomainOf', ["b.example/pfad"]));
assertSame('', callPrivate($api, 'faviconDomainOf', ['']));
});
test('Der Server meldet der Erweiterung den Stand seiner Schnittstelle', function () {
assertTrue(VaultApiController::API_VERSION >= 2);
$quelle = (string)file_get_contents(dirname(__DIR__) . '/src/Controllers/VaultApiController.php');
assertTrue(str_contains($quelle, "'api_version' => self::API_VERSION"));
});
};