import { getPool } from './db'; import type { RowDataPacket, ResultSetHeader } from 'mysql2/promise'; const TABLE = 'passkeys'; /** Ein gespeicherter Passkey (WebAuthn-Credential), einem Benutzer zugeordnet. */ export interface PasskeyRecord { credentialId: string; // base64url username: string; publicKey: Uint8Array; counter: number; transports: string[]; label: string; createdAt: Date; lastUsedAt: Date | null; } interface PasskeyRow extends RowDataPacket { credential_id: string; username: string; public_key: Buffer; counter: number; transports: string; label: string; created_at: Date; last_used_at: Date | null; } let schemaReady: Promise | null = null; /** * Legt die Passkey-Tabelle beim ersten Zugriff an (einmal pro Prozess). * Das Schema steht zusätzlich in create_table.sql. */ function ensureSchema(): Promise { if (!schemaReady) { schemaReady = getPool() .query( `CREATE TABLE IF NOT EXISTS ${TABLE} ( credential_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY, username VARCHAR(64) NOT NULL, public_key VARBINARY(1024) NOT NULL, counter INT UNSIGNED NOT NULL DEFAULT 0, transports VARCHAR(255) NOT NULL DEFAULT '[]', label VARCHAR(80) NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_used_at DATETIME NULL, INDEX idx_passkeys_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci` ) .then(() => undefined) .catch((error) => { schemaReady = null; // nächster Aufruf darf es erneut versuchen throw error; }); } return schemaReady; } function fromRow(row: PasskeyRow): PasskeyRecord { let transports: string[] = []; try { const parsed = JSON.parse(row.transports); if (Array.isArray(parsed)) transports = parsed; } catch { /* fehlerhafte transports ignorieren */ } return { credentialId: row.credential_id, username: row.username, // Uint8Array.from kopiert in einen frischen ArrayBuffer (passt zum von // @simplewebauthn erwarteten Uint8Array). publicKey: Uint8Array.from(row.public_key), counter: row.counter, transports, label: row.label, createdAt: row.created_at, lastUsedAt: row.last_used_at, }; } /** Alle Passkeys eines Benutzers, neueste zuerst. */ export async function listPasskeys(username: string): Promise { await ensureSchema(); const [rows] = await getPool().execute( `SELECT * FROM ${TABLE} WHERE username = ? ORDER BY created_at DESC`, [username] ); return rows.map(fromRow); } /** Passkey anhand der Credential-ID — benutzerübergreifend, für die Anmeldung. */ export async function getPasskey(credentialId: string): Promise { await ensureSchema(); const [rows] = await getPool().execute( `SELECT * FROM ${TABLE} WHERE credential_id = ?`, [credentialId] ); return rows.length > 0 ? fromRow(rows[0]) : undefined; } export async function insertPasskey(record: { credentialId: string; username: string; publicKey: Uint8Array; counter: number; transports: string[]; label: string; }): Promise { await ensureSchema(); await getPool().execute( `INSERT INTO ${TABLE} (credential_id, username, public_key, counter, transports, label) VALUES (?, ?, ?, ?, ?, ?)`, [ record.credentialId, record.username, Buffer.from(record.publicKey), record.counter, JSON.stringify(record.transports ?? []), record.label, ] ); } /** Aktualisiert Counter und last_used_at nach erfolgreicher Anmeldung (Replay-Schutz). */ export async function updatePasskeyUsage(credentialId: string, counter: number): Promise { await ensureSchema(); await getPool().execute( `UPDATE ${TABLE} SET counter = ?, last_used_at = NOW() WHERE credential_id = ?`, [counter, credentialId] ); } /** Löscht einen Passkey — nur wenn er dem angegebenen Benutzer gehört. */ export async function deletePasskey(credentialId: string, username: string): Promise { await ensureSchema(); const [result] = await getPool().execute( `DELETE FROM ${TABLE} WHERE credential_id = ? AND username = ?`, [credentialId, username] ); return result.affectedRows > 0; }