Files
timemaster-kiosk/lib/screens/qr_enrollment_scan_screen.dart
T
patrickandClaude Sonnet 5 3184cd352f feat: Ed25519-Request-Signing statt Bearer-Token (Security-Audit-Fix)
Security-Audit 2026-09-10 ergab: der Bearer-Token-Modus war ohne TLS im
Klartext mitschneidbar und unbegrenzt gültig/firmenweit einlösbar – ein
LAN-Sniffer hätte damit vollen Lese-/Schreibzugriff auf Mitarbeiterliste
und Zeitdaten gehabt (Finding B-1, CRITICAL). Ersetzt durch dasselbe
Ed25519-Request-Signing-Schema wie bei den Browser-PC-Terminals.

- Neuer KioskCryptoService: erzeugt/lädt Ed25519-Schlüsselpaar (Seed in
  Secure Storage, privater Schlüssel verlässt nie das Gerät), exportiert
  Public Key im OpenSSH-Format, signiert Requests (Method+Path+Timestamp+
  Nonce+Body-SHA256), passend zu backend/app/core/kiosk_security.py.
- ApiClient: Interceptor signiert jetzt jeden Request statt Bearer-Header
  zu setzen. Body wird einmalig zu exakten Bytes serialisiert (Hash und
  gesendeter Body müssen bitidentisch sein).
- EnrollmentScreen: zeigt den öffentlichen Schlüssel zum Einfügen im Portal
  ("PC-Terminal (Ed25519)"), Eingabefelder jetzt Server-URL + Geräte-ID
  statt Bearer-Token. QR-Scan-Format geändert auf {url, device_id}.
- Alte Bearer-Enrollments sind inkompatibel (anderer Secure-Storage-Key) –
  bewusst, erzwingt Re-Enrollment über den sicheren Weg. v0.2.0+13.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
2026-09-10 15:34:40 +02:00

130 lines
4.9 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
/// Scannt den QR-Code aus dem Portal (KioskDevicesPage → Bearer-Gerät
/// anlegen). Erwartetes Format: {"url": "<api base url>", "token": "<...>"}.
class QrEnrollmentScanScreen extends StatefulWidget {
const QrEnrollmentScanScreen({super.key});
@override
State<QrEnrollmentScanScreen> createState() => _QrEnrollmentScanScreenState();
}
class _QrEnrollmentScanScreenState extends State<QrEnrollmentScanScreen> {
// Feste, niedrige Auflösung + nur QR-Format: manche günstigen Android-
// Tablets (z.B. T10 Pro) liefern bei CameraX-Auto-Auflösung nur
// "MobileScannerErrorCode.genericError" ohne Details – die HAL kommt mit
// der von CameraX gewählten Default-Auflösung nicht klar. 1280x720 ist
// breit unterstützt und für QR-Codes mehr als ausreichend.
MobileScannerController _buildController() => MobileScannerController(
formats: const [BarcodeFormat.qrCode],
cameraResolution: const Size(1280, 720),
);
late MobileScannerController _controller = _buildController();
bool _handled = false;
String? _error;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _retryWithLowerResolution() async {
await _controller.dispose();
setState(() {
_controller = MobileScannerController(
formats: const [BarcodeFormat.qrCode],
cameraResolution: const Size(640, 480),
);
});
}
void _onDetect(BarcodeCapture capture) {
if (_handled) return;
final raw = capture.barcodes.firstOrNull?.rawValue;
if (raw == null) return;
try {
final data = jsonDecode(raw) as Map<String, dynamic>;
final url = data['url'] as String?;
final deviceId = (data['device_id'] ?? data['deviceId']) as String?;
if (url == null || url.isEmpty || deviceId == null || deviceId.isEmpty) {
setState(() => _error = 'QR-Code enthält nicht die erwarteten Felder (url, device_id).');
return;
}
_handled = true;
Navigator.of(context).pop({'url': url, 'deviceId': deviceId});
} catch (_) {
setState(() => _error = 'QR-Code konnte nicht gelesen werden (kein gültiges Format).');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('QR-Code scannen')),
body: Stack(
children: [
MobileScanner(
controller: _controller,
onDetect: _onDetect,
errorBuilder: (context, error, child) {
// Default-errorBuilder zeigt nur "An unexpected error occurred"
// ohne Details – hier den echten MobileScannerErrorCode +
// Details anzeigen, damit sich das Problem auf dem Gerät
// überhaupt diagnostizieren lässt.
return Container(
color: Colors.black,
padding: const EdgeInsets.all(24),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 48),
const SizedBox(height: 16),
Text('Kamera-Fehler: ${error.errorCode}',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text('${error.errorDetails?.message ?? "keine Details"}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
textAlign: TextAlign.center),
const SizedBox(height: 24),
OutlinedButton(
onPressed: _retryWithLowerResolution,
child: const Text('Mit niedrigerer Auflösung erneut versuchen', style: TextStyle(color: Colors.white)),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Zurück – manuell eintragen', style: TextStyle(color: Colors.white)),
),
],
),
),
);
},
),
if (_error != null)
Positioned(
left: 16,
right: 16,
bottom: 32,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.red[900], borderRadius: BorderRadius.circular(8)),
child: Text(_error!, style: const TextStyle(color: Colors.white), textAlign: TextAlign.center),
),
),
],
),
);
}
}
extension _FirstOrNull<T> on List<T> {
T? get firstOrNull => isEmpty ? null : first;
}