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
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 15:34:40 +02:00
1 parent d10b5d572d
commit 3184cd352f
6 files changed
+248 -45

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
/// Manuell mit pubspec.yaml synchron halten (siehe timemaster-mobile, /// Manuell mit pubspec.yaml synchron halten (siehe timemaster-mobile,
/// gleiche Konvention – kein package_info_plus während aktiver Feldtests). /// gleiche Konvention – kein package_info_plus während aktiver Feldtests).
const String kAppVersion = '0.1.11+12'; const String kAppVersion = '0.2.0+13';
+56 -18
View File
@@ -4,12 +4,12 @@ import '../services/api_client.dart';
import 'login_screen.dart'; import 'login_screen.dart';
import 'qr_enrollment_scan_screen.dart'; import 'qr_enrollment_scan_screen.dart';
/// Einmalige Ersteinrichtung: Server-URL + Bearer-Token eintragen, die per /// Einmalige Ersteinrichtung: Gerät erzeugt selbst ein Ed25519-Schlüsselpaar
/// `timemaster kiosk add --auth-mode bearer` (CLI) oder im Portal /// (privater Schlüssel verlässt nie das Gerät), zeigt den öffentlichen
/// (KioskDevicesPage → Bearer-Gerät anlegen) erzeugt wurden. Portal zeigt /// Schlüssel an – Admin trägt ihn im Portal bei "PC-Terminal (Ed25519)" ein
/// dafür zusätzlich einen QR-Code an, den diese Seite scannen kann – spart /// und gibt die dabei erzeugte Geräte-ID zurück (per QR-Scan oder manuell).
/// das Abtippen des Tokens. Kein Keypair (VPN übernimmt Transport- /// Ersetzt seit dem Security-Audit 2026-09-10 den reinen Bearer-Token-Modus
/// Sicherheit, siehe Plan Android-Tablet-Kiosk 2026-09-10). /// (siehe api_client.dart – Token war ohne TLS im Klartext mitschneidbar).
class EnrollmentScreen extends StatefulWidget { class EnrollmentScreen extends StatefulWidget {
const EnrollmentScreen({super.key}); const EnrollmentScreen({super.key});
@@ -19,10 +19,27 @@ class EnrollmentScreen extends StatefulWidget {
class _EnrollmentScreenState extends State<EnrollmentScreen> { class _EnrollmentScreenState extends State<EnrollmentScreen> {
final _urlCtrl = TextEditingController(text: ApiClient.instance.baseUrl); final _urlCtrl = TextEditingController(text: ApiClient.instance.baseUrl);
final _tokenCtrl = TextEditingController(); final _deviceIdCtrl = TextEditingController();
String? _publicKey;
bool _loadingKey = true;
bool _saving = false; bool _saving = false;
String? _error; String? _error;
@override
void initState() {
super.initState();
_loadPublicKey();
}
Future<void> _loadPublicKey() async {
final key = await ApiClient.instance.crypto.ensurePublicKeySsh();
if (!mounted) return;
setState(() {
_publicKey = key;
_loadingKey = false;
});
}
Future<void> _scanQr() async { Future<void> _scanQr() async {
final result = await Navigator.of(context).push<Map<String, String>>( final result = await Navigator.of(context).push<Map<String, String>>(
MaterialPageRoute(builder: (_) => const QrEnrollmentScanScreen()), MaterialPageRoute(builder: (_) => const QrEnrollmentScanScreen()),
@@ -30,14 +47,14 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
if (result == null || !mounted) return; if (result == null || !mounted) return;
setState(() { setState(() {
_urlCtrl.text = result['url'] ?? _urlCtrl.text; _urlCtrl.text = result['url'] ?? _urlCtrl.text;
_tokenCtrl.text = result['token'] ?? ''; _deviceIdCtrl.text = result['deviceId'] ?? result['device_id'] ?? '';
_error = null; _error = null;
}); });
} }
Future<void> _save() async { Future<void> _save() async {
if (_urlCtrl.text.trim().isEmpty || _tokenCtrl.text.trim().isEmpty) { if (_urlCtrl.text.trim().isEmpty || _deviceIdCtrl.text.trim().isEmpty) {
setState(() => _error = 'Server-URL und Token sind Pflichtfelder.'); setState(() => _error = 'Server-URL und Geräte-ID sind Pflichtfelder.');
return; return;
} }
setState(() { setState(() {
@@ -46,7 +63,7 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
}); });
await ApiClient.instance.enroll( await ApiClient.instance.enroll(
baseUrl: _urlCtrl.text.trim(), baseUrl: _urlCtrl.text.trim(),
deviceToken: _tokenCtrl.text.trim(), deviceId: _deviceIdCtrl.text.trim(),
); );
if (!mounted) return; if (!mounted) return;
Navigator.of(context).pushReplacement( Navigator.of(context).pushReplacement(
@@ -61,7 +78,7 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
body: Center( body: Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480), constraints: const BoxConstraints(maxWidth: 480),
child: Padding( child: SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -69,12 +86,14 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
const Icon(Icons.tablet_mac, size: 64, color: Colors.indigo), const Icon(Icons.tablet_mac, size: 64, color: Colors.indigo),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Ersteinrichtung', style: Theme.of(context).textTheme.headlineSmall), Text('Ersteinrichtung', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 8), const SizedBox(height: 16),
_publicKeyCard(),
const SizedBox(height: 24),
const Text( 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, textAlign: TextAlign.center,
), ),
const SizedBox(height: 24), const SizedBox(height: 16),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: OutlinedButton.icon( child: OutlinedButton.icon(
@@ -96,9 +115,8 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
controller: _tokenCtrl, controller: _deviceIdCtrl,
decoration: const InputDecoration(labelText: 'Bearer-Token'), decoration: const InputDecoration(labelText: 'Geräte-ID (UUID aus dem Portal)'),
obscureText: true,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
if (_error != null) ...[ if (_error != null) ...[
@@ -119,4 +137,24 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
), ),
); );
} }
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)),
],
),
),
);
}
} }
+4 -4
View File
@@ -50,13 +50,13 @@ class _QrEnrollmentScanScreenState extends State<QrEnrollmentScanScreen> {
try { try {
final data = jsonDecode(raw) as Map<String, dynamic>; final data = jsonDecode(raw) as Map<String, dynamic>;
final url = data['url'] as String?; final url = data['url'] as String?;
final token = data['token'] as String?; final deviceId = (data['device_id'] ?? data['deviceId']) as String?;
if (url == null || url.isEmpty || token == null || token.isEmpty) { if (url == null || url.isEmpty || deviceId == null || deviceId.isEmpty) {
setState(() => _error = 'QR-Code enthält nicht die erwarteten Felder (url, token).'); setState(() => _error = 'QR-Code enthält nicht die erwarteten Felder (url, device_id).');
return; return;
} }
_handled = true; _handled = true;
Navigator.of(context).pop({'url': url, 'token': token}); Navigator.of(context).pop({'url': url, 'deviceId': deviceId});
} catch (_) { } catch (_) {
setState(() => _error = 'QR-Code konnte nicht gelesen werden (kein gültiges Format).'); setState(() => _error = 'QR-Code konnte nicht gelesen werden (kein gültiges Format).');
} }
+70 -21
View File
@@ -1,35 +1,40 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.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 /// Kiosk-Geräte-Auth: Ed25519-Request-Signing, identisch zum Schema der
/// Backend, siehe backend/app/core/kiosk_security.py). Kein Request-Signing /// Browser-PC-Terminals (siehe backend/app/core/kiosk_security.py). Nach
/// wie beim Browser-PC-Terminal (Ed25519) – Sicherheit kommt vom WireGuard- /// dem Security-Audit vom 2026-09-10 wurde der reine Bearer-Token-Modus
/// VPN + IP-Whitelist, nicht vom Client. Token wird per /// verworfen – ohne TLS war ein mitgeschnittenes Token unbegrenzt gültig
/// `timemaster kiosk add --auth-mode bearer` einmalig generiert und hier /// und firmenweit einlösbar. Signierte Requests sind auch ohne TLS/VPN
/// bei der Ersteinrichtung eingetragen. /// wertlos für einen reinen Netzwerk-Mitleser (Timestamp+Nonce+Body-Hash
/// signiert, private Schlüssel verlässt nie dieses Gerät).
class ApiClient { class ApiClient {
ApiClient._internal(); ApiClient._internal();
static final ApiClient instance = ApiClient._internal(); static final ApiClient instance = ApiClient._internal();
static const _storage = FlutterSecureStorage(); static const _storage = FlutterSecureStorage();
static const _keyBaseUrl = 'kiosk_base_url'; 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; late final Dio dio;
String _baseUrl = 'https://timemaster.example.com/api/v1'; String _baseUrl = 'https://timemaster.example.com/api/v1';
String? _deviceToken; String? _deviceId;
Future<void> init() async { Future<void> init() async {
// Storage-Reads bewusst einzeln try/catch – wenn z.B. der Android- // Storage-Reads bewusst einzeln try/catch – wenn z.B. der Android-
// Keystore beim Kaltstart noch nicht bereit ist, soll `dio` trotzdem // Keystore beim Kaltstart noch nicht bereit ist, soll `dio` trotzdem
// konstruiert werden (sonst LateInitializationError beim ersten Zugriff, // konstruiert werden (sonst LateInitializationError beim ersten Zugriff).
// z.B. in enroll() oder KioskService).
try { try {
final savedUrl = await _storage.read(key: _keyBaseUrl); final savedUrl = await _storage.read(key: _keyBaseUrl);
if (savedUrl != null && savedUrl.isNotEmpty) _baseUrl = savedUrl; if (savedUrl != null && savedUrl.isNotEmpty) _baseUrl = savedUrl;
} catch (_) {} } catch (_) {}
try { try {
_deviceToken = await _storage.read(key: _keyDeviceToken); _deviceId = await _storage.read(key: _keyDeviceId);
} catch (_) {} } catch (_) {}
dio = Dio(BaseOptions( dio = Dio(BaseOptions(
@@ -39,28 +44,72 @@ class ApiClient {
)); ));
dio.interceptors.add(InterceptorsWrapper( dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) { onRequest: (options, handler) async {
if (_deviceToken != null) { if (_deviceId == null) {
options.headers['Authorization'] = 'Bearer $_deviceToken'; 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); handler.next(options);
}, },
)); ));
} }
String get baseUrl => _baseUrl; /// Serialisiert options.data EINMALIG zu den exakten Bytes, die auch
bool get isEnrolled => _deviceToken != null && _deviceToken!.isNotEmpty; /// 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 [];
}
Future<void> 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<void> enroll({required String baseUrl, required String deviceId}) async {
_baseUrl = baseUrl; _baseUrl = baseUrl;
_deviceToken = deviceToken; _deviceId = deviceId;
dio.options.baseUrl = baseUrl; dio.options.baseUrl = baseUrl;
await _storage.write(key: _keyBaseUrl, value: baseUrl); await _storage.write(key: _keyBaseUrl, value: baseUrl);
await _storage.write(key: _keyDeviceToken, value: deviceToken); await _storage.write(key: _keyDeviceId, value: deviceId);
} }
Future<void> resetEnrollment() async { Future<void> resetEnrollment() async {
_deviceToken = null; _deviceId = null;
await _storage.delete(key: _keyDeviceToken); await _storage.delete(key: _keyDeviceId);
await crypto.reset();
} }
} }
+115
View File
@@ -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<bool> 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<SimpleKeyPair> _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<String> ensurePublicKeySsh() async {
final keyPair = await _loadOrCreateKeyPair();
final publicKey = await keyPair.extractPublicKey();
return _encodeSshEd25519(publicKey.bytes);
}
String _encodeSshEd25519(List<int> 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<Map<String, String>> signRequest({
required String deviceId,
required String method,
required String path,
required List<int> 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<int>.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<void> reset() async {
await _storage.delete(key: _keySeed);
_keyPair = null;
}
}
Future<String> sha256Hex(List<int> bytes) async {
final digest = await Sha256().hash(bytes);
return digest.bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
+2 -1
View File
@@ -1,7 +1,7 @@
name: timemaster_kiosk name: timemaster_kiosk
description: TimeMaster Kiosk – Stempeluhr für Android-Tablets hinter VPN description: TimeMaster Kiosk – Stempeluhr für Android-Tablets hinter VPN
publish_to: 'none' publish_to: 'none'
version: 0.1.11+12 version: 0.2.0+13
environment: environment:
sdk: '>=3.3.0 <4.0.0' sdk: '>=3.3.0 <4.0.0'
@@ -17,6 +17,7 @@ dependencies:
mobile_scanner: ^6.0.2 mobile_scanner: ^6.0.2
open_filex: ^4.5.0 open_filex: ^4.5.0
path_provider: ^2.1.4 path_provider: ^2.1.4
cryptography: ^2.9.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: