feat: Implement note encryption and synchronization features
- Added NoteEncryption class for encrypting and decrypting note content using AES-GCM. - Updated NoteRepository to handle synchronization of notes and categories with the server, including encryption of note data before sending. - Introduced SyncRequest and SyncResponse models for managing synchronization data. - Enhanced LocalVaultService to store and retrieve the encryption key. - Modified HomeScreen and SettingsScreen to trigger synchronization after note operations and manage API endpoint settings. - Added SyncStatusIndicator to provide visual feedback on synchronization status in the app title bar. - Created Category model to manage note categories with encryption support. - Updated note model to include UUID, server version, deletion status, and category ID. - Added necessary UI elements for displaying and managing the encryption key in SettingsScreen. - Updated dependencies in pubspec.yaml for cryptography and HTTP handling.
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:crypto/crypto.dart' as crypto;
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:notas/data/sync_models.dart';
|
||||
|
||||
class ApiConfig {
|
||||
ApiConfig._();
|
||||
|
||||
static const String _endpointKey = 'api_endpoint_v1';
|
||||
|
||||
/// Default endpoint for local development. Can be overridden by user.
|
||||
static const String defaultEndpoint = 'http://localhost:3000/api';
|
||||
|
||||
static Future<String> getEndpoint() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_endpointKey) ?? defaultEndpoint;
|
||||
}
|
||||
|
||||
static Future<void> setEndpoint(String endpoint) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_endpointKey, endpoint);
|
||||
}
|
||||
|
||||
static Future<void> clearEndpoint() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_endpointKey);
|
||||
}
|
||||
}
|
||||
|
||||
class AuthApi {
|
||||
AuthApi._();
|
||||
|
||||
static final AuthApi instance = AuthApi._();
|
||||
|
||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||
|
||||
static const String _accessTokenKey = 'api_access_token_v1';
|
||||
static const String _refreshTokenKey = 'api_refresh_token_v1';
|
||||
static const int _passwordHashVersion = 1;
|
||||
static const int _kdfIterations = 100000;
|
||||
static final Pbkdf2 _kdf = Pbkdf2(
|
||||
macAlgorithm: Hmac.sha256(),
|
||||
iterations: _kdfIterations,
|
||||
bits: 256,
|
||||
);
|
||||
static final AesGcm _aes = AesGcm.with256bits();
|
||||
|
||||
Future<String?> get accessToken async =>
|
||||
await _secureStorage.read(key: _accessTokenKey);
|
||||
|
||||
Future<String?> get refreshToken async =>
|
||||
await _secureStorage.read(key: _refreshTokenKey);
|
||||
|
||||
Future<void> clearTokens() async {
|
||||
await _secureStorage.delete(key: _accessTokenKey);
|
||||
await _secureStorage.delete(key: _refreshTokenKey);
|
||||
}
|
||||
|
||||
String hashPassword(String password) {
|
||||
return crypto.sha256.convert(utf8.encode(password)).toString();
|
||||
}
|
||||
|
||||
Future<String> encryptWithPassword(
|
||||
String plaintext,
|
||||
String password,
|
||||
) async {
|
||||
final List<int> salt = _randomBytes(16);
|
||||
final SecretKey secretKey = await _kdf.deriveKey(
|
||||
secretKey: SecretKey(utf8.encode(password)),
|
||||
nonce: salt,
|
||||
);
|
||||
|
||||
final List<int> nonce = _randomBytes(12);
|
||||
final SecretBox box = await _aes.encrypt(
|
||||
utf8.encode(plaintext),
|
||||
secretKey: secretKey,
|
||||
nonce: nonce,
|
||||
);
|
||||
|
||||
return jsonEncode(<String, dynamic>{
|
||||
'v': _passwordHashVersion,
|
||||
'salt': base64Encode(salt),
|
||||
'nonce': base64Encode(box.nonce),
|
||||
'cipherText': base64Encode(box.cipherText),
|
||||
'mac': base64Encode(box.mac.bytes),
|
||||
});
|
||||
}
|
||||
|
||||
Future<String> decryptWithPassword(
|
||||
String encodedBox,
|
||||
String password,
|
||||
) async {
|
||||
final Map<String, dynamic> payload = jsonDecode(encodedBox) as Map<String, dynamic>;
|
||||
final List<int> salt = base64Decode(payload['salt'] as String);
|
||||
final List<int> nonce = base64Decode(payload['nonce'] as String);
|
||||
final List<int> cipherText = base64Decode(payload['cipherText'] as String);
|
||||
final List<int> macBytes = base64Decode(payload['mac'] as String);
|
||||
|
||||
final SecretKey secretKey = await _kdf.deriveKey(
|
||||
secretKey: SecretKey(utf8.encode(password)),
|
||||
nonce: salt,
|
||||
);
|
||||
|
||||
final SecretBox box = SecretBox(
|
||||
cipherText,
|
||||
nonce: nonce,
|
||||
mac: Mac(macBytes),
|
||||
);
|
||||
|
||||
final List<int> clearText = await _aes.decrypt(
|
||||
box,
|
||||
secretKey: secretKey,
|
||||
);
|
||||
|
||||
return utf8.decode(clearText);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> login(
|
||||
String username,
|
||||
String password, {
|
||||
String? deviceName,
|
||||
String? endpoint,
|
||||
}) async {
|
||||
final String base = endpoint ?? await ApiConfig.getEndpoint();
|
||||
final Uri url = Uri.parse('$base/auth/login');
|
||||
|
||||
final Map<String, dynamic> body = {
|
||||
'username': username,
|
||||
'password': hashPassword(password),
|
||||
};
|
||||
|
||||
if (deviceName != null) body['deviceName'] = deviceName;
|
||||
|
||||
final http.Response res = await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
final Map<String, dynamic> json =
|
||||
jsonDecode(res.body) as Map<String, dynamic>;
|
||||
|
||||
final String? access = json['accessToken'] as String?;
|
||||
final String? refresh = json['refreshToken'] as String?;
|
||||
|
||||
if (access != null) {
|
||||
await _secureStorage.write(key: _accessTokenKey, value: access);
|
||||
}
|
||||
|
||||
if (refresh != null) {
|
||||
await _secureStorage.write(key: _refreshTokenKey, value: refresh);
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
// Try to decode error body for better diagnostics.
|
||||
try {
|
||||
final dynamic decoded = jsonDecode(res.body);
|
||||
return {'error': true, 'status': res.statusCode, 'body': decoded};
|
||||
} catch (_) {
|
||||
return {'error': true, 'status': res.statusCode, 'body': res.body};
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> register(
|
||||
String username,
|
||||
String password, {
|
||||
String? encryptedMasterKey,
|
||||
String? endpoint,
|
||||
}) async {
|
||||
final String base = endpoint ?? await ApiConfig.getEndpoint();
|
||||
final Uri url = Uri.parse('$base/auth/register');
|
||||
|
||||
final Map<String, dynamic> body = {
|
||||
'username': username,
|
||||
'password': hashPassword(password),
|
||||
'encrypted_master_key': encryptedMasterKey,
|
||||
};
|
||||
|
||||
final http.Response res = await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
final Map<String, dynamic> json =
|
||||
jsonDecode(res.body) as Map<String, dynamic>;
|
||||
|
||||
final String? access = json['accessToken'] as String?;
|
||||
final String? refresh = json['refreshToken'] as String?;
|
||||
|
||||
if (access != null) {
|
||||
await _secureStorage.write(key: _accessTokenKey, value: access);
|
||||
}
|
||||
|
||||
if (refresh != null) {
|
||||
await _secureStorage.write(key: _refreshTokenKey, value: refresh);
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
try {
|
||||
final dynamic decoded = jsonDecode(res.body);
|
||||
return {'error': true, 'status': res.statusCode, 'body': decoded};
|
||||
} catch (_) {
|
||||
return {'error': true, 'status': res.statusCode, 'body': res.body};
|
||||
}
|
||||
}
|
||||
|
||||
List<int> _randomBytes(int length) {
|
||||
final Random random = Random.secure();
|
||||
return List<int>.generate(length, (_) => random.nextInt(256));
|
||||
}
|
||||
|
||||
// ========== Sync API ==========
|
||||
|
||||
static const String _lastSyncAtKey = 'api_last_sync_at_v1';
|
||||
|
||||
Future<DateTime?> getLastSyncAt() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final String? timestamp = prefs.getString(_lastSyncAtKey);
|
||||
if (timestamp == null) return null;
|
||||
return DateTime.tryParse(timestamp);
|
||||
}
|
||||
|
||||
Future<void> setLastSyncAt(DateTime timestamp) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_lastSyncAtKey, timestamp.toIso8601String());
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> sync(
|
||||
SyncRequest syncRequest, {
|
||||
String? endpoint,
|
||||
}) async {
|
||||
final String? token = await accessToken;
|
||||
if (token == null) {
|
||||
return {'error': true, 'message': 'No access token available'};
|
||||
}
|
||||
|
||||
final String base = endpoint ?? await ApiConfig.getEndpoint();
|
||||
final Uri url = Uri.parse('$base/sync');
|
||||
|
||||
final Map<String, String> headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
};
|
||||
|
||||
final String bodyJson = jsonEncode(syncRequest.toJson());
|
||||
|
||||
// Log request (mask authorization header)
|
||||
final Map<String, String> logHeaders = Map.from(headers);
|
||||
if (logHeaders.containsKey('Authorization')) {
|
||||
logHeaders['Authorization'] = 'REDACTED';
|
||||
}
|
||||
debugPrint('SYNC REQUEST -> POST $url');
|
||||
debugPrint('Headers: $logHeaders');
|
||||
debugPrint('Body: $bodyJson');
|
||||
|
||||
final http.Response res = await http.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: bodyJson,
|
||||
);
|
||||
|
||||
// Log response
|
||||
debugPrint('SYNC RESPONSE <- ${res.statusCode}');
|
||||
debugPrint('Response body: ${res.body}');
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
final Map<String, dynamic> json =
|
||||
jsonDecode(res.body) as Map<String, dynamic>;
|
||||
return {'error': false, 'data': SyncResponse.fromJson(json)};
|
||||
}
|
||||
|
||||
// If token expired (401), try to refresh
|
||||
if (res.statusCode == 401) {
|
||||
final String? refreshTok = await refreshToken;
|
||||
if (refreshTok != null) {
|
||||
final bool refreshed = await _refreshAccessToken(refreshTok, endpoint: endpoint);
|
||||
if (refreshed) {
|
||||
// Retry sync with new token
|
||||
return sync(syncRequest, endpoint: endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to decode error body for better diagnostics.
|
||||
try {
|
||||
final dynamic decoded = jsonDecode(res.body);
|
||||
return {'error': true, 'status': res.statusCode, 'body': decoded};
|
||||
} catch (_) {
|
||||
return {'error': true, 'status': res.statusCode, 'body': res.body};
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _refreshAccessToken(
|
||||
String refreshToken, {
|
||||
String? endpoint,
|
||||
}) async {
|
||||
final String base = endpoint ?? await ApiConfig.getEndpoint();
|
||||
final Uri url = Uri.parse('$base/auth/refresh');
|
||||
|
||||
final http.Response res = await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'refreshToken': refreshToken}),
|
||||
);
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
final Map<String, dynamic> json =
|
||||
jsonDecode(res.body) as Map<String, dynamic>;
|
||||
final String? newAccess = json['accessToken'] as String?;
|
||||
|
||||
if (newAccess != null) {
|
||||
await _secureStorage.write(key: _accessTokenKey, value: newAccess);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+62
-19
@@ -7,39 +7,70 @@ import 'package:path_provider/path_provider.dart';
|
||||
|
||||
part 'app_database.g.dart';
|
||||
|
||||
@DataClassName('DbCategory')
|
||||
class Categories extends Table {
|
||||
TextColumn get uuid => text().unique()();
|
||||
TextColumn get encryptedName => text().named('encrypted_name')();
|
||||
IntColumn get serverVersion => integer().named('server_version').withDefault(const Constant(0))();
|
||||
BoolColumn get isDeleted => boolean().named('is_deleted').withDefault(const Constant(false))();
|
||||
DateTimeColumn get updatedAt => dateTime().named('updated_at')();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {uuid};
|
||||
}
|
||||
|
||||
@DataClassName('DbNote')
|
||||
class Notes extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get uuid => text().unique()();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get body => text()();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
DateTimeColumn get createdAt => dateTime().named('created_at')();
|
||||
DateTimeColumn get updatedAt => dateTime().named('updated_at')();
|
||||
IntColumn get sortIndex => integer().named('sort_index')();
|
||||
IntColumn get serverVersion => integer().named('server_version').withDefault(const Constant(0))();
|
||||
BoolColumn get isDeleted => boolean().named('is_deleted').withDefault(const Constant(false))();
|
||||
TextColumn get categoryId => text().nullable().named('category_id')();
|
||||
}
|
||||
|
||||
@DriftDatabase(tables: [Notes])
|
||||
@DriftDatabase(tables: [Notes, Categories])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase({required String encryptionKey}) : super(_openConnection(encryptionKey));
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
AppDatabase({required String encryptionKey}) : super(_openConnection(encryptionKey));
|
||||
|
||||
// ========== Categories ==========
|
||||
Future<List<DbCategory>> getAllCategories() {
|
||||
return (select(categories)..where((c) => c.isDeleted.equals(false))).get();
|
||||
}
|
||||
|
||||
Future<int> upsertCategory(CategoriesCompanion category) {
|
||||
return into(categories).insertOnConflictUpdate(category);
|
||||
}
|
||||
|
||||
Future<void> deleteCategory(String uuid) {
|
||||
return (update(categories)..where((c) => c.uuid.equals(uuid)))
|
||||
.write(CategoriesCompanion(isDeleted: Value(true)));
|
||||
}
|
||||
|
||||
// ========== Notes ==========
|
||||
Future<List<DbNote>> getAllNotes() {
|
||||
return (select(notes)..orderBy([
|
||||
(note) => OrderingTerm(expression: note.sortIndex),
|
||||
])).get();
|
||||
return (select(notes)
|
||||
..orderBy([(note) => OrderingTerm(expression: note.sortIndex)])
|
||||
..where((n) => n.isDeleted.equals(false)))
|
||||
.get();
|
||||
}
|
||||
|
||||
Future<int> insertNoteAtTop(NotesCompanion note) {
|
||||
return transaction(() async {
|
||||
await customStatement('UPDATE notes SET sort_index = sort_index + 1');
|
||||
await customStatement('UPDATE notes SET sort_index = sort_index + 1 WHERE is_deleted = 0');
|
||||
return into(notes).insert(note.copyWith(sortIndex: const Value<int>(0)));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> replaceAllNotes(List<NotesCompanion> noteList) {
|
||||
return transaction(() async {
|
||||
await delete(notes).go();
|
||||
await (delete(notes)..where((n) => n.isDeleted.equals(false))).go();
|
||||
|
||||
for (final NotesCompanion note in noteList) {
|
||||
await into(notes).insert(note);
|
||||
@@ -51,17 +82,20 @@ class AppDatabase extends _$AppDatabase {
|
||||
return update(notes).replace(note);
|
||||
}
|
||||
|
||||
Future<void> deleteNote(int id, int removedIndex) async {
|
||||
await (update(notes)..where((n) => n.id.equals(id))).write(NotesCompanion(isDeleted: Value(true)));
|
||||
|
||||
await customStatement(
|
||||
'UPDATE notes SET sort_index = sort_index - 1 WHERE sort_index > ? AND is_deleted = 0',
|
||||
[removedIndex],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteNoteAndShift({
|
||||
required int id,
|
||||
required int removedIndex,
|
||||
}) {
|
||||
return transaction(() async {
|
||||
await (delete(notes)..where((note) => note.id.equals(id))).go();
|
||||
await customStatement(
|
||||
'UPDATE notes SET sort_index = sort_index - 1 WHERE sort_index > ?',
|
||||
[removedIndex],
|
||||
);
|
||||
});
|
||||
return deleteNote(id, removedIndex);
|
||||
}
|
||||
|
||||
Future<void> moveNote({
|
||||
@@ -76,12 +110,12 @@ class AppDatabase extends _$AppDatabase {
|
||||
return transaction(() async {
|
||||
if (oldIndex < newIndex) {
|
||||
await customStatement(
|
||||
'UPDATE notes SET sort_index = sort_index - 1 WHERE sort_index > ? AND sort_index <= ?',
|
||||
'UPDATE notes SET sort_index = sort_index - 1 WHERE sort_index > ? AND sort_index <= ? AND is_deleted = 0',
|
||||
[oldIndex, newIndex],
|
||||
);
|
||||
} else {
|
||||
await customStatement(
|
||||
'UPDATE notes SET sort_index = sort_index + 1 WHERE sort_index >= ? AND sort_index < ?',
|
||||
'UPDATE notes SET sort_index = sort_index + 1 WHERE sort_index >= ? AND sort_index < ? AND is_deleted = 0',
|
||||
[newIndex, oldIndex],
|
||||
);
|
||||
}
|
||||
@@ -92,6 +126,15 @@ class AppDatabase extends _$AppDatabase {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Sync helpers ==========
|
||||
Future<List<DbNote>> getUnsyncedNotes() {
|
||||
return (select(notes)..where((n) => n.isDeleted.equals(true) | n.serverVersion.equals(0))).get();
|
||||
}
|
||||
|
||||
Future<List<DbCategory>> getUnsyncedCategories() {
|
||||
return (select(categories)..where((c) => c.isDeleted.equals(true) | c.serverVersion.equals(0))).get();
|
||||
}
|
||||
}
|
||||
|
||||
LazyDatabase _openConnection(String encryptionKey) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -74,6 +74,14 @@ class LocalVaultService {
|
||||
return _secureStorage.read(key: _encryptionKeyStorageKey);
|
||||
}
|
||||
|
||||
Future<void> storeEncryptionKey(String encryptionKey) async {
|
||||
await _secureStorage.write(key: _encryptionKeyStorageKey, value: encryptionKey);
|
||||
}
|
||||
|
||||
String generateEncryptionKey() {
|
||||
return _generateEncryptionKey();
|
||||
}
|
||||
|
||||
Future<bool> hasEncryptionKey() async {
|
||||
return (await readStoredEncryptionKeyRaw()) != null;
|
||||
}
|
||||
@@ -117,10 +125,7 @@ class LocalVaultService {
|
||||
Future<String> createEncryptionKey({bool protectWithBiometrics = false}) async {
|
||||
final String encryptionKey = _generateEncryptionKey();
|
||||
|
||||
await _secureStorage.write(
|
||||
key: _encryptionKeyStorageKey,
|
||||
value: encryptionKey,
|
||||
);
|
||||
await storeEncryptionKey(encryptionKey);
|
||||
|
||||
// If requested, try to enable biometric protection. Only enable on mobile
|
||||
// platforms and only if the authentication succeeds.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
|
||||
/// Encriptación de contenido de notas usando AES-GCM
|
||||
/// Usa una clave derivada del master key del usuario
|
||||
class NoteEncryption {
|
||||
static final AesGcm _aes = AesGcm.with256bits();
|
||||
static const int _passwordHashVersion = 1;
|
||||
static const int _kdfIterations = 100000;
|
||||
static final Pbkdf2 _kdf = Pbkdf2(
|
||||
macAlgorithm: Hmac.sha256(),
|
||||
iterations: _kdfIterations,
|
||||
bits: 256,
|
||||
);
|
||||
|
||||
/// Encripta el contenido de una nota usando el master key
|
||||
/// Retorna el contenido encriptado en formato JSON base64
|
||||
static Future<String> encryptNote(
|
||||
String plaintext,
|
||||
String masterKey,
|
||||
) async {
|
||||
final List<int> salt = _randomBytes(16);
|
||||
final SecretKey secretKey = await _kdf.deriveKey(
|
||||
secretKey: SecretKey(utf8.encode(masterKey)),
|
||||
nonce: salt,
|
||||
);
|
||||
|
||||
final List<int> nonce = _randomBytes(12);
|
||||
final SecretBox box = await _aes.encrypt(
|
||||
utf8.encode(plaintext),
|
||||
secretKey: secretKey,
|
||||
nonce: nonce,
|
||||
);
|
||||
|
||||
return jsonEncode(<String, dynamic>{
|
||||
'v': _passwordHashVersion,
|
||||
'salt': base64Encode(salt),
|
||||
'nonce': base64Encode(box.nonce),
|
||||
'cipherText': base64Encode(box.cipherText),
|
||||
'mac': base64Encode(box.mac.bytes),
|
||||
});
|
||||
}
|
||||
|
||||
/// Desencripta el contenido de una nota usando el master key
|
||||
static Future<String> decryptNote(
|
||||
String encodedBox,
|
||||
String masterKey,
|
||||
) async {
|
||||
try {
|
||||
final Map<String, dynamic> payload =
|
||||
jsonDecode(encodedBox) as Map<String, dynamic>;
|
||||
final List<int> salt = base64Decode(payload['salt'] as String);
|
||||
final List<int> nonce = base64Decode(payload['nonce'] as String);
|
||||
final List<int> cipherText =
|
||||
base64Decode(payload['cipherText'] as String);
|
||||
final List<int> macBytes = base64Decode(payload['mac'] as String);
|
||||
|
||||
final SecretKey secretKey = await _kdf.deriveKey(
|
||||
secretKey: SecretKey(utf8.encode(masterKey)),
|
||||
nonce: salt,
|
||||
);
|
||||
|
||||
final SecretBox box = SecretBox(
|
||||
cipherText,
|
||||
nonce: nonce,
|
||||
mac: Mac(macBytes),
|
||||
);
|
||||
|
||||
final List<int> clearText = await _aes.decrypt(
|
||||
box,
|
||||
secretKey: secretKey,
|
||||
);
|
||||
|
||||
return utf8.decode(clearText);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to decrypt note: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static List<int> _randomBytes(int length) {
|
||||
final Random random = Random.secure();
|
||||
return List<int>.generate(length, (_) => random.nextInt(256));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,23 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:notas/data/app_database.dart';
|
||||
import 'package:notas/data/api_client.dart';
|
||||
import 'package:notas/data/sync_models.dart';
|
||||
import 'package:notas/models/note.dart';
|
||||
import 'package:notas/models/category.dart';
|
||||
|
||||
import 'package:notas/data/note_encryption.dart';
|
||||
class NoteRepository {
|
||||
NoteRepository({required AppDatabase database}) : _database = database;
|
||||
NoteRepository({
|
||||
required AppDatabase database,
|
||||
required AuthApi authApi,
|
||||
required String masterKey,
|
||||
}) : _database = database,
|
||||
_authApi = authApi,
|
||||
_masterKey = masterKey;
|
||||
|
||||
final AppDatabase _database;
|
||||
final AuthApi _authApi;
|
||||
final String _masterKey;
|
||||
|
||||
Future<List<Note>> loadNotes() async {
|
||||
return _loadNotesFromDatabase();
|
||||
@@ -13,11 +26,15 @@ class NoteRepository {
|
||||
Future<Note> createNote(Note note) async {
|
||||
final int id = await _database.insertNoteAtTop(
|
||||
NotesCompanion.insert(
|
||||
uuid: note.uuid,
|
||||
title: note.title,
|
||||
body: note.body,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt,
|
||||
sortIndex: 0,
|
||||
serverVersion: const Value(0),
|
||||
isDeleted: const Value(false),
|
||||
categoryId: const Value(null),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -30,11 +47,15 @@ class NoteRepository {
|
||||
await _database.updateNoteRow(
|
||||
DbNote(
|
||||
id: noteId,
|
||||
uuid: note.uuid,
|
||||
title: note.title,
|
||||
body: note.body,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt,
|
||||
sortIndex: note.index,
|
||||
serverVersion: note.serverVersion,
|
||||
isDeleted: note.isDeleted,
|
||||
categoryId: note.categoryId,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -60,19 +81,176 @@ class NoteRepository {
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Note>> _loadNotesFromDatabase() async {
|
||||
final List<DbNote> rows = await _database.getAllNotes();
|
||||
return rows.map(_fromRow).toList();
|
||||
// ========== Sync logic ==========
|
||||
|
||||
/// Sincroniza notas con el servidor.
|
||||
/// Requiere que el usuario esté autenticado (token válido).
|
||||
Future<Map<String, dynamic>> performSync({bool forceFull = false}) async {
|
||||
try {
|
||||
// Get last sync timestamp
|
||||
final DateTime? lastSync = await _authApi.getLastSyncAt();
|
||||
final DateTime? lastSyncForRequest = forceFull ? DateTime.utc(1970, 1, 1) : lastSync;
|
||||
|
||||
// Collect pending changes
|
||||
final List<DbNote> unsyncedNotes = await _database.getUnsyncedNotes();
|
||||
final List<DbCategory> unsyncedCategories = await _database.getUnsyncedCategories();
|
||||
|
||||
// Build sync request (note: we send encrypted data, but locally we have plaintext)
|
||||
// Encrypt all notes before sending
|
||||
final List<SyncNotePayload> encryptedNotesPayload = [];
|
||||
for (final dbNote in unsyncedNotes) {
|
||||
final note = _fromDbNote(dbNote);
|
||||
final encryptedTitle = await NoteEncryption.encryptNote(note.title, _masterKey);
|
||||
final encryptedBody = await NoteEncryption.encryptNote(note.body, _masterKey);
|
||||
encryptedNotesPayload.add(
|
||||
SyncNotePayload.fromNote(
|
||||
note,
|
||||
encryptedTitle: encryptedTitle,
|
||||
encryptedBody: encryptedBody,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final List<SyncCategoryPayload> categoriesPayload = unsyncedCategories
|
||||
.map((cat) => SyncCategoryPayload.fromCategory(
|
||||
_fromDbCategory(cat),
|
||||
))
|
||||
.toList();
|
||||
|
||||
final SyncRequest syncRequest = SyncRequest(
|
||||
lastSyncAt: lastSyncForRequest,
|
||||
changes: SyncChanges(
|
||||
categories: categoriesPayload,
|
||||
notes: encryptedNotesPayload,
|
||||
),
|
||||
);
|
||||
|
||||
// Call sync API
|
||||
final Map<String, dynamic> syncResult =
|
||||
await _authApi.sync(syncRequest);
|
||||
|
||||
if (syncResult['error'] == true) {
|
||||
return {'error': true, 'message': syncResult['body']};
|
||||
}
|
||||
|
||||
final SyncResponse response = syncResult['data'] as SyncResponse;
|
||||
|
||||
// Apply server changes to local database
|
||||
await _applySyncResponse(response);
|
||||
|
||||
// Update lastSyncAt
|
||||
await _authApi.setLastSyncAt(response.serverTimestamp);
|
||||
|
||||
return {
|
||||
'error': false,
|
||||
'synced': response.synced,
|
||||
'notesCount': response.changes.notes.length,
|
||||
'categoriesCount': response.changes.categories.length,
|
||||
};
|
||||
} catch (e) {
|
||||
return {'error': true, 'message': e.toString()};
|
||||
}
|
||||
}
|
||||
|
||||
Note _fromRow(DbNote row) {
|
||||
Future<void> _applySyncResponse(SyncResponse response) async {
|
||||
// Apply categories from server
|
||||
for (final SyncCategoryResponse catResponse in response.changes.categories) {
|
||||
await _database.upsertCategory(
|
||||
CategoriesCompanion(
|
||||
uuid: Value(catResponse.id),
|
||||
encryptedName: Value(catResponse.encryptedName),
|
||||
serverVersion: Value(catResponse.serverVersion),
|
||||
isDeleted: Value(catResponse.isDeleted),
|
||||
updatedAt: Value(catResponse.updatedAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Apply notes from server
|
||||
for (final SyncNoteResponse noteResponse in response.changes.notes) {
|
||||
final existingNote = await (_database.select(_database.notes)
|
||||
..where((n) => n.uuid.equals(noteResponse.id)))
|
||||
.getSingleOrNull();
|
||||
|
||||
// Decrypt note content
|
||||
String decryptedTitle = 'Encrypted';
|
||||
String decryptedBody = 'Encrypted';
|
||||
try {
|
||||
decryptedTitle = await NoteEncryption.decryptNote(
|
||||
noteResponse.encryptedTitle,
|
||||
_masterKey,
|
||||
);
|
||||
decryptedBody = await NoteEncryption.decryptNote(
|
||||
noteResponse.encryptedBody,
|
||||
_masterKey,
|
||||
);
|
||||
} catch (e) {
|
||||
// If decryption fails, keep default encrypted placeholders
|
||||
print('Failed to decrypt note ${noteResponse.id}: $e');
|
||||
}
|
||||
|
||||
if (existingNote != null) {
|
||||
// Update existing note
|
||||
await _database.updateNoteRow(
|
||||
DbNote(
|
||||
id: existingNote.id,
|
||||
uuid: noteResponse.id,
|
||||
title: decryptedTitle,
|
||||
body: decryptedBody,
|
||||
createdAt: existingNote.createdAt,
|
||||
updatedAt: noteResponse.updatedAt,
|
||||
sortIndex: noteResponse.position,
|
||||
serverVersion: noteResponse.serverVersion,
|
||||
isDeleted: noteResponse.isDeleted,
|
||||
categoryId: noteResponse.categoryId,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Insert new note
|
||||
await _database.into(_database.notes).insert(
|
||||
NotesCompanion(
|
||||
uuid: Value(noteResponse.id),
|
||||
title: Value(decryptedTitle),
|
||||
body: Value(decryptedBody),
|
||||
createdAt: Value(noteResponse.updatedAt),
|
||||
updatedAt: Value(noteResponse.updatedAt),
|
||||
sortIndex: Value(noteResponse.position),
|
||||
serverVersion: Value(noteResponse.serverVersion),
|
||||
isDeleted: Value(noteResponse.isDeleted),
|
||||
categoryId: Value(noteResponse.categoryId),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Note>> _loadNotesFromDatabase() async {
|
||||
final List<DbNote> rows = await _database.getAllNotes();
|
||||
return rows.map(_fromDbNote).toList();
|
||||
}
|
||||
|
||||
Note _fromDbNote(DbNote row) {
|
||||
return Note(
|
||||
id: row.id,
|
||||
uuid: row.uuid,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
index: row.sortIndex,
|
||||
serverVersion: row.serverVersion,
|
||||
isDeleted: row.isDeleted,
|
||||
categoryId: row.categoryId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Category _fromDbCategory(DbCategory row) {
|
||||
return Category(
|
||||
uuid: row.uuid,
|
||||
encryptedName: row.encryptedName,
|
||||
serverVersion: row.serverVersion,
|
||||
isDeleted: row.isDeleted,
|
||||
updatedAt: row.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import 'package:notas/models/note.dart';
|
||||
import 'package:notas/models/category.dart';
|
||||
|
||||
// DTOs para sincronización con el servidor
|
||||
|
||||
class SyncRequest {
|
||||
SyncRequest({
|
||||
DateTime? lastSyncAt,
|
||||
required this.changes,
|
||||
}) : lastSyncAt = lastSyncAt ?? DateTime.utc(1970, 1, 1);
|
||||
|
||||
final DateTime lastSyncAt;
|
||||
final SyncChanges changes;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'lastSyncAt': lastSyncAt.toIso8601String(),
|
||||
'changes': changes.toJson(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class SyncChanges {
|
||||
const SyncChanges({
|
||||
this.categories = const [],
|
||||
this.notes = const [],
|
||||
});
|
||||
|
||||
final List<SyncCategoryPayload> categories;
|
||||
final List<SyncNotePayload> notes;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
if (categories.isNotEmpty)
|
||||
'categories': categories.map((c) => c.toJson()).toList(),
|
||||
if (notes.isNotEmpty)
|
||||
'notes': notes.map((n) => n.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class SyncChangesResponse {
|
||||
const SyncChangesResponse({
|
||||
this.categories = const [],
|
||||
this.notes = const [],
|
||||
});
|
||||
|
||||
final List<SyncCategoryResponse> categories;
|
||||
final List<SyncNoteResponse> notes;
|
||||
|
||||
factory SyncChangesResponse.fromJson(Map<String, dynamic> json) {
|
||||
final List<dynamic> categoriesJson = json['categories'] as List<dynamic>? ?? [];
|
||||
final List<dynamic> notesJson = json['notes'] as List<dynamic>? ?? [];
|
||||
|
||||
return SyncChangesResponse(
|
||||
categories: categoriesJson
|
||||
.map((c) => SyncCategoryResponse.fromJson(c as Map<String, dynamic>))
|
||||
.toList(),
|
||||
notes: notesJson
|
||||
.map((n) => SyncNoteResponse.fromJson(n as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
class SyncCategoryPayload {
|
||||
const SyncCategoryPayload({
|
||||
required this.id,
|
||||
required this.encryptedName,
|
||||
required this.serverVersion,
|
||||
this.isDeleted = false,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final String id; // uuid
|
||||
final String encryptedName;
|
||||
final int serverVersion;
|
||||
final bool isDeleted;
|
||||
final DateTime updatedAt;
|
||||
|
||||
factory SyncCategoryPayload.fromCategory(Category category) {
|
||||
return SyncCategoryPayload(
|
||||
id: category.uuid,
|
||||
encryptedName: category.encryptedName,
|
||||
serverVersion: category.serverVersion,
|
||||
isDeleted: category.isDeleted,
|
||||
updatedAt: category.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'encrypted_name': encryptedName,
|
||||
'serverVersion': serverVersion,
|
||||
'isDeleted': isDeleted,
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class SyncNotePayload {
|
||||
const SyncNotePayload({
|
||||
required this.id,
|
||||
this.categoryId,
|
||||
required this.encryptedTitle,
|
||||
required this.encryptedBody,
|
||||
required this.serverVersion,
|
||||
this.position = 0,
|
||||
this.isDeleted = false,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final String id; // uuid
|
||||
final String? categoryId;
|
||||
final String encryptedTitle;
|
||||
final String encryptedBody;
|
||||
final int serverVersion;
|
||||
final int position;
|
||||
final bool isDeleted;
|
||||
final DateTime updatedAt;
|
||||
|
||||
factory SyncNotePayload.fromNote(
|
||||
Note note, {
|
||||
required String encryptedTitle,
|
||||
required String encryptedBody,
|
||||
}) {
|
||||
return SyncNotePayload(
|
||||
id: note.uuid,
|
||||
categoryId: note.categoryId,
|
||||
encryptedTitle: encryptedTitle,
|
||||
encryptedBody: encryptedBody,
|
||||
serverVersion: note.serverVersion,
|
||||
position: note.index,
|
||||
isDeleted: note.isDeleted,
|
||||
updatedAt: note.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
if (categoryId != null) 'categoryId': categoryId,
|
||||
'encrypted_title': encryptedTitle,
|
||||
'encrypted_body': encryptedBody,
|
||||
'serverVersion': serverVersion,
|
||||
if (position != 0) 'position': position,
|
||||
if (isDeleted) 'isDeleted': isDeleted,
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class SyncResponse {
|
||||
const SyncResponse({
|
||||
required this.serverTimestamp,
|
||||
required this.synced,
|
||||
required this.changes,
|
||||
});
|
||||
|
||||
final DateTime serverTimestamp;
|
||||
final bool synced;
|
||||
final SyncChangesResponse changes;
|
||||
|
||||
factory SyncResponse.fromJson(Map<String, dynamic> json) {
|
||||
return SyncResponse(
|
||||
serverTimestamp:
|
||||
DateTime.parse(json['serverTimestamp'] as String),
|
||||
synced: json['synced'] as bool? ?? false,
|
||||
changes: SyncChangesResponse.fromJson(
|
||||
json['changes'] as Map<String, dynamic>? ?? {}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SyncCategoryResponse {
|
||||
const SyncCategoryResponse({
|
||||
required this.id,
|
||||
required this.encryptedName,
|
||||
required this.serverVersion,
|
||||
this.isDeleted = false,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final String id; // uuid
|
||||
final String encryptedName;
|
||||
final int serverVersion;
|
||||
final bool isDeleted;
|
||||
final DateTime updatedAt;
|
||||
|
||||
factory SyncCategoryResponse.fromJson(Map<String, dynamic> json) {
|
||||
return SyncCategoryResponse(
|
||||
id: json['id'] as String,
|
||||
encryptedName: json['encrypted_name'] as String,
|
||||
serverVersion: json['serverVersion'] as int,
|
||||
isDeleted: json['isDeleted'] as bool? ?? false,
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Category toCategory() {
|
||||
return Category(
|
||||
uuid: id,
|
||||
encryptedName: encryptedName,
|
||||
serverVersion: serverVersion,
|
||||
isDeleted: isDeleted,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SyncNoteResponse {
|
||||
const SyncNoteResponse({
|
||||
required this.id,
|
||||
this.categoryId,
|
||||
required this.encryptedTitle,
|
||||
required this.encryptedBody,
|
||||
required this.serverVersion,
|
||||
this.position = 0,
|
||||
this.isDeleted = false,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final String id; // uuid
|
||||
final String? categoryId;
|
||||
final String encryptedTitle;
|
||||
final String encryptedBody;
|
||||
final int serverVersion;
|
||||
final int position;
|
||||
final bool isDeleted;
|
||||
final DateTime updatedAt;
|
||||
|
||||
factory SyncNoteResponse.fromJson(Map<String, dynamic> json) {
|
||||
return SyncNoteResponse(
|
||||
id: json['id'] as String,
|
||||
categoryId: json['categoryId'] as String?,
|
||||
encryptedTitle: json['encrypted_title'] as String,
|
||||
encryptedBody: json['encrypted_body'] as String,
|
||||
serverVersion: json['serverVersion'] as int,
|
||||
position: json['position'] as int? ?? 0,
|
||||
isDeleted: json['isDeleted'] as bool? ?? false,
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Note toNote() {
|
||||
return Note(
|
||||
uuid: id,
|
||||
title: 'Encrypted', // placeholder, será descifrado por la app
|
||||
body: 'Encrypted', // placeholder, será descifrado por la app
|
||||
createdAt: updatedAt,
|
||||
updatedAt: updatedAt,
|
||||
index: position,
|
||||
serverVersion: serverVersion,
|
||||
isDeleted: isDeleted,
|
||||
categoryId: categoryId,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user