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
116 lines
4.3 KiB
Dart
116 lines
4.3 KiB
Dart
import 'dart:convert';
|
||
import 'dart:typed_data';
|
||
import 'package:dio/dio.dart';
|
||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||
import 'kiosk_crypto_service.dart';
|
||
|
||
/// Kiosk-Geräte-Auth: Ed25519-Request-Signing, identisch zum Schema der
|
||
/// Browser-PC-Terminals (siehe backend/app/core/kiosk_security.py). Nach
|
||
/// dem Security-Audit vom 2026-09-10 wurde der reine Bearer-Token-Modus
|
||
/// verworfen – ohne TLS war ein mitgeschnittenes Token unbegrenzt gültig
|
||
/// und firmenweit einlösbar. Signierte Requests sind auch ohne TLS/VPN
|
||
/// wertlos für einen reinen Netzwerk-Mitleser (Timestamp+Nonce+Body-Hash
|
||
/// signiert, private Schlüssel verlässt nie dieses Gerät).
|
||
class ApiClient {
|
||
ApiClient._internal();
|
||
static final ApiClient instance = ApiClient._internal();
|
||
|
||
static const _storage = FlutterSecureStorage();
|
||
static const _keyBaseUrl = 'kiosk_base_url';
|
||
static const _keyDeviceId = 'kiosk_device_id';
|
||
|
||
final crypto = KioskCryptoService();
|
||
|
||
late final Dio dio;
|
||
String _baseUrl = 'https://timemaster.example.com/api/v1';
|
||
String? _deviceId;
|
||
|
||
Future<void> init() async {
|
||
// Storage-Reads bewusst einzeln try/catch – wenn z.B. der Android-
|
||
// Keystore beim Kaltstart noch nicht bereit ist, soll `dio` trotzdem
|
||
// konstruiert werden (sonst LateInitializationError beim ersten Zugriff).
|
||
try {
|
||
final savedUrl = await _storage.read(key: _keyBaseUrl);
|
||
if (savedUrl != null && savedUrl.isNotEmpty) _baseUrl = savedUrl;
|
||
} catch (_) {}
|
||
try {
|
||
_deviceId = await _storage.read(key: _keyDeviceId);
|
||
} catch (_) {}
|
||
|
||
dio = Dio(BaseOptions(
|
||
baseUrl: _baseUrl,
|
||
connectTimeout: const Duration(seconds: 10),
|
||
receiveTimeout: const Duration(seconds: 15),
|
||
));
|
||
|
||
dio.interceptors.add(InterceptorsWrapper(
|
||
onRequest: (options, handler) async {
|
||
if (_deviceId == null) {
|
||
handler.next(options);
|
||
return;
|
||
}
|
||
try {
|
||
final bodyBytes = _serializeBody(options);
|
||
final fullPath = _fullRequestPath(options.path);
|
||
final headers = await crypto.signRequest(
|
||
deviceId: _deviceId!,
|
||
method: options.method,
|
||
path: fullPath,
|
||
bodyBytes: bodyBytes,
|
||
);
|
||
options.headers.addAll(headers);
|
||
if (bodyBytes.isNotEmpty) {
|
||
options.data = bodyBytes;
|
||
options.headers['Content-Type'] = 'application/json';
|
||
}
|
||
} catch (_) {
|
||
// Signieren fehlgeschlagen (z.B. Keystore-Problem) – Request geht
|
||
// unsigniert raus und scheitert serverseitig mit klarer 401-
|
||
// Fehlermeldung statt dass die App hier hängen bleibt.
|
||
}
|
||
handler.next(options);
|
||
},
|
||
));
|
||
}
|
||
|
||
/// Serialisiert options.data EINMALIG zu den exakten Bytes, die auch
|
||
/// gesendet werden – Hash und gesendeter Body müssen bitidentisch sein,
|
||
/// sonst schlägt die Server-Signaturprüfung fehl.
|
||
List<int> _serializeBody(RequestOptions options) {
|
||
final data = options.data;
|
||
if (data == null) return const [];
|
||
if (data is Map || data is List) return utf8.encode(jsonEncode(data));
|
||
if (data is String) return utf8.encode(data);
|
||
if (data is List<int>) return data;
|
||
if (data is Uint8List) return data;
|
||
return const [];
|
||
}
|
||
|
||
/// Server sieht den vollen ASGI-Pfad inkl. "/api/v1"-Präfix
|
||
/// (request.url.path in kiosk_security.py) – dio's options.path ist nur
|
||
/// relativ zur baseUrl, hier beides zusammensetzen.
|
||
String _fullRequestPath(String relativePath) {
|
||
final basePath = Uri.parse(_baseUrl).path; // z.B. "/api/v1"
|
||
final base = basePath.endsWith('/') ? basePath.substring(0, basePath.length - 1) : basePath;
|
||
final rel = relativePath.startsWith('/') ? relativePath : '/$relativePath';
|
||
return '$base$rel';
|
||
}
|
||
|
||
String get baseUrl => _baseUrl;
|
||
bool get isEnrolled => _deviceId != null && _deviceId!.isNotEmpty;
|
||
|
||
Future<void> enroll({required String baseUrl, required String deviceId}) async {
|
||
_baseUrl = baseUrl;
|
||
_deviceId = deviceId;
|
||
dio.options.baseUrl = baseUrl;
|
||
await _storage.write(key: _keyBaseUrl, value: baseUrl);
|
||
await _storage.write(key: _keyDeviceId, value: deviceId);
|
||
}
|
||
|
||
Future<void> resetEnrollment() async {
|
||
_deviceId = null;
|
||
await _storage.delete(key: _keyDeviceId);
|
||
await crypto.reset();
|
||
}
|
||
}
|