Files
timemaster-kiosk/lib/services/kiosk_crypto_service.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

116 lines
4.5 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 '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();
}