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