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:
1 parent
d10b5d572d
commit
3184cd352f
6 files changed
+248
-45
No files matched your search
@@ -1,3 +1,3 @@
|
|||||||
/// Manuell mit pubspec.yaml synchron halten (siehe timemaster-mobile,
|
/// Manuell mit pubspec.yaml synchron halten (siehe timemaster-mobile,
|
||||||
/// gleiche Konvention – kein package_info_plus während aktiver Feldtests).
|
/// gleiche Konvention – kein package_info_plus während aktiver Feldtests).
|
||||||
const String kAppVersion = '0.1.11+12';
|
const String kAppVersion = '0.2.0+13';
|
||||||
@@ -4,12 +4,12 @@ import '../services/api_client.dart';
|
|||||||
import 'login_screen.dart';
|
import 'login_screen.dart';
|
||||||
import 'qr_enrollment_scan_screen.dart';
|
import 'qr_enrollment_scan_screen.dart';
|
||||||
|
|
||||||
/// Einmalige Ersteinrichtung: Server-URL + Bearer-Token eintragen, die per
|
/// Einmalige Ersteinrichtung: Gerät erzeugt selbst ein Ed25519-Schlüsselpaar
|
||||||
/// `timemaster kiosk add --auth-mode bearer` (CLI) oder im Portal
|
/// (privater Schlüssel verlässt nie das Gerät), zeigt den öffentlichen
|
||||||
/// (KioskDevicesPage → Bearer-Gerät anlegen) erzeugt wurden. Portal zeigt
|
/// Schlüssel an – Admin trägt ihn im Portal bei "PC-Terminal (Ed25519)" ein
|
||||||
/// dafür zusätzlich einen QR-Code an, den diese Seite scannen kann – spart
|
/// und gibt die dabei erzeugte Geräte-ID zurück (per QR-Scan oder manuell).
|
||||||
/// das Abtippen des Tokens. Kein Keypair (VPN übernimmt Transport-
|
/// Ersetzt seit dem Security-Audit 2026-09-10 den reinen Bearer-Token-Modus
|
||||||
/// Sicherheit, siehe Plan Android-Tablet-Kiosk 2026-09-10).
|
/// (siehe api_client.dart – Token war ohne TLS im Klartext mitschneidbar).
|
||||||
class EnrollmentScreen extends StatefulWidget {
|
class EnrollmentScreen extends StatefulWidget {
|
||||||
const EnrollmentScreen({super.key});
|
const EnrollmentScreen({super.key});
|
||||||
|
|
||||||
@@ -19,10 +19,27 @@ class EnrollmentScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _EnrollmentScreenState extends State<EnrollmentScreen> {
|
class _EnrollmentScreenState extends State<EnrollmentScreen> {
|
||||||
final _urlCtrl = TextEditingController(text: ApiClient.instance.baseUrl);
|
final _urlCtrl = TextEditingController(text: ApiClient.instance.baseUrl);
|
||||||
final _tokenCtrl = TextEditingController();
|
final _deviceIdCtrl = TextEditingController();
|
||||||
|
String? _publicKey;
|
||||||
|
bool _loadingKey = true;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
String? _error;
|
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 {
|
Future<void> _scanQr() async {
|
||||||
final result = await Navigator.of(context).push<Map<String, String>>(
|
final result = await Navigator.of(context).push<Map<String, String>>(
|
||||||
MaterialPageRoute(builder: (_) => const QrEnrollmentScanScreen()),
|
MaterialPageRoute(builder: (_) => const QrEnrollmentScanScreen()),
|
||||||
@@ -30,14 +47,14 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
|
|||||||
if (result == null || !mounted) return;
|
if (result == null || !mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_urlCtrl.text = result['url'] ?? _urlCtrl.text;
|
_urlCtrl.text = result['url'] ?? _urlCtrl.text;
|
||||||
_tokenCtrl.text = result['token'] ?? '';
|
_deviceIdCtrl.text = result['deviceId'] ?? result['device_id'] ?? '';
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
if (_urlCtrl.text.trim().isEmpty || _tokenCtrl.text.trim().isEmpty) {
|
if (_urlCtrl.text.trim().isEmpty || _deviceIdCtrl.text.trim().isEmpty) {
|
||||||
setState(() => _error = 'Server-URL und Token sind Pflichtfelder.');
|
setState(() => _error = 'Server-URL und Geräte-ID sind Pflichtfelder.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -46,7 +63,7 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
|
|||||||
});
|
});
|
||||||
await ApiClient.instance.enroll(
|
await ApiClient.instance.enroll(
|
||||||
baseUrl: _urlCtrl.text.trim(),
|
baseUrl: _urlCtrl.text.trim(),
|
||||||
deviceToken: _tokenCtrl.text.trim(),
|
deviceId: _deviceIdCtrl.text.trim(),
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context).pushReplacement(
|
Navigator.of(context).pushReplacement(
|
||||||
@@ -61,7 +78,7 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
|
|||||||
body: Center(
|
body: Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 480),
|
constraints: const BoxConstraints(maxWidth: 480),
|
||||||
child: Padding(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -69,12 +86,14 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
|
|||||||
const Icon(Icons.tablet_mac, size: 64, color: Colors.indigo),
|
const Icon(Icons.tablet_mac, size: 64, color: Colors.indigo),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text('Ersteinrichtung', style: Theme.of(context).textTheme.headlineSmall),
|
Text('Ersteinrichtung', style: Theme.of(context).textTheme.headlineSmall),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 16),
|
||||||
|
_publicKeyCard(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
const Text(
|
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,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 16),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
@@ -96,9 +115,8 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _tokenCtrl,
|
controller: _deviceIdCtrl,
|
||||||
decoration: const InputDecoration(labelText: 'Bearer-Token'),
|
decoration: const InputDecoration(labelText: 'Geräte-ID (UUID aus dem Portal)'),
|
||||||
obscureText: true,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
if (_error != null) ...[
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -50,13 +50,13 @@ class _QrEnrollmentScanScreenState extends State<QrEnrollmentScanScreen> {
|
|||||||
try {
|
try {
|
||||||
final data = jsonDecode(raw) as Map<String, dynamic>;
|
final data = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
final url = data['url'] as String?;
|
final url = data['url'] as String?;
|
||||||
final token = data['token'] as String?;
|
final deviceId = (data['device_id'] ?? data['deviceId']) as String?;
|
||||||
if (url == null || url.isEmpty || token == null || token.isEmpty) {
|
if (url == null || url.isEmpty || deviceId == null || deviceId.isEmpty) {
|
||||||
setState(() => _error = 'QR-Code enthält nicht die erwarteten Felder (url, token).');
|
setState(() => _error = 'QR-Code enthält nicht die erwarteten Felder (url, device_id).');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_handled = true;
|
_handled = true;
|
||||||
Navigator.of(context).pop({'url': url, 'token': token});
|
Navigator.of(context).pop({'url': url, 'deviceId': deviceId});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
setState(() => _error = 'QR-Code konnte nicht gelesen werden (kein gültiges Format).');
|
setState(() => _error = 'QR-Code konnte nicht gelesen werden (kein gültiges Format).');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,40 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.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
|
/// Kiosk-Geräte-Auth: Ed25519-Request-Signing, identisch zum Schema der
|
||||||
/// Backend, siehe backend/app/core/kiosk_security.py). Kein Request-Signing
|
/// Browser-PC-Terminals (siehe backend/app/core/kiosk_security.py). Nach
|
||||||
/// wie beim Browser-PC-Terminal (Ed25519) – Sicherheit kommt vom WireGuard-
|
/// dem Security-Audit vom 2026-09-10 wurde der reine Bearer-Token-Modus
|
||||||
/// VPN + IP-Whitelist, nicht vom Client. Token wird per
|
/// verworfen – ohne TLS war ein mitgeschnittenes Token unbegrenzt gültig
|
||||||
/// `timemaster kiosk add --auth-mode bearer` einmalig generiert und hier
|
/// und firmenweit einlösbar. Signierte Requests sind auch ohne TLS/VPN
|
||||||
/// bei der Ersteinrichtung eingetragen.
|
/// wertlos für einen reinen Netzwerk-Mitleser (Timestamp+Nonce+Body-Hash
|
||||||
|
/// signiert, private Schlüssel verlässt nie dieses Gerät).
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
ApiClient._internal();
|
ApiClient._internal();
|
||||||
static final ApiClient instance = ApiClient._internal();
|
static final ApiClient instance = ApiClient._internal();
|
||||||
|
|
||||||
static const _storage = FlutterSecureStorage();
|
static const _storage = FlutterSecureStorage();
|
||||||
static const _keyBaseUrl = 'kiosk_base_url';
|
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;
|
late final Dio dio;
|
||||||
String _baseUrl = 'https://timemaster.example.com/api/v1';
|
String _baseUrl = 'https://timemaster.example.com/api/v1';
|
||||||
String? _deviceToken;
|
String? _deviceId;
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
// Storage-Reads bewusst einzeln try/catch – wenn z.B. der Android-
|
// Storage-Reads bewusst einzeln try/catch – wenn z.B. der Android-
|
||||||
// Keystore beim Kaltstart noch nicht bereit ist, soll `dio` trotzdem
|
// Keystore beim Kaltstart noch nicht bereit ist, soll `dio` trotzdem
|
||||||
// konstruiert werden (sonst LateInitializationError beim ersten Zugriff,
|
// konstruiert werden (sonst LateInitializationError beim ersten Zugriff).
|
||||||
// z.B. in enroll() oder KioskService).
|
|
||||||
try {
|
try {
|
||||||
final savedUrl = await _storage.read(key: _keyBaseUrl);
|
final savedUrl = await _storage.read(key: _keyBaseUrl);
|
||||||
if (savedUrl != null && savedUrl.isNotEmpty) _baseUrl = savedUrl;
|
if (savedUrl != null && savedUrl.isNotEmpty) _baseUrl = savedUrl;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
try {
|
try {
|
||||||
_deviceToken = await _storage.read(key: _keyDeviceToken);
|
_deviceId = await _storage.read(key: _keyDeviceId);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
dio = Dio(BaseOptions(
|
dio = Dio(BaseOptions(
|
||||||
@@ -39,28 +44,72 @@ class ApiClient {
|
|||||||
));
|
));
|
||||||
|
|
||||||
dio.interceptors.add(InterceptorsWrapper(
|
dio.interceptors.add(InterceptorsWrapper(
|
||||||
onRequest: (options, handler) {
|
onRequest: (options, handler) async {
|
||||||
if (_deviceToken != null) {
|
if (_deviceId == null) {
|
||||||
options.headers['Authorization'] = 'Bearer $_deviceToken';
|
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);
|
handler.next(options);
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
String get baseUrl => _baseUrl;
|
/// Serialisiert options.data EINMALIG zu den exakten Bytes, die auch
|
||||||
bool get isEnrolled => _deviceToken != null && _deviceToken!.isNotEmpty;
|
/// 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;
|
_baseUrl = baseUrl;
|
||||||
_deviceToken = deviceToken;
|
_deviceId = deviceId;
|
||||||
dio.options.baseUrl = baseUrl;
|
dio.options.baseUrl = baseUrl;
|
||||||
await _storage.write(key: _keyBaseUrl, value: 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 {
|
Future<void> resetEnrollment() async {
|
||||||
_deviceToken = null;
|
_deviceId = null;
|
||||||
await _storage.delete(key: _keyDeviceToken);
|
await _storage.delete(key: _keyDeviceId);
|
||||||
|
await crypto.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
name: timemaster_kiosk
|
name: timemaster_kiosk
|
||||||
description: TimeMaster Kiosk – Stempeluhr für Android-Tablets hinter VPN
|
description: TimeMaster Kiosk – Stempeluhr für Android-Tablets hinter VPN
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.1.11+12
|
version: 0.2.0+13
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.3.0 <4.0.0'
|
sdk: '>=3.3.0 <4.0.0'
|
||||||
@@ -17,6 +17,7 @@ dependencies:
|
|||||||
mobile_scanner: ^6.0.2
|
mobile_scanner: ^6.0.2
|
||||||
open_filex: ^4.5.0
|
open_filex: ^4.5.0
|
||||||
path_provider: ^2.1.4
|
path_provider: ^2.1.4
|
||||||
|
cryptography: ^2.9.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in new issue
Block a user