Files
timemaster-kiosk/lib/screens/enrollment_screen.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

161 lines
5.7 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 '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)),
],
),
),
);
}
}