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,
|
||||
/// 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 'qr_enrollment_scan_screen.dart';
|
||||
|
||||
/// Einmalige Ersteinrichtung: Server-URL + Bearer-Token eintragen, die per
|
||||
/// `timemaster kiosk add --auth-mode bearer` (CLI) oder im Portal
|
||||
/// (KioskDevicesPage → Bearer-Gerät anlegen) erzeugt wurden. Portal zeigt
|
||||
/// dafür zusätzlich einen QR-Code an, den diese Seite scannen kann – spart
|
||||
/// das Abtippen des Tokens. Kein Keypair (VPN übernimmt Transport-
|
||||
/// Sicherheit, siehe Plan Android-Tablet-Kiosk 2026-09-10).
|
||||
/// 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});
|
||||
|
||||
@@ -19,10 +19,27 @@ class EnrollmentScreen extends StatefulWidget {
|
||||
|
||||
class _EnrollmentScreenState extends State<EnrollmentScreen> {
|
||||
final _urlCtrl = TextEditingController(text: ApiClient.instance.baseUrl);
|
||||
final _tokenCtrl = TextEditingController();
|
||||
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()),
|
||||
@@ -30,14 +47,14 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
|
||||
if (result == null || !mounted) return;
|
||||
setState(() {
|
||||
_urlCtrl.text = result['url'] ?? _urlCtrl.text;
|
||||
_tokenCtrl.text = result['token'] ?? '';
|
||||
_deviceIdCtrl.text = result['deviceId'] ?? result['device_id'] ?? '';
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_urlCtrl.text.trim().isEmpty || _tokenCtrl.text.trim().isEmpty) {
|
||||
setState(() => _error = 'Server-URL und Token sind Pflichtfelder.');
|
||||
if (_urlCtrl.text.trim().isEmpty || _deviceIdCtrl.text.trim().isEmpty) {
|
||||
setState(() => _error = 'Server-URL und Geräte-ID sind Pflichtfelder.');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
@@ -46,7 +63,7 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
|
||||
});
|
||||
await ApiClient.instance.enroll(
|
||||
baseUrl: _urlCtrl.text.trim(),
|
||||
deviceToken: _tokenCtrl.text.trim(),
|
||||
deviceId: _deviceIdCtrl.text.trim(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pushReplacement(
|
||||
@@ -61,7 +78,7 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Padding(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -69,12 +86,14 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
|
||||
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: 8),
|
||||
const SizedBox(height: 16),
|
||||
_publicKeyCard(),
|
||||
const SizedBox(height: 24),
|
||||
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,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
@@ -96,9 +115,8 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _tokenCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Bearer-Token'),
|
||||
obscureText: true,
|
||||
controller: _deviceIdCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Geräte-ID (UUID aus dem Portal)'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
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 {
|
||||
final data = jsonDecode(raw) as Map<String, dynamic>;
|
||||
final url = data['url'] as String?;
|
||||
final token = data['token'] as String?;
|
||||
if (url == null || url.isEmpty || token == null || token.isEmpty) {
|
||||
setState(() => _error = 'QR-Code enthält nicht die erwarteten Felder (url, token).');
|
||||
final deviceId = (data['device_id'] ?? data['deviceId']) as String?;
|
||||
if (url == null || url.isEmpty || deviceId == null || deviceId.isEmpty) {
|
||||
setState(() => _error = 'QR-Code enthält nicht die erwarteten Felder (url, device_id).');
|
||||
return;
|
||||
}
|
||||
_handled = true;
|
||||
Navigator.of(context).pop({'url': url, 'token': token});
|
||||
Navigator.of(context).pop({'url': url, 'deviceId': deviceId});
|
||||
} catch (_) {
|
||||
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:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'kiosk_crypto_service.dart';
|
||||
|
||||
/// Kiosk-Geräte-Auth: einfaches Bearer-Token (key_algorithm="bearer_vpn" im
|
||||
/// Backend, siehe backend/app/core/kiosk_security.py). Kein Request-Signing
|
||||
/// wie beim Browser-PC-Terminal (Ed25519) – Sicherheit kommt vom WireGuard-
|
||||
/// VPN + IP-Whitelist, nicht vom Client. Token wird per
|
||||
/// `timemaster kiosk add --auth-mode bearer` einmalig generiert und hier
|
||||
/// bei der Ersteinrichtung eingetragen.
|
||||
/// Kiosk-Geräte-Auth: Ed25519-Request-Signing, identisch zum Schema der
|
||||
/// Browser-PC-Terminals (siehe backend/app/core/kiosk_security.py). Nach
|
||||
/// dem Security-Audit vom 2026-09-10 wurde der reine Bearer-Token-Modus
|
||||
/// verworfen – ohne TLS war ein mitgeschnittenes Token unbegrenzt gültig
|
||||
/// und firmenweit einlösbar. Signierte Requests sind auch ohne TLS/VPN
|
||||
/// wertlos für einen reinen Netzwerk-Mitleser (Timestamp+Nonce+Body-Hash
|
||||
/// signiert, private Schlüssel verlässt nie dieses Gerät).
|
||||
class ApiClient {
|
||||
ApiClient._internal();
|
||||
static final ApiClient instance = ApiClient._internal();
|
||||
|
||||
static const _storage = FlutterSecureStorage();
|
||||
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;
|
||||
String _baseUrl = 'https://timemaster.example.com/api/v1';
|
||||
String? _deviceToken;
|
||||
String? _deviceId;
|
||||
|
||||
Future<void> init() async {
|
||||
// Storage-Reads bewusst einzeln try/catch – wenn z.B. der Android-
|
||||
// Keystore beim Kaltstart noch nicht bereit ist, soll `dio` trotzdem
|
||||
// konstruiert werden (sonst LateInitializationError beim ersten Zugriff,
|
||||
// z.B. in enroll() oder KioskService).
|
||||
// konstruiert werden (sonst LateInitializationError beim ersten Zugriff).
|
||||
try {
|
||||
final savedUrl = await _storage.read(key: _keyBaseUrl);
|
||||
if (savedUrl != null && savedUrl.isNotEmpty) _baseUrl = savedUrl;
|
||||
} catch (_) {}
|
||||
try {
|
||||
_deviceToken = await _storage.read(key: _keyDeviceToken);
|
||||
_deviceId = await _storage.read(key: _keyDeviceId);
|
||||
} catch (_) {}
|
||||
|
||||
dio = Dio(BaseOptions(
|
||||
@@ -39,28 +44,72 @@ class ApiClient {
|
||||
));
|
||||
|
||||
dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
if (_deviceToken != null) {
|
||||
options.headers['Authorization'] = 'Bearer $_deviceToken';
|
||||
onRequest: (options, handler) async {
|
||||
if (_deviceId == null) {
|
||||
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);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
String get baseUrl => _baseUrl;
|
||||
bool get isEnrolled => _deviceToken != null && _deviceToken!.isNotEmpty;
|
||||
/// Serialisiert options.data EINMALIG zu den exakten Bytes, die auch
|
||||
/// 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;
|
||||
_deviceToken = deviceToken;
|
||||
_deviceId = deviceId;
|
||||
dio.options.baseUrl = 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 {
|
||||
_deviceToken = null;
|
||||
await _storage.delete(key: _keyDeviceToken);
|
||||
_deviceId = null;
|
||||
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
|
||||
description: TimeMaster Kiosk – Stempeluhr für Android-Tablets hinter VPN
|
||||
publish_to: 'none'
|
||||
version: 0.1.11+12
|
||||
version: 0.2.0+13
|
||||
|
||||
environment:
|
||||
sdk: '>=3.3.0 <4.0.0'
|
||||
@@ -17,6 +17,7 @@ dependencies:
|
||||
mobile_scanner: ^6.0.2
|
||||
open_filex: ^4.5.0
|
||||
path_provider: ^2.1.4
|
||||
cryptography: ^2.9.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in new issue
Block a user