From 5fa253ec32f9e4edad1f67de6ec322f42faf6555 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sat, 9 May 2026 14:58:59 +0200 Subject: [PATCH] keyring works --- .../src/main/kotlin/eu/twonly/MainActivity.kt | 4 + .../main/kotlin/io/crates/keyring/Keyring.kt | 14 +++ lib/main.dart | 39 +++--- .../generated/app_localizations.dart | 2 +- .../generated/app_localizations_de.dart | 2 +- .../generated/app_localizations_en.dart | 2 +- lib/src/services/backup/restore.backup.dart | 7 +- lib/src/services/user.service.dart | 44 +++++-- .../views/onboarding/register.view.dart | 12 +- rust/Cargo.lock | 98 +++++++++++++-- rust/Cargo.toml | 5 +- rust/src/backup/backup_archive.rs | 108 ++++++++++------ rust/src/backup/backup_password.rs | 115 ++++++++++++++++++ .../backup_passwordless/mod.rs | 0 .../backup_passwordless/types.rs | 0 rust/src/backup/mod.rs | 2 + rust/src/bridge/mod.rs | 1 - rust/src/context.rs | 2 +- rust/src/database/migrations/0001_initial.sql | 12 +- rust/src/database/mod.rs | 13 +- rust/src/database/tables/mod.rs | 102 +++++++++++++++- rust/src/database/tables/received_messages.rs | 39 +++--- rust/src/database/tables/sending_messages.rs | 35 ------ rust/src/error.rs | 33 +++++ rust/src/keys/backup_password.rs | 0 rust/src/keys/identity_key.rs | 2 +- rust/src/keys/main_key.rs | 41 +++---- rust/src/keys/mod.rs | 21 ++-- rust/src/secure_storage.rs | 35 ++++-- 29 files changed, 579 insertions(+), 211 deletions(-) create mode 100644 android/app/src/main/kotlin/io/crates/keyring/Keyring.kt create mode 100644 rust/src/backup/backup_password.rs rename rust/src/{keys => backup}/backup_passwordless/mod.rs (100%) rename rust/src/{keys => backup}/backup_passwordless/types.rs (100%) delete mode 100644 rust/src/database/tables/sending_messages.rs delete mode 100644 rust/src/keys/backup_password.rs diff --git a/android/app/src/main/kotlin/eu/twonly/MainActivity.kt b/android/app/src/main/kotlin/eu/twonly/MainActivity.kt index 0ef89f3d..e87f278e 100644 --- a/android/app/src/main/kotlin/eu/twonly/MainActivity.kt +++ b/android/app/src/main/kotlin/eu/twonly/MainActivity.kt @@ -6,6 +6,8 @@ import dev.darttools.flutter_android_volume_keydown.FlutterAndroidVolumeKeydownP import android.view.KeyEvent.KEYCODE_VOLUME_DOWN import android.view.KeyEvent.KEYCODE_VOLUME_UP import io.flutter.embedding.engine.FlutterEngine +import android.content.Context +import io.crates.keyring.Keyring class MainActivity : FlutterFragmentActivity() { @@ -24,6 +26,8 @@ class MainActivity : FlutterFragmentActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + Keyring.initializeNdkContext(applicationContext) + MediaStoreChannel.configure(flutterEngine, applicationContext) VideoCompressionChannel.configure(flutterEngine, applicationContext) } diff --git a/android/app/src/main/kotlin/io/crates/keyring/Keyring.kt b/android/app/src/main/kotlin/io/crates/keyring/Keyring.kt new file mode 100644 index 00000000..58830901 --- /dev/null +++ b/android/app/src/main/kotlin/io/crates/keyring/Keyring.kt @@ -0,0 +1,14 @@ +package io.crates.keyring + +import android.content.Context + +class Keyring { + companion object { + init { + // Replace with the name of your compiled Rust library + System.loadLibrary("rust_lib_twonly") + } + // The underlying Rust crate provides the implementation for this + external fun initializeNdkContext(context: Context) + } +} diff --git a/lib/main.dart b/lib/main.dart index 1b82d399..66174396 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -38,9 +38,9 @@ final _initMutex = Mutex(); /// This function is used to initialized the absolute minimum so it /// can also be used by the backend without the UI was loaded. -Future twonlyMinimumInitialization() async { +Future twonlyMinimumInitialization() async { Log.info('twonlyMinimumInitialization: called'); - await exclusiveAccess( + final hasStorageError = await exclusiveAccess( lockName: 'init', mutex: _initMutex, action: () async { @@ -54,15 +54,22 @@ Future twonlyMinimumInitialization() async { await initFlutterCallbacksForRust(); Log.info('twonlyMinimumInitialization: bridge.initializeTwonlyFlutter()'); - await bridge.initializeTwonlyFlutter( - config: bridge.InitConfig( - databaseDir: AppEnvironment.supportDir, - dataDir: AppEnvironment.supportDir, - ), - ); + try { + await bridge.initializeTwonlyFlutter( + config: bridge.InitConfig( + databaseDir: AppEnvironment.supportDir, + dataDir: AppEnvironment.supportDir, + ), + ); + } catch (e) { + Log.error(e); + return true; + } Log.info('twonlyMinimumInitialization: finished'); + return false; }, ); + return hasStorageError; } void main() async { @@ -72,18 +79,18 @@ void main() async { unawaited(StartupGuard.markAppStartup()); - await twonlyMinimumInitialization(); - + var storageError = await twonlyMinimumInitialization(); unawaited(initFCMService()); var userExists = false; - var storageError = false; - try { - userExists = await userService.tryInit(); - } catch (e) { - Log.error('Failed to initialize user session due to storage error: $e'); - storageError = true; + if (!storageError) { + try { + userExists = await userService.tryInit(); + } catch (e) { + Log.error('Failed to initialize user session due to storage error: $e'); + storageError = true; + } } if (Platform.isIOS && userExists) { diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index a18d6f96..1ad3b059 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -1511,7 +1511,7 @@ abstract class AppLocalizations { /// No description provided for @backupPasswordRequirement. /// /// In en, this message translates to: - /// **'Password must be at least 8 characters long.'** + /// **'Password must be at least 10 characters long.'** String get backupPasswordRequirement; /// No description provided for @backupExpertSettings. diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index d1cfd1c8..17e86ea2 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -777,7 +777,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get backupPasswordRequirement => - 'Das Passwort muss mindestens 8 Zeichen lang sein.'; + 'Das Passwort muss mindestens 10 Zeichen lang sein.'; @override String get backupExpertSettings => 'Experteneinstellungen'; diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 555d59c5..df0a57f8 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -771,7 +771,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get backupPasswordRequirement => - 'Password must be at least 8 characters long.'; + 'Password must be at least 10 characters long.'; @override String get backupExpertSettings => 'Expert settings'; diff --git a/lib/src/services/backup/restore.backup.dart b/lib/src/services/backup/restore.backup.dart index 3ca90187..b06c2fe6 100644 --- a/lib/src/services/backup/restore.backup.dart +++ b/lib/src/services/backup/restore.backup.dart @@ -108,10 +108,9 @@ Future handleBackupData( key: SecureStorageKeys.signalSignedPreKey, value: secureStorage[SecureStorageKeys.signalSignedPreKey] as String, ); - await storage.write( - key: SecureStorageKeys.userData, - value: secureStorage[SecureStorageKeys.userData] as String, - ); + final userDataMap = jsonDecode(secureStorage[SecureStorageKeys.userData] as String) as Map; + final userData = UserData.fromJson(userDataMap); + await UserService.save(userData); await UserService.update((u) { u.deviceId += 1; }); diff --git a/lib/src/services/user.service.dart b/lib/src/services/user.service.dart index 6bdb53fa..232c9813 100644 --- a/lib/src/services/user.service.dart +++ b/lib/src/services/user.service.dart @@ -7,6 +7,7 @@ import 'package:twonly/src/constants/secure_storage.keys.dart'; import 'package:twonly/src/model/json/userdata.model.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/secure_storage.dart'; +import 'package:twonly/src/utils/keyvalue.dart'; class UserService { late UserData currentUser; @@ -26,21 +27,42 @@ class UserService { static Future getUser() async { try { + // 1. Try to load from KeyValueStore (user.json) + final userDataMap = await KeyValueStore.get('user'); + if (userDataMap != null) { + return UserData.fromJson(userDataMap); + } + + // 2. If not found, try to load from SecureStorage (Migration path) final userDataJson = await SecureStorage.instance.read( key: SecureStorageKeys.userData, ); - if (userDataJson == null) { - return null; + + if (userDataJson != null) { + final userData = UserData.fromJson( + jsonDecode(userDataJson) as Map, + ); + + // 3. Run migration + await _migrateFromSecureStorage(userData); + return userData; } - return UserData.fromJson( - jsonDecode(userDataJson) as Map, - ); + + return null; } catch (e) { Log.error('could not load user: $e'); - rethrow; // Rethrow instead of returning null to distinguish error from missing user + rethrow; } } + static Future _migrateFromSecureStorage(UserData userData) async { + // Currently empty migration logic as requested, but we MUST store the data + await KeyValueStore.put('user', userData.toJson()); + + // Optional: Log migration + Log.info('Migrated user data from SecureStorage to KeyValueStore'); + } + static Future update( void Function(UserData userData) updateUser, ) async { @@ -53,10 +75,7 @@ class UserService { user.defaultShowTime = null; } updateUser(user); - await SecureStorage.instance.write( - key: SecureStorageKeys.userData, - value: jsonEncode(user), - ); + await KeyValueStore.put('user', user.toJson()); userService.currentUser = user; } catch (e) { Log.error('Could not update the user: $e'); @@ -66,6 +85,11 @@ class UserService { userService.triggerUserUpdate(); } + static Future save(UserData user) async { + await KeyValueStore.put('user', user.toJson()); + await userService.tryInit(); + } + void triggerUserUpdate() { _userDataUpdateController.add(null); } diff --git a/lib/src/visual/views/onboarding/register.view.dart b/lib/src/visual/views/onboarding/register.view.dart index 01deebc8..1a02b505 100644 --- a/lib/src/visual/views/onboarding/register.view.dart +++ b/lib/src/visual/views/onboarding/register.view.dart @@ -1,22 +1,19 @@ // ignore_for_file: avoid_dynamic_calls import 'dart:async'; -import 'dart:convert'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; -import 'package:twonly/src/constants/secure_storage.keys.dart'; import 'package:twonly/src/model/json/userdata.model.dart'; import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart'; import 'package:twonly/src/services/signal/identity.signal.dart'; +import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/pow.dart'; -import 'package:twonly/src/utils/secure_storage.dart'; import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/views/groups/group.view.dart'; @@ -141,12 +138,7 @@ class _RegisterViewState extends State { currentSetupPage: SetupPages.profile.name, )..appVersion = AppState.latestAppVersionId; - await SecureStorage.instance.write( - key: SecureStorageKeys.userData, - value: jsonEncode(userData), - ); - - await userService.tryInit(); + await UserService.save(userData); await apiService.authenticate(); widget.callbackOnSuccess(); diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 23a0e4a6..f9e95d67 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -34,7 +34,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] @@ -46,7 +46,7 @@ checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ "aead", "aes", - "cipher", + "cipher 0.4.4", "ctr", "ghash", "subtle", @@ -357,7 +357,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -389,7 +389,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] @@ -412,7 +412,7 @@ checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", "chacha20 0.9.1", - "cipher", + "cipher 0.4.4", "poly1305", "zeroize", ] @@ -438,10 +438,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "inout 0.1.4", "zeroize", ] +[[package]] +name = "cipher" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.1", + "inout 0.2.2", +] + [[package]] name = "cmov" version = "0.5.3" @@ -660,7 +671,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -1711,6 +1722,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -2095,6 +2115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" dependencies = [ "cc", + "openssl-sys", "pkg-config", "vcpkg", ] @@ -2287,7 +2308,7 @@ dependencies = [ "getrandom 0.2.17", "hex", "instant", - "scrypt", + "scrypt 0.11.0", "secp256k1", "serde", "serde_json", @@ -2470,6 +2491,28 @@ dependencies = [ "tls_codec", ] +[[package]] +name = "openssl-src" +version = "300.5.0+3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8ce546f549326b0e6052b649198487d91320875da901e7bd11a06d1ee3f9c2f" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + [[package]] name = "oslog" version = "0.2.0" @@ -2565,6 +2608,16 @@ dependencies = [ "hmac 0.12.1", ] +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.2", + "hmac 0.13.0", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3134,6 +3187,7 @@ dependencies = [ "prost-build", "protocols", "rand 0.10.1", + "scrypt 0.12.0", "serde", "sha2 0.10.9", "sqlx", @@ -3194,7 +3248,17 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher 0.5.1", ] [[package]] @@ -3219,11 +3283,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ "password-hash", - "pbkdf2", - "salsa20", + "pbkdf2 0.12.2", + "salsa20 0.10.2", "sha2 0.10.9", ] +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "pbkdf2 0.13.0", + "salsa20 0.11.0", + "sha2 0.11.0", +] + [[package]] name = "sec1" version = "0.7.3" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index abc70558..f3ef96ec 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -24,7 +24,9 @@ mdk-core = { version = "0.8.0", git = "https://github.com/marmot-protocol/mdk", ] } mdk-sqlite-storage = { version = "0.8.0", git = "https://github.com/marmot-protocol/mdk", rev = "7f809f8549458a0d7f7d885bcdd694023abf299c" } mdk-storage-traits = { version = "0.8.0", git = "https://github.com/marmot-protocol/mdk", rev = "7f809f8549458a0d7f7d885bcdd694023abf299c" } -libsqlite3-sys = { version = "0.35.0", features = ["bundled", "sqlcipher"] } +libsqlite3-sys = { version = "0.35.0", features = [ + "bundled-sqlcipher-vendored-openssl", +] } tokio = { version = "1.44", features = ["full"] } tracing = "0.1.44" rand = "0.10.1" @@ -42,6 +44,7 @@ keyring-core = "1" postcard = { version = "1.0", features = ["alloc"] } chrono = { version = "0.4", features = ["serde"] } zip = { version = "2.2.2", default-features = false, features = ["deflate"] } +scrypt = { version = "0.12", default-features = false } walkdir = "2.5.0" [target.'cfg(target_os = "ios")'.dependencies] # iOS backend: Requires the 'protected' feature for Data Protection Keychain diff --git a/rust/src/backup/backup_archive.rs b/rust/src/backup/backup_archive.rs index 1afdac6b..fc0b1f20 100644 --- a/rust/src/backup/backup_archive.rs +++ b/rust/src/backup/backup_archive.rs @@ -1,7 +1,7 @@ use crate::context::Context; use crate::database::Database; -use crate::error::Result; -use crate::keys::DatabaseKey; +use crate::error::{Result, TwonlyError}; +use crate::keys::{DatabaseKey, MainKey}; use std::fs::{remove_file, File}; use std::io::{copy, Cursor}; use std::path::PathBuf; @@ -23,7 +23,8 @@ impl BackupArchive { Ok(vec![ ("twonly.sqlite", database_dir.clone(), true, None), ("rust_db.sqlite", database_dir, true, Some(rust_db_key)), - ("user_discovery_config.json", data_dir, false, None), + ("user_discovery_config.json", data_dir.clone(), false, None), + ("user.json", data_dir.join("keyvalue"), false, None), ]) } @@ -38,7 +39,7 @@ impl BackupArchive { std::fs::create_dir_all(&backup_data_dir)?; for (file_name, source_dir, is_db, mut encryption_key) in Self::get_backup_files(ctx)? { - let file_path = source_dir.join(&file_name); + let file_path = source_dir.join(file_name); if !file_path.exists() { tracing::warn!( "Could not backup {} as it does not exist.", @@ -54,16 +55,20 @@ impl BackupArchive { encryption_key.is_none(), ) .await?; - let backup_database_file = backup_data_dir.join(&file_name).display().to_string(); + let backup_database_file = backup_data_dir.join(file_name).display().to_string(); db.create_backup(backup_database_file.as_str(), encryption_key.as_deref()) .await?; } else { - let file_backup = backup_data_dir.join(&file_name); + let file_backup = backup_data_dir.join(file_name); std::fs::copy(file_path, file_backup)?; } encryption_key.zeroize(); } + let mut keys = ctx.get_key_manager()?; + + let keys_serialized = postcard::to_allocvec(&keys)?; + let mut zip_data = Vec::new(); { @@ -71,6 +76,9 @@ impl BackupArchive { let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + zip.start_file(".key_manager.bin", options)?; + copy(&mut keys_serialized.as_slice(), &mut zip)?; + for entry in WalkDir::new(&backup_data_dir) { let entry = entry?; let path = entry.path(); @@ -87,8 +95,6 @@ impl BackupArchive { zip.finish()?; } - let mut keys = ctx.get_key_manager()?; - let zip_path = data_dir.join("temp_backup.zip"); std::fs::write(&zip_path, keys.main_key.encrypt_backup(&zip_data))?; @@ -98,13 +104,21 @@ impl BackupArchive { Ok(zip_path) } - pub(crate) async fn restore_from_backup(ctx: &Context, file_path: &PathBuf) -> Result<()> { + pub(crate) async fn restore_from_backup( + ctx: &Context, + main_key_bytes: &[u8], + file_path: &PathBuf, + ) -> Result<()> { let data_dir = PathBuf::from(&ctx.get_config()?.data_dir); - let mut keys = ctx.get_key_manager()?; + let main_key_arr: [u8; 32] = main_key_bytes + .try_into() + .map_err(|_| TwonlyError::Generic("Invalid main key length".to_string()))?; + + let mut main_key = MainKey::from_main_key(main_key_arr); let encrypted_zip = std::fs::read(file_path)?; - let zip_content = keys.main_key.decrypt_backup(&encrypted_zip)?; + let zip_content = main_key.decrypt_backup(&encrypted_zip)?; let restore_temp_dir = data_dir.join("restore_temp"); @@ -120,6 +134,15 @@ impl BackupArchive { let mut file = archive.by_index(i)?; if file.is_file() { + let name = file.name().to_string(); + if name == ".key_manager.bin" { + let mut data = Vec::new(); + copy(&mut file, &mut data)?; + let key_manager: crate::keys::KeyManager = postcard::from_bytes(&data)?; + key_manager.store_to_keychain(ctx.get_secure_storage()?)?; + continue; + } + let enclosed_name = file.enclosed_name(); if let Some(name) = enclosed_name.as_ref().and_then(|p| p.file_name()) { let restored_file = restore_temp_dir.join(name); @@ -129,9 +152,9 @@ impl BackupArchive { } for (file_name, target_dir, is_db, _) in Self::get_backup_files(ctx)? { - let src = restore_temp_dir.join(&file_name); + let src = restore_temp_dir.join(file_name); if src.exists() { - let dst = target_dir.join(&file_name); + let dst = target_dir.join(file_name); if is_db { // Remove existing database and its temporary files (WAL, SHM) let _ = remove_file(&dst); @@ -143,7 +166,7 @@ impl BackupArchive { } } - keys.zeroize(); + main_key.zeroize(); std::fs::remove_dir_all(&restore_temp_dir)?; Ok(()) @@ -152,6 +175,8 @@ impl BackupArchive { #[cfg(test)] mod tests { + use crate::{database::tables::received_messages::ReceivedMessage, keys::KeyManager}; + use super::*; use tempfile::tempdir; @@ -172,7 +197,14 @@ mod tests { { let config = ctx.get_config().unwrap(); let rust_db_path = PathBuf::from(&config.database_dir).join("rust_db.sqlite"); - let key_manager = ctx.get_key_manager().unwrap(); + let mut key_manager = ctx.get_key_manager().unwrap(); + key_manager + .identity_keys + .push(crate::keys::IdentityKey::Nost()); + key_manager + .store_to_keychain(ctx.get_secure_storage().unwrap()) + .unwrap(); + let db = Database::new( &rust_db_path.display().to_string(), Some(&key_manager.main_key.get_database_key(DatabaseKey::RustDb)), @@ -181,13 +213,9 @@ mod tests { .await .unwrap(); - crate::database::tables::received_messages::ReceivedMessage::insert( - &db.pool, - "sender1", - b"original message", - ) - .await - .unwrap(); + ReceivedMessage::insert(&db.pool, 1, b"original message") + .await + .unwrap(); // Add a file let config_file = PathBuf::from(&config.data_dir).join("user_discovery_config.json"); @@ -198,6 +226,9 @@ mod tests { let backup_path = BackupArchive::create_backup(&ctx).await.unwrap(); assert!(backup_path.exists()); + // Save the original main key bytes + let original_main_key = *ctx.get_key_manager().unwrap().main_key.as_bytes(); + // 3. Modify data (to simulate state before restore) { let config = ctx.get_config().unwrap(); @@ -211,20 +242,23 @@ mod tests { .await .unwrap(); - crate::database::tables::received_messages::ReceivedMessage::insert( - &db.pool, - "sender2", - b"new message", - ) - .await - .unwrap(); + ReceivedMessage::insert(&db.pool, 2, b"new message") + .await + .unwrap(); let config_file = PathBuf::from(&config.data_dir).join("user_discovery_config.json"); std::fs::write(config_file, "new config").unwrap(); + + // Delete old keys to ensure they will be actually restored + + let key_manager = KeyManager::generate().unwrap(); + key_manager + .store_to_keychain(&ctx.get_secure_storage().unwrap()) + .unwrap(); } // 4. Restore backup - BackupArchive::restore_from_backup(&ctx, &backup_path) + BackupArchive::restore_from_backup(&ctx, &original_main_key, &backup_path) .await .unwrap(); @@ -241,18 +275,22 @@ mod tests { .await .unwrap(); - let messages = - crate::database::tables::received_messages::ReceivedMessage::get_all(&db.pool) - .await - .unwrap(); + let messages = ReceivedMessage::get_all(&db.pool).await.unwrap(); // Should only have the original message because restore overwrites assert_eq!(messages.len(), 1); - assert_eq!(messages[0].sender_id, "sender1"); + assert_eq!(messages[0].sender_id, 1); assert_eq!(messages[0].content, b"original message"); let config_file = PathBuf::from(&config.data_dir).join("user_discovery_config.json"); let config_content = std::fs::read_to_string(config_file).unwrap(); assert_eq!(config_content, "original config"); + + let key_manager = ctx.get_key_manager().unwrap(); + assert_eq!(key_manager.identity_keys.len(), 1); + match &key_manager.identity_keys[0] { + crate::keys::IdentityKey::Nost() => {} + _ => panic!("Wrong identity key!"), + } } } } diff --git a/rust/src/backup/backup_password.rs b/rust/src/backup/backup_password.rs new file mode 100644 index 00000000..8d91a63d --- /dev/null +++ b/rust/src/backup/backup_password.rs @@ -0,0 +1,115 @@ +use crate::error::{Result, TwonlyError}; +use crate::keys::KeyManager; +use aes_gcm::aead::rand_core::RngCore; +use aes_gcm::aead::{Aead, KeyInit, OsRng}; +use aes_gcm::{Aes256Gcm, Nonce}; +use scrypt::{scrypt, Params}; +use serde::{Deserialize, Serialize}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +#[derive(Debug, Clone, PartialEq, Zeroize, ZeroizeOnDrop, Serialize, Deserialize)] +pub(crate) struct BackupPasswordKeys { + backup_id: [u8; 32], + encryption_key: [u8; 32], +} + +impl BackupPasswordKeys { + pub(crate) fn new(backup_id: [u8; 32], encryption_key: [u8; 32]) -> Self { + Self { + backup_id, + encryption_key, + } + } + + pub(crate) fn from_password(password: &str, salt: &str) -> Result { + let params = Params::new(17, 8, 1)?; + let mut output = [0u8; 64]; + + scrypt(password.as_bytes(), salt.as_bytes(), ¶ms, &mut output)?; + + let mut backup_id = [0u8; 32]; + let mut encryption_key = [0u8; 32]; + backup_id.copy_from_slice(&output[0..32]); + encryption_key.copy_from_slice(&output[32..64]); + + Ok(Self::new(backup_id, encryption_key)) + } +} + +#[derive(Debug, PartialEq, Zeroize, ZeroizeOnDrop, Serialize, Deserialize)] +pub(crate) struct BackupPlainTextContent { + pub(crate) user_id: i64, + pub(crate) key_manager: KeyManager, +} + +impl BackupPlainTextContent { + fn get_encrypted_backup(&self) -> Result> { + let Some(keys) = &self.key_manager.backup_password else { + return Err(TwonlyError::Generic("No backup password".into())); + }; + + let serialized_bytes = postcard::to_allocvec(&self)?; + + let key = aes_gcm::Key::::from_slice(&keys.encryption_key); + let cipher = Aes256Gcm::new(key); + + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher.encrypt(nonce, serialized_bytes.as_slice())?; + + let mut encrypted_bytes = vec![]; + encrypted_bytes.extend_from_slice(&nonce_bytes); + encrypted_bytes.extend_from_slice(&ciphertext); + + Ok(encrypted_bytes) + } + + pub(crate) fn from_encrypted_backup( + encrypted_bytes: &[u8], + keys: &BackupPasswordKeys, + ) -> Result { + if encrypted_bytes.len() < 12 { + return Err(TwonlyError::Generic( + "Invalid encrypted backup length".into(), + )); + } + + let (nonce_bytes, ciphertext) = encrypted_bytes.split_at(12); + let nonce = Nonce::from_slice(nonce_bytes); + + let key = aes_gcm::Key::::from_slice(&keys.encryption_key); + let cipher = Aes256Gcm::new(key); + + let decrypted_bytes = cipher.decrypt(nonce, ciphertext)?; + + Ok(postcard::from_bytes(&decrypted_bytes)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_backup_encryption_decryption() { + let mut key_manager = KeyManager::generate().unwrap(); + let password = "my_secure_password"; + let salt = "my_random_salt"; + + let keys = BackupPasswordKeys::from_password(password, salt).unwrap(); + key_manager.backup_password = Some(keys.clone()); + + let content = BackupPlainTextContent { + user_id: 12345, + key_manager, + }; + + let encrypted = content.get_encrypted_backup().unwrap(); + let decrypted = BackupPlainTextContent::from_encrypted_backup(&encrypted, &keys).unwrap(); + + assert_eq!(content.user_id, decrypted.user_id); + assert_eq!(content.key_manager.main_key, decrypted.key_manager.main_key); + } +} diff --git a/rust/src/keys/backup_passwordless/mod.rs b/rust/src/backup/backup_passwordless/mod.rs similarity index 100% rename from rust/src/keys/backup_passwordless/mod.rs rename to rust/src/backup/backup_passwordless/mod.rs diff --git a/rust/src/keys/backup_passwordless/types.rs b/rust/src/backup/backup_passwordless/types.rs similarity index 100% rename from rust/src/keys/backup_passwordless/types.rs rename to rust/src/backup/backup_passwordless/types.rs diff --git a/rust/src/backup/mod.rs b/rust/src/backup/mod.rs index e2e6fa43..73396f42 100644 --- a/rust/src/backup/mod.rs +++ b/rust/src/backup/mod.rs @@ -1 +1,3 @@ mod backup_archive; +pub(crate) mod backup_password; +mod backup_passwordless; diff --git a/rust/src/bridge/mod.rs b/rust/src/bridge/mod.rs index 6bb8dd98..3616b2e1 100644 --- a/rust/src/bridge/mod.rs +++ b/rust/src/bridge/mod.rs @@ -2,7 +2,6 @@ pub mod callbacks; pub mod wrapper; -use std::path::Path; use std::sync::Arc; use crate::bridge::callbacks::user_discovery::{ diff --git a/rust/src/context.rs b/rust/src/context.rs index 425cb76a..50caf212 100644 --- a/rust/src/context.rs +++ b/rust/src/context.rs @@ -99,7 +99,7 @@ impl Context { Err(err) => { tracing::error!("{err}"); if rust_db_path.exists() { - tracing::error!("Rust Database exsist, while the key manager not"); + tracing::error!("Rust Database exsist, while the key manager not. This must be a secure storage error."); return Err(TwonlyError::SecureStorageError); } tracing::info!("Generating a new key manager."); diff --git a/rust/src/database/migrations/0001_initial.sql b/rust/src/database/migrations/0001_initial.sql index 84945152..715bcda8 100644 --- a/rust/src/database/migrations/0001_initial.sql +++ b/rust/src/database/migrations/0001_initial.sql @@ -2,15 +2,7 @@ CREATE TABLE IF NOT EXISTS received_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, - sender_id TEXT NOT NULL, + sender_id BIGINT NOT NULL, content BLOB NOT NULL, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, -); - -CREATE TABLE IF NOT EXISTS sending_messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - recipient_id TEXT NOT NULL, - content BLOB NOT NULL, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - status TEXT DEFAULT 'pending' + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP ); diff --git a/rust/src/database/mod.rs b/rust/src/database/mod.rs index 3ebc7af0..d78c47cb 100644 --- a/rust/src/database/mod.rs +++ b/rust/src/database/mod.rs @@ -96,6 +96,7 @@ impl Database { #[cfg(test)] mod tests { use crate::database::tables::received_messages::ReceivedMessage; + use chrono::Utc; use super::*; use tempfile::tempdir; @@ -109,7 +110,7 @@ mod tests { // 1. Create and initialize database with key let db = Database::new(&db_path, Some(key), false).await.unwrap(); - ReceivedMessage::insert(&db.pool, "sender1", b"hello world") + ReceivedMessage::insert(&db.pool, 1, b"hello world") .await .unwrap(); @@ -124,7 +125,7 @@ mod tests { let db = Database::new(&db_path, Some(key), false).await.unwrap(); let messages = ReceivedMessage::get_all(&db.pool).await.unwrap(); assert_eq!(messages.len(), 1); - assert_eq!(messages[0].sender_id, "sender1"); + assert_eq!(messages[0].sender_id, 1); assert_eq!(messages[0].content, b"hello world"); } @@ -137,7 +138,7 @@ mod tests { let key = "secure_password"; let db = Database::new(&db_path, Some(key), false).await.unwrap(); - ReceivedMessage::insert(&db.pool, "sender1", b"hello world") + ReceivedMessage::insert(&db.pool, 1, b"hello world") .await .unwrap(); @@ -154,7 +155,7 @@ mod tests { let backup_db = Database::new(&backup_path, Some(key), false).await.unwrap(); let messages = ReceivedMessage::get_all(&backup_db.pool).await.unwrap(); assert_eq!(messages.len(), 1); - assert_eq!(messages[0].sender_id, "sender1"); + assert_eq!(messages[0].sender_id, 1); } #[tokio::test] @@ -165,7 +166,7 @@ mod tests { let backup_path = dir.path().join("backup_plain.sqlite").display().to_string(); let db = Database::new(&db_path, None, false).await.unwrap(); - ReceivedMessage::insert(&db.pool, "sender1", b"hello world") + ReceivedMessage::insert(&db.pool, 1, b"hello world") .await .unwrap(); @@ -175,6 +176,6 @@ mod tests { let backup_db = Database::new(&backup_path, None, false).await.unwrap(); let messages = ReceivedMessage::get_all(&backup_db.pool).await.unwrap(); assert_eq!(messages.len(), 1); - assert_eq!(messages[0].sender_id, "sender1"); + assert_eq!(messages[0].sender_id, 1); } } diff --git a/rust/src/database/tables/mod.rs b/rust/src/database/tables/mod.rs index b87cf5a5..c8a04d57 100644 --- a/rust/src/database/tables/mod.rs +++ b/rust/src/database/tables/mod.rs @@ -1,2 +1,102 @@ pub mod received_messages; -pub mod sending_messages; + +#[macro_export] +macro_rules! generate_insert { + ($table:literal, $fn_name:ident, $($field:ident : $ty:ty),+) => { + pub async fn $fn_name( + pool: &sqlx::SqlitePool, + $($field: $ty),+ + ) -> crate::error::Result { + let sql = format!( + "INSERT INTO {} ({}) VALUES ({}) RETURNING id", + $table, + vec![$(stringify!($field)),+].join(", "), + vec!["?"; [$({stringify!($field); 1}),+].len()].join(", ") + ); + + let row: (i64,) = sqlx::query_as(sqlx::AssertSqlSafe(sql)) + $(.bind($field))+ + .fetch_one(pool) + .await?; + + Ok(row.0) + } + }; +} + +#[macro_export] +macro_rules! generate_select { + ($table:literal, $fn_name:ident) => { + pub async fn $fn_name(pool: &sqlx::SqlitePool) -> crate::error::Result> { + let sql = format!("SELECT * FROM {}", $table); + let results = sqlx::query_as::<_, Self>(sqlx::AssertSqlSafe(sql)) + .fetch_all(pool) + .await?; + Ok(results) + } + }; + ($table:literal, $fn_name:ident, $($field:ident : $ty:ty),+) => { + pub async fn $fn_name(pool: &sqlx::SqlitePool, $($field: $ty),+) -> crate::error::Result> { + let mut sql = format!("SELECT * FROM {} WHERE ", $table); + let mut filters = Vec::new(); + $( + filters.push(format!("{} = ?", stringify!($field))); + )+ + sql.push_str(&filters.join(" AND ")); + + let results = sqlx::query_as::<_, Self>(sqlx::AssertSqlSafe(sql)) + $(.bind($field))+ + .fetch_all(pool) + .await?; + Ok(results) + } + }; +} + +#[macro_export] +macro_rules! generate_table_tests { + ( + $struct:ident, + $insert_fn:ident ($($arg:expr),+), + $select_all_fn:ident + ) => { + #[cfg(test)] + mod tests { + use super::*; + use crate::database::Database; + use tempfile::tempdir; + + #[tokio::test] + async fn test_generated_basic() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("test.sqlite").display().to_string(); + let db = Database::new(&db_path, None, false).await.unwrap(); + + let _id = $struct::$insert_fn(&db.pool, $($arg),+).await.unwrap(); + let all = $struct::$select_all_fn(&db.pool).await.unwrap(); + assert_eq!(all.len(), 1); + } + } + }; +} + +#[macro_export] +macro_rules! generate_test_select { + ($struct:ident, $insert_fn:ident ($($arg:expr),+), $select_fn:ident ($($sel_arg:expr),+)) => { + paste::paste! { + #[cfg(test)] + #[tokio::test] + async fn []() { + use crate::database::Database; + use tempfile::tempdir; + let dir = tempdir().unwrap(); + let db_path = dir.path().join("test.sqlite").display().to_string(); + let db = Database::new(&db_path, None, false).await.unwrap(); + + $struct::$insert_fn(&db.pool, $($arg),+).await.unwrap(); + let results = $struct::$select_fn(&db.pool, $($sel_arg),+).await.unwrap(); + assert_eq!(results.len(), 1); + } + } + }; +} diff --git a/rust/src/database/tables/received_messages.rs b/rust/src/database/tables/received_messages.rs index 800ab79f..50502a02 100644 --- a/rust/src/database/tables/received_messages.rs +++ b/rust/src/database/tables/received_messages.rs @@ -1,34 +1,25 @@ -use crate::error::Result; use chrono::{DateTime, Utc}; -use sqlx::{FromRow, SqlitePool}; +use sqlx::FromRow; -#[derive(Debug, FromRow)] +#[derive(Debug, FromRow, PartialEq, Clone)] pub struct ReceivedMessage { pub id: i64, - pub sender_id: String, + pub sender_id: i64, pub content: Vec, pub timestamp: DateTime, } impl ReceivedMessage { - pub async fn insert(pool: &SqlitePool, sender_id: &str, content: &[u8]) -> Result { - let result = - sqlx::query("INSERT INTO received_messages (sender_id, content) VALUES (?, ?)") - .bind(sender_id) - .bind(content) - .execute(pool) - .await?; - - Ok(result.last_insert_rowid()) - } - - pub async fn get_all(pool: &SqlitePool) -> Result> { - let messages = sqlx::query_as::<_, Self>( - "SELECT id, sender_id, content, timestamp FROM received_messages ORDER BY timestamp DESC", - ) - .fetch_all(pool) - .await?; - - Ok(messages) - } + crate::generate_insert!( + "received_messages", + insert, + sender_id: i64, + content: &[u8] + ); + crate::generate_select!("received_messages", get_all); + crate::generate_select!("received_messages", get_by_sender, sender_id: i64); } + +crate::generate_table_tests!(ReceivedMessage, insert(1, b"hello world"), get_all); + +crate::generate_test_select!(ReceivedMessage, insert(1, b"hello world"), get_by_sender(1)); diff --git a/rust/src/database/tables/sending_messages.rs b/rust/src/database/tables/sending_messages.rs deleted file mode 100644 index d9341266..00000000 --- a/rust/src/database/tables/sending_messages.rs +++ /dev/null @@ -1,35 +0,0 @@ -use crate::error::Result; -use chrono::{DateTime, Utc}; -use sqlx::{FromRow, SqlitePool}; - -#[derive(Debug, FromRow)] -pub struct SendingMessage { - pub id: i64, - pub recipient_id: String, - pub content: Vec, - pub timestamp: DateTime, - pub status: String, -} - -impl SendingMessage { - pub async fn insert(pool: &SqlitePool, recipient_id: &str, content: &[u8]) -> Result { - let result = - sqlx::query("INSERT INTO sending_messages (recipient_id, content) VALUES (?, ?)") - .bind(recipient_id) - .bind(content) - .execute(pool) - .await?; - - Ok(result.last_insert_rowid()) - } - - pub async fn get_all(pool: &SqlitePool) -> Result> { - let messages = sqlx::query_as::<_, Self>( - "SELECT id, recipient_id, content, timestamp, status FROM sending_messages ORDER BY timestamp DESC", - ) - .fetch_all(pool) - .await?; - - Ok(messages) - } -} diff --git a/rust/src/error.rs b/rust/src/error.rs index 53a6f4a5..599b15a1 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -1,4 +1,6 @@ +use hex::FromHexError; use protocols::user_discovery::error::UserDiscoveryError; +use scrypt::errors::{InvalidOutputLen, InvalidParams}; use thiserror::Error; use zip::result::ZipError; @@ -8,24 +10,36 @@ pub type Result = core::result::Result; pub enum TwonlyError { #[error("global twonly is not initialized")] Initialization, + #[error("Tried to access the wrong context")] WrongContext, + #[error("init_flutter_callbacks was not called")] MissingCallbackInitialization, + #[error("Could not find the given database")] DatabaseNotFound, + + #[error("main_key could not be loaded from the key_chain")] + MissingMainKey, + #[error("{0}")] UserDiscoveryError(#[from] UserDiscoveryError), + #[error("Error in dart callback")] DartError, + #[error( "Storage error: database exists but master key could not be loaded from secure storage" )] SecureStorageError, + #[error("{0}")] SqliteError(#[from] sqlx::Error), + #[error("{0}")] Generic(String), + #[error("{0}")] IoError(#[from] std::io::Error), @@ -34,6 +48,19 @@ pub enum TwonlyError { #[error("{0}")] Walkdir(#[from] walkdir::Error), + + #[error("{0}")] + Postcard(#[from] postcard::Error), + + #[error("{0}")] + HexError(#[from] FromHexError), + + #[error("{0}")] + InvalidParams(#[from] InvalidParams), + #[error("{0}")] + InvalidOutputLen(#[from] InvalidOutputLen), + #[error("AES-GCM error")] + AesGcm, } impl From for TwonlyError { @@ -47,3 +74,9 @@ impl From for UserDiscoveryError { UserDiscoveryError::Store(error.to_string()) } } + +impl From for TwonlyError { + fn from(_: aes_gcm::Error) -> Self { + TwonlyError::AesGcm + } +} diff --git a/rust/src/keys/backup_password.rs b/rust/src/keys/backup_password.rs deleted file mode 100644 index e69de29b..00000000 diff --git a/rust/src/keys/identity_key.rs b/rust/src/keys/identity_key.rs index 3e6b24b6..5f32ad6e 100644 --- a/rust/src/keys/identity_key.rs +++ b/rust/src/keys/identity_key.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use zeroize::{Zeroize, ZeroizeOnDrop}; -#[derive(Zeroize, ZeroizeOnDrop, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Zeroize, ZeroizeOnDrop, Serialize, Deserialize)] pub(crate) enum IdentityKey { Nost(), Signal(), diff --git a/rust/src/keys/main_key.rs b/rust/src/keys/main_key.rs index ddf63e20..9a90885d 100644 --- a/rust/src/keys/main_key.rs +++ b/rust/src/keys/main_key.rs @@ -9,7 +9,7 @@ use zeroize::{Zeroize, ZeroizeOnDrop}; /// `MainKey` is responsible for handling the cryptographically secure, immutable master key. /// It uses HKDF to derive subordinate keys (Authentication Token, Backup Key, Media Main Key). -#[derive(Zeroize, ZeroizeOnDrop, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Zeroize, ZeroizeOnDrop, Serialize, Deserialize)] pub struct MainKey { /// The 32-byte main master key main_key: [u8; 32], @@ -33,6 +33,22 @@ impl MainKey { Self { main_key } } + pub fn as_bytes(&self) -> &[u8; 32] { + &self.main_key + } + + /// Download token required to download a backup. + /// This ensures that the user who tries to download the backup must have knowledge over the + /// main key + pub fn backup_download_token(&self) -> [u8; 32] { + self.derive_key(b"backup_download_token") + } + + /// Uses as a password to authenitcate agains the server + pub fn server_auth_token(&self) -> [u8; 32] { + self.derive_key(b"server_auth_token") + } + /// Derives the database encryption key. pub(crate) fn get_database_key(&self, db: DatabaseKey) -> String { let db_name = match db { @@ -43,11 +59,6 @@ impl MainKey { hex::encode(key) } - /// Derives the authentication token uploaded to the server for session authentication. - pub fn get_authentication_token(&self) -> [u8; 32] { - self.derive_key(b"auth_token") - } - /// Encrypts a backup payload. /// The backup key is derived using HKDF from the main key. pub fn encrypt_backup(&self, backup_payload: &[u8]) -> Vec { @@ -126,24 +137,6 @@ mod tests { assert_eq!(km.main_key, km2.main_key); } - #[test] - fn test_get_authentication_token() { - let km1 = MainKey::generate(); - let token1 = km1.get_authentication_token(); - - let km2 = MainKey::from_main_key(km1.main_key); - let token2 = km2.get_authentication_token(); - - // Tokens derived from the same main key should match - assert_eq!(token1, token2); - - let km3 = MainKey::generate(); - let token3 = km3.get_authentication_token(); - - // Different main keys should produce different tokens - assert_ne!(token1, token3); - } - #[test] fn test_backup_encryption_decryption_success() { let km = MainKey::generate(); diff --git a/rust/src/keys/mod.rs b/rust/src/keys/mod.rs index 88d36044..215cdb4e 100644 --- a/rust/src/keys/mod.rs +++ b/rust/src/keys/mod.rs @@ -1,20 +1,22 @@ -mod backup_password; -mod backup_passwordless; mod identity_key; mod main_key; +use crate::backup::backup_password::BackupPasswordKeys; +use crate::error::Result; +use crate::error::TwonlyError; +pub(crate) use crate::keys::identity_key::IdentityKey; pub(crate) use crate::keys::main_key::{DatabaseKey, MainKey}; use crate::secure_storage::SecureStorage; -use crate::{error::Result, keys::identity_key::IdentityKey}; use serde::{Deserialize, Serialize}; use zeroize::{Zeroize, ZeroizeOnDrop}; const KEY_MANAGER_ID: &str = "twonly_key_manager"; -#[derive(Zeroize, ZeroizeOnDrop, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Zeroize, ZeroizeOnDrop, Serialize, Deserialize)] pub(crate) struct KeyManager { pub(crate) main_key: MainKey, pub(crate) identity_keys: Vec, + pub(crate) backup_password: Option, } impl KeyManager { @@ -22,6 +24,7 @@ impl KeyManager { Ok(KeyManager { main_key: MainKey::generate(), identity_keys: vec![], + backup_password: None, }) } @@ -29,20 +32,18 @@ impl KeyManager { pub fn try_from_keychain(storage: &SecureStorage) -> Result { let hex_key = storage .read(KEY_MANAGER_ID)? - .ok_or_else(|| "Main key not found in keychain".to_string())?; + .ok_or_else(|| TwonlyError::MissingMainKey)?; - let bytes = hex::decode(hex_key).map_err(|e| format!("Failed to decode hex key: {}", e))?; + let bytes = hex::decode(hex_key)?; - let main_key: KeyManager = postcard::from_bytes(&bytes) - .map_err(|e| format!("Failed to deserialize KeyManager: {}", e))?; + let main_key: KeyManager = postcard::from_bytes(&bytes)?; Ok(main_key) } /// Stores the main key into the secure keychain/local storage. pub fn store_to_keychain(&self, storage: &SecureStorage) -> Result<()> { - let serialized = postcard::to_allocvec(self) - .map_err(|e| format!("Failed to serialize KeyManager: {}", e))?; + let serialized = postcard::to_allocvec(self)?; let hex_key = hex::encode(serialized); storage.write(KEY_MANAGER_ID, &hex_key)?; diff --git a/rust/src/secure_storage.rs b/rust/src/secure_storage.rs index 0d8ad4d4..0d44de4a 100644 --- a/rust/src/secure_storage.rs +++ b/rust/src/secure_storage.rs @@ -32,9 +32,13 @@ impl SecureStorage { #[cfg(target_os = "ios")] { + use std::collections::HashMap; let group = "CN332ZUGRP.eu.twonly.shared"; - let store = apple_native_keyring_store::protected::Store::with_application_group(group) - .map_err(|e| format!("Failed to init iOS Protected Store: {}", e))?; + let mut config = HashMap::new(); + config.insert("access-group", group); + let store = + apple_native_keyring_store::protected::Store::new_with_configuration(&config) + .map_err(|e| format!("Failed to init iOS Protected Store: {}", e))?; keyring_core::set_default_store(store); } @@ -62,8 +66,7 @@ impl SecureStorage { /// * `key` - The identifier (account name) for the secret. /// * `value` - The secret string to store. pub fn write(&self, key: &str, value: &str) -> Result<(), String> { - let entry = Entry::new(&self.service_name, key) - .map_err(|e| format!("Failed to create keyring entry: {}", e))?; + let entry = self.get_entry(key)?; entry .set_password(value) @@ -77,8 +80,7 @@ impl SecureStorage { /// Returns `Ok(Some(String))` if the key exists, `Ok(None)` if it doesn't, /// or an `Err` if a system error occurs. pub fn read(&self, key: &str) -> Result, String> { - let entry = Entry::new(&self.service_name, key) - .map_err(|e| format!("Failed to create keyring entry: {}", e))?; + let entry = self.get_entry(key)?; match entry.get_password() { Ok(password) => Ok(Some(password)), @@ -91,8 +93,7 @@ impl SecureStorage { /// /// If the key does not exist, this function returns `Ok(())` (idempotent). pub fn delete(&self, key: &str) -> Result<(), String> { - let entry = Entry::new(&self.service_name, key) - .map_err(|e| format!("Failed to create keyring entry: {}", e))?; + let entry = self.get_entry(key)?; match entry.delete_credential() { Ok(()) => Ok(()), @@ -100,6 +101,24 @@ impl SecureStorage { Err(e) => Err(format!("Failed to delete secret from keyring: {}", e)), } } + + /// Helper to create a keyring entry with the appropriate platform modifiers. + fn get_entry(&self, key: &str) -> Result { + #[cfg(target_os = "ios")] + { + use std::collections::HashMap; + let mut modifiers = HashMap::new(); + modifiers.insert("access-policy", "AfterFirstUnlock"); + Entry::new_with_modifiers(&self.service_name, key, &modifiers) + .map_err(|e| format!("Failed to create keyring entry with modifiers: {}", e)) + } + + #[cfg(not(target_os = "ios"))] + { + Entry::new(&self.service_name, key) + .map_err(|e| format!("Failed to create keyring entry: {}", e)) + } + } } #[cfg(test)]