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
130 lines
4.9 KiB
Dart
130 lines
4.9 KiB
Dart
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;
|
||
}
|