import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:freezer/api/definitions.dart'; import 'package:freezer/settings.dart'; import 'package:freezer/ui/login_screen.dart'; import 'package:freezer/utils.dart'; import 'package:pointycastle/export.dart' as pc; import 'package:pointycastle/src/platform_check/platform_check.dart'; import 'package:encrypt/encrypt.dart' as encrypt; import 'package:freezer/translations.i18n.dart'; class LoginOnOtherDevice extends StatefulWidget { const LoginOnOtherDevice({super.key}); @override State createState() => _LoginOnOtherDeviceState(); } class _LoginOnOtherDeviceState extends State { String _ipAddress = ''; String _code = ''; String? _error; bool _loading = false; bool _step2 = false; final GlobalKey _formKey = GlobalKey(); late final encrypt.Key key; void _doHandshake() async { if (!_formKey.currentState!.validate()) return; setState(() { _loading = true; _error = null; }); // generate keypair final keyGen = pc.RSAKeyGenerator(); final secureRandom = pc.SecureRandom('Fortuna') ..seed(pc.KeyParameter( Platform.instance.platformEntropySource().getBytes(32))); keyGen.init(pc.ParametersWithRandom( pc.RSAKeyGeneratorParameters(BigInt.parse('65537'), 2048, 64), secureRandom)); final keyPair = keyGen.generateKeyPair(); final privKey = keyPair.privateKey as pc.RSAPrivateKey; final pubKey = keyPair.publicKey as pc.RSAPublicKey; // initial handshake final hash = Hmac(sha512, Utils.splitDigits(int.parse(_code))) .convert(Utils.serializeBigInt(pubKey.exponent! ^ pubKey.modulus!)) .toString(); late final Response res; try { res = await Dio().post('http://$_ipAddress/', data: jsonEncode({ '_': 'handshake', 'pubKey': [ base64.encode(Utils.serializeBigInt(pubKey.modulus!)), base64.encode(Utils.serializeBigInt(pubKey.exponent!)) ], 'hash': hash, }), options: Options(responseType: ResponseType.json)); } on DioException catch (e) { if (e.type == DioExceptionType.badResponse) { if (e.response?.statusCode == 403) { setState(() { _error = 'Wrong code'; _loading = false; }); return; } final data = e.response?.data as Map?; if (data != null && data['message'] is String) { setState(() { _error = 'Request failed: ${data['message']}'; _loading = false; }); return; } setState(() { _error = 'Request failed: $e'; _loading = false; }); return; } } print(res); final data = res.data as Map; if (!data['ok']) { setState(() { _error = data['message'] as String? ?? 'Unknown server error'; _loading = false; }); return; } final encryptedKey = data['key'] as String; final encrypter = encrypt.Encrypter(encrypt.RSA(privateKey: privKey)); key = encrypt.Key(Uint8List.fromList( encrypter.decryptBytes(encrypt.Encrypted.fromBase64(encryptedKey)))); setState(() { _loading = false; _step2 = true; }); } void _loginWithArl(String arl) async { setState(() { _loading = true; }); final encrypter = encrypt.Encrypter( encrypt.AES(key), ); final iv = encrypt.IV.fromSecureRandom(16); final encryptedARL = encrypter.encrypt(arl, iv: iv); // send it over with dio late final Response res; try { res = await Dio().post('http://$_ipAddress/', data: jsonEncode({ '_': 'arl', 'arl': encryptedARL.base64, 'iv': iv.base64, }), options: Options(responseType: ResponseType.json)); } on DioException catch (e) { if (e.type == DioExceptionType.badResponse) { final data = e.response?.data as Map?; if (data != null && data['message'] is String) { setState(() { _error = 'Request failed: ${data['message']}'; _loading = false; }); } } setState(() { _error = 'Request failed: $e'; _loading = false; }); return; } if (!res.data['ok']) { setState(() { _error = res.data['message'] as String? ?? 'Unknown server error.'; _loading = false; }); return; } Navigator.pop(context); ScaffoldMessenger.of(context).snack('Logged in successfully!'.i18n); } @override void dispose() async { if (_step2) { final hash = Hmac(sha512, key.bytes).convert(utf8.encode(_code)).toString(); await Dio().post('http://$_ipAddress/', data: jsonEncode({'_': 'cancel', 'hash': hash})); } super.dispose(); } @override Widget build(BuildContext context) { return AlertDialog( title: Text('Login on other device'.i18n), content: _step2 ? (_loading ? const CircularProgressIndicator() : Column(mainAxisSize: MainAxisSize.min, children: [ if (_error != null) Text(_error!, style: const TextStyle(color: Colors.red)), OutlinedButton( onPressed: () => _loginWithArl(settings.arl!), child: Text('Login with current ARL (one-click)'.i18n)), const SizedBox(height: 16.0), Text('Or, login with external ARL'.i18n), const SizedBox(height: 16.0), if (LoginBrowser.supported()) ...[ OutlinedButton( onPressed: () { Navigator.of(context).pushRoute( builder: (context) => LoginBrowser(_loginWithArl)); }, child: Text('Login using browser'.i18n)), const SizedBox(height: 16.0), ], OutlinedButton( onPressed: () { String arl = ''; final key = GlobalKey>(); showDialog( context: context, builder: (context) { submit(String arl) { if (!key.currentState!.validate()) return; Navigator.pop(context); _loginWithArl(arl); } return AlertDialog( title: Text('Enter ARL'.i18n), content: TextFormField( key: key, validator: (value) => value?.trim().length == 192 ? null : 'Invalid ARL length!'.i18n, onChanged: (value) => arl = value, decoration: InputDecoration( labelText: 'Token (ARL)'.i18n), onFieldSubmitted: submit, ), actions: [ TextButton( child: Text('Save'.i18n), onPressed: () => submit(arl), ) ], ); }); }, child: Text('Login with ARL'.i18n)), ])) : Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_error != null) Text(_error!, style: const TextStyle(color: Colors.red)), TextFormField( validator: (value) { value ??= ''; final p = value.split(':'); if (p.length != 2) { return 'Invalid IP and Port'.i18n; } final ip = p[0]; final port = int.tryParse(p[1]); if (port == null || port > 65535) { return 'Invalid port'.i18n; } final ipParts = ip.split('.'); if (ipParts.length != 4) { return 'Invalid IP'.i18n; } for (final part in ipParts) { final a = int.tryParse(part); if (a == null || a < 0 || a > 255) { return 'Invalid IP'.i18n; } } return null; }, onChanged: (value) => _ipAddress = value, decoration: InputDecoration(label: Text('IP Address'.i18n)), ), TextFormField( validator: (value) { value ??= ''; if (value.length != 6 || int.tryParse(value) == null) { return 'Invalid code'.i18n; } return null; }, onChanged: (value) => _code = value, decoration: InputDecoration(label: Text('Code'.i18n)), ) ]), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text('Cancel'.i18n)), if (!_step2) TextButton( onPressed: _loading ? null : _doHandshake, child: Text('Login'.i18n), ), ], ); } }