From 3184cd352fad663839d3e94a72e82ef7a689ca33 Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 10 Sep 2026 15:34:40 +0200 Subject: [PATCH] feat: Ed25519-Request-Signing statt Bearer-Token (Security-Audit-Fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1 --- lib/app_version.dart | 2 +- lib/screens/enrollment_screen.dart | 74 +++++++++---- lib/screens/qr_enrollment_scan_screen.dart | 8 +- lib/services/api_client.dart | 91 ++++++++++++---- lib/services/kiosk_crypto_service.dart | 115 +++++++++++++++++++++ pubspec.yaml | 3 +- 6 files changed, 248 insertions(+), 45 deletions(-) create mode 100644 lib/services/kiosk_crypto_service.dart diff --git a/lib/app_version.dart b/lib/app_version.dart index 17afb29..a37c00a 100644 --- a/lib/app_version.dart +++ b/lib/app_version.dart @@ -1,3 +1,3 @@ /// Manuell mit pubspec.yaml synchron halten (siehe timemaster-mobile, /// gleiche Konvention – kein package_info_plus während aktiver Feldtests). -const String kAppVersion = '0.1.11+12'; +const String kAppVersion = '0.2.0+13'; diff --git a/lib/screens/enrollment_screen.dart b/lib/screens/enrollment_screen.dart index 5dae4a7..44093ac 100644 --- a/lib/screens/enrollment_screen.dart +++ b/lib/screens/enrollment_screen.dart @@ -4,12 +4,12 @@ import '../services/api_client.dart'; import 'login_screen.dart'; import 'qr_enrollment_scan_screen.dart'; -/// Einmalige Ersteinrichtung: Server-URL + Bearer-Token eintragen, die per -/// `timemaster kiosk add --auth-mode bearer` (CLI) oder im Portal -/// (KioskDevicesPage → Bearer-Gerät anlegen) erzeugt wurden. Portal zeigt -/// dafür zusätzlich einen QR-Code an, den diese Seite scannen kann – spart -/// das Abtippen des Tokens. Kein Keypair (VPN übernimmt Transport- -/// Sicherheit, siehe Plan Android-Tablet-Kiosk 2026-09-10). +/// Einmalige Ersteinrichtung: Gerät erzeugt selbst ein Ed25519-Schlüsselpaar +/// (privater Schlüssel verlässt nie das Gerät), zeigt den öffentlichen +/// Schlüssel an – Admin trägt ihn im Portal bei "PC-Terminal (Ed25519)" ein +/// und gibt die dabei erzeugte Geräte-ID zurück (per QR-Scan oder manuell). +/// Ersetzt seit dem Security-Audit 2026-09-10 den reinen Bearer-Token-Modus +/// (siehe api_client.dart – Token war ohne TLS im Klartext mitschneidbar). class EnrollmentScreen extends StatefulWidget { const EnrollmentScreen({super.key}); @@ -19,10 +19,27 @@ class EnrollmentScreen extends StatefulWidget { class _EnrollmentScreenState extends State { final _urlCtrl = TextEditingController(text: ApiClient.instance.baseUrl); - final _tokenCtrl = TextEditingController(); + final _deviceIdCtrl = TextEditingController(); + String? _publicKey; + bool _loadingKey = true; bool _saving = false; String? _error; + @override + void initState() { + super.initState(); + _loadPublicKey(); + } + + Future _loadPublicKey() async { + final key = await ApiClient.instance.crypto.ensurePublicKeySsh(); + if (!mounted) return; + setState(() { + _publicKey = key; + _loadingKey = false; + }); + } + Future _scanQr() async { final result = await Navigator.of(context).push>( MaterialPageRoute(builder: (_) => const QrEnrollmentScanScreen()), @@ -30,14 +47,14 @@ class _EnrollmentScreenState extends State { if (result == null || !mounted) return; setState(() { _urlCtrl.text = result['url'] ?? _urlCtrl.text; - _tokenCtrl.text = result['token'] ?? ''; + _deviceIdCtrl.text = result['deviceId'] ?? result['device_id'] ?? ''; _error = null; }); } Future _save() async { - if (_urlCtrl.text.trim().isEmpty || _tokenCtrl.text.trim().isEmpty) { - setState(() => _error = 'Server-URL und Token sind Pflichtfelder.'); + if (_urlCtrl.text.trim().isEmpty || _deviceIdCtrl.text.trim().isEmpty) { + setState(() => _error = 'Server-URL und Geräte-ID sind Pflichtfelder.'); return; } setState(() { @@ -46,7 +63,7 @@ class _EnrollmentScreenState extends State { }); await ApiClient.instance.enroll( baseUrl: _urlCtrl.text.trim(), - deviceToken: _tokenCtrl.text.trim(), + deviceId: _deviceIdCtrl.text.trim(), ); if (!mounted) return; Navigator.of(context).pushReplacement( @@ -61,7 +78,7 @@ class _EnrollmentScreenState extends State { body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), - child: Padding( + child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, @@ -69,12 +86,14 @@ class _EnrollmentScreenState extends State { const Icon(Icons.tablet_mac, size: 64, color: Colors.indigo), const SizedBox(height: 16), Text('Ersteinrichtung', style: Theme.of(context).textTheme.headlineSmall), - const SizedBox(height: 8), + const SizedBox(height: 16), + _publicKeyCard(), + const SizedBox(height: 24), const Text( - 'Bearer-Token vom Admin einfügen (per "timemaster kiosk add --auth-mode bearer" erzeugt).', + 'Danach: Server-URL + Geräte-ID eintragen (aus dem Portal, nach dem Anlegen des Geräts mit obigem Public Key).', textAlign: TextAlign.center, ), - const SizedBox(height: 24), + const SizedBox(height: 16), SizedBox( width: double.infinity, child: OutlinedButton.icon( @@ -96,9 +115,8 @@ class _EnrollmentScreenState extends State { ), const SizedBox(height: 12), TextField( - controller: _tokenCtrl, - decoration: const InputDecoration(labelText: 'Bearer-Token'), - obscureText: true, + controller: _deviceIdCtrl, + decoration: const InputDecoration(labelText: 'Geräte-ID (UUID aus dem Portal)'), ), const SizedBox(height: 24), if (_error != null) ...[ @@ -119,4 +137,24 @@ class _EnrollmentScreenState extends State { ), ); } + + Widget _publicKeyCard() { + if (_loadingKey) return const CircularProgressIndicator(); + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Öffentlicher Schlüssel dieses Geräts', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + const Text('Im Portal unter "Neues Kiosk-Gerät" → PC-Terminal (Ed25519) einfügen.', + style: TextStyle(fontSize: 12, color: Colors.grey)), + const SizedBox(height: 8), + SelectableText(_publicKey ?? '', style: const TextStyle(fontFamily: 'monospace', fontSize: 11)), + ], + ), + ), + ); + } } diff --git a/lib/screens/qr_enrollment_scan_screen.dart b/lib/screens/qr_enrollment_scan_screen.dart index bffc9f1..30293c7 100644 --- a/lib/screens/qr_enrollment_scan_screen.dart +++ b/lib/screens/qr_enrollment_scan_screen.dart @@ -50,13 +50,13 @@ class _QrEnrollmentScanScreenState extends State { try { final data = jsonDecode(raw) as Map; final url = data['url'] as String?; - final token = data['token'] as String?; - if (url == null || url.isEmpty || token == null || token.isEmpty) { - setState(() => _error = 'QR-Code enthält nicht die erwarteten Felder (url, token).'); + 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, 'token': token}); + Navigator.of(context).pop({'url': url, 'deviceId': deviceId}); } catch (_) { setState(() => _error = 'QR-Code konnte nicht gelesen werden (kein gültiges Format).'); } diff --git a/lib/services/api_client.dart b/lib/services/api_client.dart index 8498a48..a2d8bc2 100644 --- a/lib/services/api_client.dart +++ b/lib/services/api_client.dart @@ -1,35 +1,40 @@ +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: einfaches Bearer-Token (key_algorithm="bearer_vpn" im -/// Backend, siehe backend/app/core/kiosk_security.py). Kein Request-Signing -/// wie beim Browser-PC-Terminal (Ed25519) – Sicherheit kommt vom WireGuard- -/// VPN + IP-Whitelist, nicht vom Client. Token wird per -/// `timemaster kiosk add --auth-mode bearer` einmalig generiert und hier -/// bei der Ersteinrichtung eingetragen. +/// 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 _keyDeviceToken = 'kiosk_device_token'; + static const _keyDeviceId = 'kiosk_device_id'; + + final crypto = KioskCryptoService(); late final Dio dio; String _baseUrl = 'https://timemaster.example.com/api/v1'; - String? _deviceToken; + String? _deviceId; Future 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, - // z.B. in enroll() oder KioskService). + // konstruiert werden (sonst LateInitializationError beim ersten Zugriff). try { final savedUrl = await _storage.read(key: _keyBaseUrl); if (savedUrl != null && savedUrl.isNotEmpty) _baseUrl = savedUrl; } catch (_) {} try { - _deviceToken = await _storage.read(key: _keyDeviceToken); + _deviceId = await _storage.read(key: _keyDeviceId); } catch (_) {} dio = Dio(BaseOptions( @@ -39,28 +44,72 @@ class ApiClient { )); dio.interceptors.add(InterceptorsWrapper( - onRequest: (options, handler) { - if (_deviceToken != null) { - options.headers['Authorization'] = 'Bearer $_deviceToken'; + 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); }, )); } - String get baseUrl => _baseUrl; - bool get isEnrolled => _deviceToken != null && _deviceToken!.isNotEmpty; + /// 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 _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) return data; + if (data is Uint8List) return data; + return const []; + } - Future enroll({required String baseUrl, required String deviceToken}) async { + /// 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 enroll({required String baseUrl, required String deviceId}) async { _baseUrl = baseUrl; - _deviceToken = deviceToken; + _deviceId = deviceId; dio.options.baseUrl = baseUrl; await _storage.write(key: _keyBaseUrl, value: baseUrl); - await _storage.write(key: _keyDeviceToken, value: deviceToken); + await _storage.write(key: _keyDeviceId, value: deviceId); } Future resetEnrollment() async { - _deviceToken = null; - await _storage.delete(key: _keyDeviceToken); + _deviceId = null; + await _storage.delete(key: _keyDeviceId); + await crypto.reset(); } } diff --git a/lib/services/kiosk_crypto_service.dart b/lib/services/kiosk_crypto_service.dart new file mode 100644 index 0000000..110b3eb --- /dev/null +++ b/lib/services/kiosk_crypto_service.dart @@ -0,0 +1,115 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; +import 'package:cryptography/cryptography.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Ed25519-Request-Signing für Kiosk-Geräte – ersetzt den reinen Bearer- +/// Token-Modus (siehe Security-Audit 2026-09-10: Bearer-Token ist ohne TLS +/// im Klartext mitschneidbar und unbegrenzt gültig; eine Signatur über +/// Timestamp+Nonce+Body ist das auch ohne TLS/VPN wertlos für einen +/// Angreifer, der nur den Netzwerk-Traffic sieht). Gleiches Schema wie die +/// Browser-PC-Terminals, siehe backend/app/core/kiosk_security.py. +class KioskCryptoService { + static const _storage = FlutterSecureStorage(); + static const _keySeed = 'kiosk_ed25519_seed'; + + static final _algorithm = Ed25519(); + SimpleKeyPair? _keyPair; + + Future get hasKeyPair async => (await _storage.read(key: _keySeed)) != null; + + /// Lädt das gespeicherte Schlüsselpaar oder erzeugt (falls keins existiert) + /// eines neu und speichert den Seed. Der private Schlüssel verlässt nie + /// dieses Gerät. + Future _loadOrCreateKeyPair() async { + if (_keyPair != null) return _keyPair!; + + final storedSeed = await _storage.read(key: _keySeed); + if (storedSeed != null) { + final seedBytes = base64Decode(storedSeed); + _keyPair = await _algorithm.newKeyPairFromSeed(seedBytes); + return _keyPair!; + } + + final newKeyPair = await _algorithm.newKeyPair(); + final seedBytes = await newKeyPair.extractPrivateKeyBytes(); + await _storage.write(key: _keySeed, value: base64Encode(seedBytes)); + _keyPair = newKeyPair; + return newKeyPair; + } + + /// Erzeugt (falls nötig) das Schlüsselpaar und gibt den Public Key im + /// OpenSSH-Format zurück ("ssh-ed25519 AAAA..."), genau das Format, das + /// backend/app/core/kiosk_security.py::_load_ed25519_public_key erwartet + /// und das Portal-Formular für Ed25519-Geräte annimmt. + Future ensurePublicKeySsh() async { + final keyPair = await _loadOrCreateKeyPair(); + final publicKey = await keyPair.extractPublicKey(); + return _encodeSshEd25519(publicKey.bytes); + } + + String _encodeSshEd25519(List rawPublicKey) { + // OpenSSH-Wire-Format: string("ssh-ed25519") + string(pubkey-bytes), + // jedes "string" = 4-Byte-Big-Endian-Länge + Rohbytes. + final keyType = utf8.encode('ssh-ed25519'); + final buffer = BytesBuilder(); + buffer.add(_uint32be(keyType.length)); + buffer.add(keyType); + buffer.add(_uint32be(rawPublicKey.length)); + buffer.add(rawPublicKey); + return 'ssh-ed25519 ${base64Encode(buffer.toBytes())}'; + } + + Uint8List _uint32be(int value) { + final bytes = Uint8List(4); + bytes.buffer.asByteData().setUint32(0, value, Endian.big); + return bytes; + } + + /// Signiert einen Request nach dem Schema von verify_kiosk_request(): + /// "METHOD PATH TIMESTAMP NONCE sha256(BODY)". Gibt die 4 Header zurück. + Future> signRequest({ + required String deviceId, + required String method, + required String path, + required List bodyBytes, + }) async { + final keyPair = await _loadOrCreateKeyPair(); + final timestamp = (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString(); + final nonce = _generateNonce(); + final bodyHash = await sha256Hex(bodyBytes); + final message = '$method $path $timestamp $nonce $bodyHash'; + + final signature = await _algorithm.sign(utf8.encode(message), keyPair: keyPair); + + return { + 'X-Kiosk-Key-Id': deviceId, + 'X-Kiosk-Timestamp': timestamp, + 'X-Kiosk-Nonce': nonce, + 'X-Kiosk-Signature': base64Encode(signature.bytes), + }; + } + + String _generateNonce() { + // UUID v4, wie vom Server verlangt (verify_kiosk_request prüft + // UUID-Format). Random.secure() = kryptografisch sicherer Zufalls- + // generator des Betriebssystems, kein extra Paket nötig. + final random = Random.secure(); + final rnd = List.generate(16, (_) => random.nextInt(256)); + rnd[6] = (rnd[6] & 0x0f) | 0x40; + rnd[8] = (rnd[8] & 0x3f) | 0x80; + final hex = rnd.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}'; + } + + Future reset() async { + await _storage.delete(key: _keySeed); + _keyPair = null; + } +} + +Future sha256Hex(List bytes) async { + final digest = await Sha256().hash(bytes); + return digest.bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); +} diff --git a/pubspec.yaml b/pubspec.yaml index a5d4a4c..432f327 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: timemaster_kiosk description: TimeMaster Kiosk – Stempeluhr für Android-Tablets hinter VPN publish_to: 'none' -version: 0.1.11+12 +version: 0.2.0+13 environment: sdk: '>=3.3.0 <4.0.0' @@ -17,6 +17,7 @@ dependencies: mobile_scanner: ^6.0.2 open_filex: ^4.5.0 path_provider: ^2.1.4 + cryptography: ^2.9.0 dev_dependencies: flutter_test: