Files
timemaster-kiosk/lib/services/api_client.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

116 lines
4.3 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 '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();
}
}