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,
/// 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 '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<EnrollmentScreen> {
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<void> _loadPublicKey() async {
final key = await ApiClient.instance.crypto.ensurePublicKeySsh();
if (!mounted) return;
setState(() {
_publicKey = key;
_loadingKey = false;
});
}
Future<void> _scanQr() async {
final result = await Navigator.of(context).push<Map<String, String>>(
MaterialPageRoute(builder: (_) => const QrEnrollmentScanScreen()),
@@ -30,14 +47,14 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
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<void> _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<EnrollmentScreen> {
});
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<EnrollmentScreen> {
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<EnrollmentScreen> {
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<EnrollmentScreen> {
),
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<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 {
final data = jsonDecode(raw) as Map<String, dynamic>;
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).');
}
+70 -21
View File
@@ -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<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,
// 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<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;
_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<void> resetEnrollment() async {
_deviceToken = null;
await _storage.delete(key: _keyDeviceToken);
_deviceId = null;
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
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: