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
161 lines
5.7 KiB
Dart
161 lines
5.7 KiB
Dart
import 'package:flutter/material.dart';
|
||
import '../app_version.dart';
|
||
import '../services/api_client.dart';
|
||
import 'login_screen.dart';
|
||
import 'qr_enrollment_scan_screen.dart';
|
||
|
||
/// 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});
|
||
|
||
@override
|
||
State<EnrollmentScreen> createState() => _EnrollmentScreenState();
|
||
}
|
||
|
||
class _EnrollmentScreenState extends State<EnrollmentScreen> {
|
||
final _urlCtrl = TextEditingController(text: ApiClient.instance.baseUrl);
|
||
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()),
|
||
);
|
||
if (result == null || !mounted) return;
|
||
setState(() {
|
||
_urlCtrl.text = result['url'] ?? _urlCtrl.text;
|
||
_deviceIdCtrl.text = result['deviceId'] ?? result['device_id'] ?? '';
|
||
_error = null;
|
||
});
|
||
}
|
||
|
||
Future<void> _save() async {
|
||
if (_urlCtrl.text.trim().isEmpty || _deviceIdCtrl.text.trim().isEmpty) {
|
||
setState(() => _error = 'Server-URL und Geräte-ID sind Pflichtfelder.');
|
||
return;
|
||
}
|
||
setState(() {
|
||
_saving = true;
|
||
_error = null;
|
||
});
|
||
await ApiClient.instance.enroll(
|
||
baseUrl: _urlCtrl.text.trim(),
|
||
deviceId: _deviceIdCtrl.text.trim(),
|
||
);
|
||
if (!mounted) return;
|
||
Navigator.of(context).pushReplacement(
|
||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
appBar: AppBar(title: Text('TimeMaster Kiosk · v$kAppVersion')),
|
||
body: Center(
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 480),
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
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: 16),
|
||
_publicKeyCard(),
|
||
const SizedBox(height: 24),
|
||
const Text(
|
||
'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: 16),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: OutlinedButton.icon(
|
||
onPressed: _scanQr,
|
||
icon: const Icon(Icons.qr_code_scanner),
|
||
label: const Text('QR-Code scannen'),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Row(children: const [
|
||
Expanded(child: Divider()),
|
||
Padding(padding: EdgeInsets.symmetric(horizontal: 8), child: Text('oder manuell')),
|
||
Expanded(child: Divider()),
|
||
]),
|
||
const SizedBox(height: 16),
|
||
TextField(
|
||
controller: _urlCtrl,
|
||
decoration: const InputDecoration(labelText: 'Server-URL', hintText: 'https://.../api/v1'),
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextField(
|
||
controller: _deviceIdCtrl,
|
||
decoration: const InputDecoration(labelText: 'Geräte-ID (UUID aus dem Portal)'),
|
||
),
|
||
const SizedBox(height: 24),
|
||
if (_error != null) ...[
|
||
Text(_error!, style: const TextStyle(color: Colors.red)),
|
||
const SizedBox(height: 12),
|
||
],
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: FilledButton(
|
||
onPressed: _saving ? null : _save,
|
||
child: const Text('Einrichten'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|