import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/core/errors.dart'; import 'package:ente_auth/core/event_bus.dart'; import 'package:ente_auth/events/codes_updated_event.dart'; import 'package:ente_auth/events/signed_in_event.dart'; import 'package:ente_auth/events/trigger_logout_event.dart'; import 'package:ente_auth/gateway/authenticator.dart'; import 'package:ente_auth/models/authenticator/auth_entity.dart'; import 'package:ente_auth/models/authenticator/auth_key.dart'; import 'package:ente_auth/models/authenticator/entity_result.dart'; import 'package:ente_auth/models/authenticator/local_auth_entity.dart'; import 'package:ente_auth/store/authenticator_db.dart'; import 'package:ente_auth/store/offline_authenticator_db.dart'; import 'package:ente_auth/utils/crypto_util.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_sodium/flutter_sodium.dart'; import 'package:logging/logging.dart'; import 'package:shared_preferences/shared_preferences.dart'; enum AccountMode { online, offline, } extension on AccountMode { bool get isOnline => this == AccountMode.online; bool get isOffline => this == AccountMode.offline; } class AuthenticatorService { final _logger = Logger((AuthenticatorService).toString()); final _config = Configuration.instance; late SharedPreferences _prefs; late AuthenticatorGateway _gateway; late AuthenticatorDB _db; late OfflineAuthenticatorDB _offlineDb; final String _lastEntitySyncTime = "lastEntitySyncTime"; AuthenticatorService._privateConstructor(); static final AuthenticatorService instance = AuthenticatorService._privateConstructor(); AccountMode getAccountMode() { return Configuration.instance.hasOptedForOfflineMode() && !Configuration.instance.hasConfiguredAccount() ? AccountMode.offline : AccountMode.online; } Future init() async { _prefs = await SharedPreferences.getInstance(); _db = AuthenticatorDB.instance; _offlineDb = OfflineAuthenticatorDB.instance; _gateway = AuthenticatorGateway(); if (Configuration.instance.hasConfiguredAccount()) { unawaited(onlineSync()); } Bus.instance.on().listen((event) { unawaited(onlineSync()); }); } Future> getEntities(AccountMode mode) async { final List result = mode.isOnline ? await _db.getAll() : await _offlineDb.getAll(); final List entities = []; if (result.isEmpty) { return entities; } final key = await getOrCreateAuthDataKey(mode); for (LocalAuthEntity e in result) { try { final decryptedValue = await CryptoUtil.decryptChaCha( Sodium.base642bin(e.encryptedData), key, Sodium.base642bin(e.header), ); final hasSynced = !(e.id == null || e.shouldSync); entities.add( EntityResult( e.generatedID, utf8.decode(decryptedValue), hasSynced, ), ); } catch (e, s) { _logger.severe(e, s); } } return entities; } Future addEntry( String plainText, bool shouldSync, AccountMode accountMode, ) async { var key = await getOrCreateAuthDataKey(accountMode); final encryptedKeyData = await CryptoUtil.encryptChaCha( utf8.encode(plainText) as Uint8List, key, ); String encryptedData = Sodium.bin2base64(encryptedKeyData.encryptedData!); String header = Sodium.bin2base64(encryptedKeyData.header!); final insertedID = accountMode.isOnline ? await _db.insert(encryptedData, header) : await _offlineDb.insert(encryptedData, header); if (shouldSync) { unawaited(onlineSync()); } return insertedID; } Future updateEntry( int generatedID, String plainText, bool shouldSync, AccountMode accountMode, ) async { var key = await getOrCreateAuthDataKey(accountMode); final encryptedKeyData = await CryptoUtil.encryptChaCha( utf8.encode(plainText) as Uint8List, key, ); String encryptedData = Sodium.bin2base64(encryptedKeyData.encryptedData!); String header = Sodium.bin2base64(encryptedKeyData.header!); final int affectedRows = accountMode.isOnline ? await _db.updateEntry(generatedID, encryptedData, header) : await _offlineDb.updateEntry(generatedID, encryptedData, header); assert( affectedRows == 1, "updateEntry should have updated exactly one row", ); if (shouldSync) { unawaited(onlineSync()); } } Future deleteEntry(int genID, AccountMode accountMode) async { LocalAuthEntity? result = accountMode.isOnline ? await _db.getEntryByID(genID) : await _offlineDb.getEntryByID(genID); if (result == null) { _logger.info("No entry found for given id"); return; } if (result.id != null && accountMode.isOnline) { await _gateway.deleteEntity(result.id!); } else { debugPrint("Skipping delete since account mode is offline"); } if (accountMode.isOnline) { await _db.deleteByIDs(generatedIDs: [genID]); } else { await _offlineDb.deleteByIDs(generatedIDs: [genID]); } } Future onlineSync() async { try { if (getAccountMode().isOffline) { debugPrint("Skipping sync since account mode is offline"); return false; } _logger.info("Sync"); await _remoteToLocalSync(); _logger.info("remote fetch completed"); await _localToRemoteSync(); _logger.info("local push completed"); Bus.instance.fire(CodesUpdatedEvent()); return true; } on UnauthorizedError { if ((await _db.removeSyncedData()) > 0) { Bus.instance.fire(CodesUpdatedEvent()); } debugPrint("Firing logout event"); Bus.instance.fire(TriggerLogoutEvent()); return false; } catch (e) { _logger.severe("Failed to sync with remote", e); return false; } } Future _remoteToLocalSync() async { _logger.info('Initiating remote to local sync'); final int lastSyncTime = _prefs.getInt(_lastEntitySyncTime) ?? 0; _logger.info("Current sync is " + lastSyncTime.toString()); const int fetchLimit = 500; final List result = await _gateway.getDiff(lastSyncTime, limit: fetchLimit); _logger.info(result.length.toString() + " entries fetched from remote"); if (result.isEmpty) { return; } final maxSyncTime = result.map((e) => e.updatedAt).reduce(max); List deletedIDs = result.where((element) => element.isDeleted).map((e) => e.id).toList(); _logger.info(deletedIDs.length.toString() + " entries deleted"); result.removeWhere((element) => element.isDeleted); await _db.insertOrReplace(result); if (deletedIDs.isNotEmpty) { await _db.deleteByIDs(ids: deletedIDs); } await _prefs.setInt(_lastEntitySyncTime, maxSyncTime); _logger.info("Setting synctime to " + maxSyncTime.toString()); if (result.length == fetchLimit) { _logger.info("Diff limit reached, pulling again"); await _remoteToLocalSync(); } } Future _localToRemoteSync() async { _logger.info('Initiating local to remote sync'); final List result = await _db.getAll(); final List pendingUpdate = result .where((element) => element.shouldSync || element.id == null) .toList(); _logger.info( pendingUpdate.length.toString() + " entries to be updated at remote", ); for (LocalAuthEntity entity in pendingUpdate) { if (entity.id == null) { _logger.info("Adding new entry"); final authEntity = await _gateway.createEntity(entity.encryptedData, entity.header); await _db.updateLocalEntity( entity.copyWith( id: authEntity.id, shouldSync: false, ), ); } else { _logger.info("Updating entry"); await _gateway.updateEntity( entity.id!, entity.encryptedData, entity.header, ); await _db.updateLocalEntity(entity.copyWith(shouldSync: false)); } } if (pendingUpdate.isNotEmpty) { _logger.info("Initiating remote sync since local entries were pushed"); await _remoteToLocalSync(); } } Future getOrCreateAuthDataKey(AccountMode mode) async { if (mode.isOffline) { return _config.getOfflineSecretKey()!; } if (_config.getAuthSecretKey() != null) { return _config.getAuthSecretKey()!; } try { final AuthKey response = await _gateway.getKey(); final authKey = CryptoUtil.decryptSync( Sodium.base642bin(response.encryptedKey), _config.getKey()!, Sodium.base642bin(response.header), ); await _config.setAuthSecretKey(Sodium.bin2base64(authKey)); return authKey; } on AuthenticatorKeyNotFound catch (e) { _logger.info("AuthenticatorKeyNotFound generating key ${e.stackTrace}"); final key = CryptoUtil.generateKey(); final encryptedKeyData = CryptoUtil.encryptSync(key, _config.getKey()!); await _gateway.createKey( Sodium.bin2base64(encryptedKeyData.encryptedData!), Sodium.bin2base64(encryptedKeyData.nonce!), ); await _config.setAuthSecretKey(Sodium.bin2base64(key)); return key; } catch (e, s) { _logger.severe("Failed to getOrCreateAuthDataKey", e, s); rethrow; } } }